From 7a4b6d2be5823d03f91a448751947a68add0a285 Mon Sep 17 00:00:00 2001 From: matthewtrepte Date: Tue, 5 May 2026 13:13:43 -0700 Subject: [PATCH 001/133] Add Debug Visualization Markers to Newton Based Visualizers (#5473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Extend debug Visualization Markers, which are supported in the Kit Visualizer, to the Newton Visualizers. These Visualization Markers are various shapes and models which can be added to envs for debugging / showing extra information. Also added filtering for partial visualization (when we filtered which envs are shown the in the visualizer, we also filter the markers) For general USD mesh marker support in Newton, a followup PR will be required, once a Newton API for general USD -> Newton Mesh conversion is added (see https://github.com/newton-physics/newton/issues/2667) Checked velocity arrows, dexcubes, raycasts, frames, goal markers ## Type of change - New feature (non-breaking change which adds functionality) - 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: matthewtrepte --- docs/source/features/visualization.rst | 26 +- .../mtrepte-expand_viz_markers-2.skip | 0 source/isaaclab/isaaclab/assets/asset_base.py | 27 +- .../isaaclab/managers/action_manager.py | 23 +- .../isaaclab/managers/command_manager.py | 26 +- .../isaaclab/markers/vis_marker_registry.py | 66 +++ .../isaaclab/markers/visualization_markers.py | 486 +++++++--------- .../isaaclab/isaaclab/sensors/sensor_base.py | 29 +- .../isaaclab/sim/simulation_context.py | 7 + .../test_simulation_context_visualizers.py | 344 ++++++++++++ .../kit/kit_visualization_markers.py | 221 ++++++++ .../kit/kit_visualizer.py | 2 +- .../newton/newton_visualization_markers.py | 531 ++++++++++++++++++ .../newton/newton_visualizer.py | 17 +- .../rerun/rerun_visualizer.py | 32 +- .../viser/viser_visualizer.py | 35 +- 16 files changed, 1490 insertions(+), 382 deletions(-) create mode 100644 source/isaaclab/changelog.d/mtrepte-expand_viz_markers-2.skip create mode 100644 source/isaaclab/isaaclab/markers/vis_marker_registry.py create mode 100644 source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualization_markers.py create mode 100644 source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualization_markers.py diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index 4efbf2470698..c68816c0da00 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -22,16 +22,16 @@ Isaac Lab supports four visualizer backends, each optimized for different use ca - Key Features * - **Omniverse** - High-fidelity, Isaac Sim integration - - USD, visual markers, live plots + - USD, visualization markers, live plots * - **Newton** - Fast iteration - - Low overhead, visual markers + - Low overhead, visualization markers * - **Rerun** - Remote viewing, replay - - Webviewer, time scrubbing, recording export + - Webviewer, time scrubbing, recording export, visualization markers * - **Viser** - Web-based remote visualization, sharing, recording - - Warp-based rendering, browser-based, share URL + - Warp-based rendering, browser-based, share URL, visualization markers *The following visualizers are shown training the Isaac-Velocity-Flat-Anymal-D-v0 environment.* @@ -284,9 +284,9 @@ Omniverse Visualizer **Main Features:** - Native USD stage integration -- Visualization markers for debugging (arrows, frames, points, etc.) - Live plots for monitoring training metrics - Full Isaac Sim rendering capabilities and tooling +- Visualization markers for debugging (arrows, frames, object targets, etc.) **Core Configuration:** @@ -316,10 +316,10 @@ Newton Visualizer **Main Features:** - Lightweight OpenGL rendering with low overhead -- Visualization markers (joints, contacts, springs, COM) - Simulation and rendering pause controls - Adjustable update frequency for performance tuning - Some customizable rendering options (shadows, sky, wireframe) +- Visualization markers (joints, contacts, springs, COM, debug markers) **Interactive Controls:** @@ -388,6 +388,7 @@ Rerun Visualizer - Metadata logging and filtering - Recording to .rrd files for offline replay (.rrd files can be opened with ctrl+O from the web viewer) - Timeline scrubbing and playback controls of recordings +- Visualization debug markers **Core Configuration:** @@ -432,6 +433,7 @@ server, allowing you to view and interact with the scene from any browser. - Optional public share URL for remote viewing - Recording to ``.viser`` format for replay - Environment filtering to control which environments are rendered +- Visualization debug markers **Launch with Viser:** @@ -464,7 +466,7 @@ server, allowing you to view and interact with the scene from any browser. .. note:: - The Viser visualizer does not currently support markers or live plots. + The Viser visualizer does not currently support live plots. Performance Note @@ -497,15 +499,9 @@ the num of environments can be overwritten and decreased using ``--num_envs``: The FPS control in the Rerun visualizer UI may not affect the visualization frame rate in all configurations. -**Newton Visualizer Contact and Center of Mass Markers** +**Live Plots** -Contact and center of mass markers are not yet supported in the Newton visualizer. This will be addressed in a future release. - - -**Viser Visualizer Markers and Live Plots** - -The Viser visualizer does not currently support visualization markers or live plots. For these features, use the -Omniverse or Newton visualizers. +Currently, live plots are only available in the Kit Visualizer. **Viser Visualizer Renderer Requirement** diff --git a/source/isaaclab/changelog.d/mtrepte-expand_viz_markers-2.skip b/source/isaaclab/changelog.d/mtrepte-expand_viz_markers-2.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/isaaclab/assets/asset_base.py b/source/isaaclab/isaaclab/assets/asset_base.py index a7da0d36fe12..b72788083ad3 100644 --- a/source/isaaclab/isaaclab/assets/asset_base.py +++ b/source/isaaclab/isaaclab/assets/asset_base.py @@ -205,16 +205,13 @@ def set_debug_vis(self, debug_vis: bool) -> bool: if debug_vis: if self._debug_vis_handle is None: sim_ctx = SimulationContext.instance() - if "physx" in sim_ctx.physics_manager.__name__.lower(): - import omni.kit.app - - app_interface = omni.kit.app.get_app_interface() - self._debug_vis_handle = app_interface.get_post_update_event_stream().create_subscription_to_pop( - lambda event, obj=weakref.proxy(self): obj._debug_vis_callback(event) - ) + if sim_ctx is not None: + self._debug_vis_handle = sim_ctx.vis_marker_registry.add_debug_vis_callback(self) else: - if self._debug_vis_handle is not None: - self._debug_vis_handle.unsubscribe() + sim_ctx = SimulationContext.instance() + if sim_ctx is not None: + sim_ctx.vis_marker_registry.clear_debug_vis_callback(self) + else: self._debug_vis_handle = None # return success return True @@ -404,8 +401,10 @@ def _initialize_callback(self, event): def _invalidate_initialize_callback(self, event): """Invalidates the scene elements.""" self._is_initialized = False - if self._debug_vis_handle is not None: - self._debug_vis_handle.unsubscribe() + sim_ctx = SimulationContext.instance() + if sim_ctx is not None: + sim_ctx.vis_marker_registry.clear_debug_vis_callback(self) + else: self._debug_vis_handle = None def _on_prim_deletion(self, event) -> None: @@ -435,6 +434,8 @@ def _clear_callbacks(self) -> None: if self._prim_deletion_handle is not None: self._prim_deletion_handle.deregister() self._prim_deletion_handle = None - if self._debug_vis_handle is not None: - self._debug_vis_handle.unsubscribe() + sim_ctx = SimulationContext.instance() + if sim_ctx is not None: + sim_ctx.vis_marker_registry.clear_debug_vis_callback(self) + else: self._debug_vis_handle = None diff --git a/source/isaaclab/isaaclab/managers/action_manager.py b/source/isaaclab/isaaclab/managers/action_manager.py index 7d49d0245d7b..d711596e5f5a 100644 --- a/source/isaaclab/isaaclab/managers/action_manager.py +++ b/source/isaaclab/isaaclab/managers/action_manager.py @@ -9,7 +9,6 @@ import inspect import re -import weakref from abc import abstractmethod from collections.abc import Sequence from typing import TYPE_CHECKING, Any @@ -17,11 +16,6 @@ import torch from prettytable import PrettyTable -from isaaclab.utils.version import has_kit - -if has_kit(): - import omni.kit.app - from isaaclab.envs.utils.io_descriptors import GenericActionIODescriptor from .manager_base import ManagerBase, ManagerTermBase @@ -66,9 +60,11 @@ def __init__(self, cfg: ActionTermCfg, env: ManagerBasedEnv): def __del__(self): """Unsubscribe from the callbacks.""" - if self._debug_vis_handle: - self._debug_vis_handle.unsubscribe() - self._debug_vis_handle = None + env = getattr(self, "_env", None) + sim = getattr(env, "sim", None) + registry = getattr(sim, "vis_marker_registry", None) + if registry is not None: + registry.clear_debug_vis_callback(self) """ Properties. @@ -135,15 +131,10 @@ def set_debug_vis(self, debug_vis: bool) -> bool: if debug_vis: # create a subscriber for the post update event if it doesn't exist if self._debug_vis_handle is None: - app_interface = omni.kit.app.get_app_interface() - self._debug_vis_handle = app_interface.get_post_update_event_stream().create_subscription_to_pop( - lambda event, obj=weakref.proxy(self): obj._debug_vis_callback(event) - ) + self._debug_vis_handle = self._env.sim.vis_marker_registry.add_debug_vis_callback(self) else: # remove the subscriber if it exists - if self._debug_vis_handle is not None: - self._debug_vis_handle.unsubscribe() - self._debug_vis_handle = None + self._env.sim.vis_marker_registry.clear_debug_vis_callback(self) # return success return True diff --git a/source/isaaclab/isaaclab/managers/command_manager.py b/source/isaaclab/isaaclab/managers/command_manager.py index f92dbd2799f3..9493fc81fbcf 100644 --- a/source/isaaclab/isaaclab/managers/command_manager.py +++ b/source/isaaclab/isaaclab/managers/command_manager.py @@ -8,7 +8,6 @@ from __future__ import annotations import inspect -import weakref from abc import abstractmethod from collections.abc import Sequence from typing import TYPE_CHECKING @@ -16,17 +15,12 @@ import torch from prettytable import PrettyTable -from isaaclab.utils.version import has_kit - from .manager_base import ManagerBase, ManagerTermBase from .manager_term_cfg import CommandTermCfg if TYPE_CHECKING: from isaaclab.envs import ManagerBasedRLEnv -if has_kit(): - import omni.kit.app - class CommandTerm(ManagerTermBase): """The base class for implementing a command term. @@ -65,9 +59,11 @@ def __init__(self, cfg: CommandTermCfg, env: ManagerBasedRLEnv): def __del__(self): """Unsubscribe from the callbacks.""" - if self._debug_vis_handle: - self._debug_vis_handle.unsubscribe() - self._debug_vis_handle = None + env = getattr(self, "_env", None) + sim = getattr(env, "sim", None) + registry = getattr(sim, "vis_marker_registry", None) + if registry is not None: + registry.clear_debug_vis_callback(self) """ Properties @@ -106,18 +102,12 @@ def set_debug_vis(self, debug_vis: bool) -> bool: # toggle debug visualization objects self._set_debug_vis_impl(debug_vis) # toggle debug visualization handles - if debug_vis and has_kit(): - # create a subscriber for the post update event if it doesn't exist + if debug_vis: if self._debug_vis_handle is None: - app_interface = omni.kit.app.get_app_interface() - self._debug_vis_handle = app_interface.get_post_update_event_stream().create_subscription_to_pop( - lambda event, obj=weakref.proxy(self): obj._debug_vis_callback(event) - ) + self._debug_vis_handle = self._env.sim.vis_marker_registry.add_debug_vis_callback(self) else: # remove the subscriber if it exists - if self._debug_vis_handle is not None: - self._debug_vis_handle.unsubscribe() - self._debug_vis_handle = None + self._env.sim.vis_marker_registry.clear_debug_vis_callback(self) # return success return True diff --git a/source/isaaclab/isaaclab/markers/vis_marker_registry.py b/source/isaaclab/isaaclab/markers/vis_marker_registry.py new file mode 100644 index 000000000000..a50d26713971 --- /dev/null +++ b/source/isaaclab/isaaclab/markers/vis_marker_registry.py @@ -0,0 +1,66 @@ +# 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 + +"""Registry for visualization marker state.""" + +from __future__ import annotations + +import weakref +from collections.abc import Callable +from typing import Any + + +class VisMarkerRegistry: + """Tracks visualization marker callbacks and active marker groups.""" + + def __init__(self): + self._callbacks: dict[str, Callable[[Any], None]] = {} + self._groups: dict[str, Any] = {} + + def add_callback(self, name: str, callback: Callable[[Any], None]) -> str: + """Register a callback invoked before marker-capable visualizers step each render tick.""" + self._callbacks[name] = callback + return name + + def add_debug_vis_callback(self, owner: Any) -> str: + """Register an owner's debug visualization callback. + + Args: + owner: Object implementing ``_debug_vis_callback(event)``. + + Returns: + Callback identifier that can be passed to :meth:`remove_callback`. + """ + callback_id = f"visualization_marker:{type(owner).__name__}:{id(owner)}" + owner_ref = weakref.proxy(owner) + return self.add_callback(callback_id, lambda event: owner_ref._debug_vis_callback(event)) + + def clear_debug_vis_callback(self, owner: Any) -> None: + """Clear an owner's registered debug visualization callback, if any.""" + callback_id = getattr(owner, "_debug_vis_handle", None) + if callback_id is not None: + self.remove_callback(callback_id) + owner._debug_vis_handle = None + + def remove_callback(self, callback_id: str) -> None: + """Remove a visualization marker callback if it exists.""" + self._callbacks.pop(callback_id, None) + + def dispatch_callbacks(self, event: Any = None) -> None: + """Invoke all registered visualization marker callbacks.""" + for callback in list(self._callbacks.values()): + callback(event) + + def set_group(self, group_id: str, state: Any) -> None: + """Set or replace one visualization marker group state.""" + self._groups[group_id] = state + + def remove_group(self, group_id: str) -> None: + """Remove one visualization marker group state if present.""" + self._groups.pop(group_id, None) + + def get_groups(self) -> dict[str, Any]: + """Return all active visualization marker groups.""" + return self._groups diff --git a/source/isaaclab/isaaclab/markers/visualization_markers.py b/source/isaaclab/isaaclab/markers/visualization_markers.py index 8c8504958c22..2e418bbecade 100644 --- a/source/isaaclab/isaaclab/markers/visualization_markers.py +++ b/source/isaaclab/isaaclab/markers/visualization_markers.py @@ -3,20 +3,15 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""A class to coordinate groups of visual markers (such as spheres, frames or arrows) -using `UsdGeom.PointInstancer`_ class. +"""Backend-agnostic facade for coordinating groups of visual markers. -The class :class:`VisualizationMarkers` is used to create a group of visual markers and -visualize them in the viewport. The markers are represented as :class:`UsdGeom.PointInstancer` prims -in the USD stage. The markers are created as prototypes in the :class:`UsdGeom.PointInstancer` prim -and are instanced in the :class:`UsdGeom.PointInstancer` prim. The markers can be visualized by -passing the indices of the marker prototypes and their translations, orientations and scales. -The marker prototypes can be configured with the :class:`VisualizationMarkersCfg` class. - -.. _UsdGeom.PointInstancer: https://graphics.pixar.com/usd/dev/api/class_usd_geom_point_instancer.html +The :class:`VisualizationMarkers` class is used to create a group of visual +markers and visualize them through the active visualizer backends. The marker +prototypes are configured with :class:`VisualizationMarkersCfg`, and individual +marker instances can be updated by passing prototype indices and their +translations, orientations, and scales. """ -# needed to import for allowing type-hinting: np.ndarray | torch.Tensor | None from __future__ import annotations import logging @@ -24,54 +19,48 @@ import numpy as np import torch -from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics, Vt - import isaaclab.sim as sim_utils -from isaaclab.utils.version import has_kit from .visualization_markers_cfg import VisualizationMarkersCfg -# import logger logger = logging.getLogger(__name__) class VisualizationMarkers: - """A class to coordinate groups of visual markers (loaded from USD). - - This class allows visualization of different UI markers in the scene, such as points and frames. - The class wraps around the `UsdGeom.PointInstancer`_ for efficient handling of objects - in the stage via instancing the created marker prototype prims. - - A marker prototype prim is a reusable template prim used for defining variations of objects - in the scene. For example, a sphere prim can be used as a marker prototype prim to create - multiple sphere prims in the scene at different locations. Thus, prototype prims are useful - for creating multiple instances of the same prim in the scene. - - The class parses the configuration to create different the marker prototypes into the stage. Each marker - prototype prim is created as a child of the :class:`UsdGeom.PointInstancer` prim. The prim path for the - marker prim is resolved using the key of the marker in the :attr:`VisualizationMarkersCfg.markers` - dictionary. The marker prototypes are created using the :meth:`isaaclab.sim.utils.prims.create_prim` - function, and then instanced using :class:`UsdGeom.PointInstancer` prim to allow creating multiple - instances of the marker prims. - - Switching between different marker prototypes is possible by calling the :meth:`visualize` method with - the prototype indices corresponding to the marker prototype. The prototype indices are based on the order - in the :attr:`VisualizationMarkersCfg.markers` dictionary. For example, if the dictionary has two markers, - "marker1" and "marker2", then their prototype indices are 0 and 1 respectively. The prototype indices - can be passed as a list or array of integers. + """Coordinate groups of visual markers across active visualizer backends. + + This class allows visualization of different UI markers in the scene, such + as points, frames, arrows, and shapes. Marker prototypes are reusable + templates that define variations of objects to visualize. For example, a + sphere marker prototype can be used to create many sphere marker instances + at different locations. + + The class parses the configuration to create the marker prototypes in each + active backend. The marker prototype name comes from the key in the + :attr:`VisualizationMarkersCfg.markers` dictionary, and prototype indices + are based on the dictionary order. For example, if the dictionary has two + markers, ``"marker1"`` and ``"marker2"``, their prototype indices are 0 + and 1 respectively. These indices can be passed to :meth:`visualize` as a + list or array of integers. + + Switching between marker prototypes is possible by calling + :meth:`visualize` with the corresponding prototype indices. The marker + transforms are updated only for the arguments that are provided; omitted + translations, orientations, scales, or marker indices are left unchanged + when supported by the active backend. Usage: - The following snippet shows how to create 24 sphere markers with a radius of 1.0 at random translations - within the range [-1.0, 1.0]. The first 12 markers will be colored red and the rest will be colored green. + The following snippet creates 24 sphere markers at random translations. + The first 12 markers use the first prototype and the rest use the + second prototype. .. code-block:: python + import numpy as np + import isaaclab.sim as sim_utils - from isaaclab.markers import VisualizationMarkersCfg, VisualizationMarkers + from isaaclab.markers import VisualizationMarkers, VisualizationMarkersCfg - # Create the markers configuration - # This creates two marker prototypes, "marker1" and "marker2" which are spheres with a radius of 1.0. - # The color of "marker1" is red and the color of "marker2" is green. cfg = VisualizationMarkersCfg( prim_path="/World/Visuals/testMarkers", markers={ @@ -79,48 +68,35 @@ class VisualizationMarkers: radius=1.0, visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0)), ), - "marker2": VisualizationMarkersCfg.SphereCfg( + "marker2": sim_utils.SphereCfg( radius=1.0, visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 1.0, 0.0)), ), }, ) - # Create the markers instance - # This will create a UsdGeom.PointInstancer prim at the given path along with the marker prototypes. - marker = VisualizationMarkers(cfg) - # Set position of the marker - # -- randomly sample translations between -1.0 and 1.0 + marker = VisualizationMarkers(cfg) marker_translations = np.random.uniform(-1.0, 1.0, (24, 3)) - # -- this will create 24 markers at the given translations - # note: the markers will all be `marker1` since the marker indices are not given + + # This creates 24 markers using the first prototype because marker + # indices are not given. marker.visualize(translations=marker_translations) - # alter the markers based on their prototypes indices - # first 12 markers will be marker1 and the rest will be marker2 - # 0 -> marker1, 1 -> marker2 + # 0 -> marker1, 1 -> marker2. Since translations are omitted here, + # only the marker prototypes are changed. marker_indices = [0] * 12 + [1] * 12 - # this will change the marker prototypes at the given indices - # note: the translations of the markers will not be changed from the previous call - # since the translations are not given. marker.visualize(marker_indices=marker_indices) - # alter the markers based on their prototypes indices and translations + # Update both marker prototypes and translations. marker.visualize(marker_indices=marker_indices, translations=marker_translations) - .. _UsdGeom.PointInstancer: https://graphics.pixar.com/usd/dev/api/class_usd_geom_point_instancer.html - + The public API intentionally remains the historical marker API: + :meth:`set_visibility`, :meth:`is_visible`, and :meth:`visualize`. Backend + details are delegated to Kit and Newton marker implementations. """ def __init__(self, cfg: VisualizationMarkersCfg): - """Initialize the class. - - When the class is initialized, the :class:`UsdGeom.PointInstancer` is created into the stage - and the marker prims are registered into it. - - .. note:: - If a prim already exists at the given path, the function will find the next free path - and create the :class:`UsdGeom.PointInstancer` prim there. + """Initialize visualization marker backends from the active simulation context. Args: cfg: The configuration for the markers. @@ -128,28 +104,18 @@ def __init__(self, cfg: VisualizationMarkersCfg): Raises: ValueError: When no markers are provided in the :obj:`cfg`. """ - # get next free path for the prim - prim_path = sim_utils.get_next_free_prim_path(cfg.prim_path) - # create a new prim - self.stage = sim_utils.get_current_stage() - self._instancer_manager = UsdGeom.PointInstancer.Define(self.stage, prim_path) - # store inputs - self.prim_path = prim_path + if len(cfg.markers) == 0: + raise ValueError(f"The `cfg.markers` cannot be empty. Received: {cfg.markers}") + self.cfg = cfg - # check if any markers is provided - if len(self.cfg.markers) == 0: - raise ValueError(f"The `cfg.markers` cannot be empty. Received: {self.cfg.markers}") - - # create a child prim for the marker - self._add_markers_prototypes(self.cfg.markers) - # Note: We need to do this the first time to initialize the instancer. - # Otherwise, the instancer will not be "created" and the function `GetInstanceIndices()` will fail. - self._instancer_manager.GetProtoIndicesAttr().Set(list(range(self.num_prototypes))) - self._instancer_manager.GetPositionsAttr().Set([Gf.Vec3f(0.0)] * self.num_prototypes) - self._count = self.num_prototypes + self.prim_path = cfg.prim_path + self._count = len(cfg.markers) + self._is_visible = True + self._backends: list[object] = [] + self._ensure_backends_initialized() def __str__(self) -> str: - """Return: A string representation of the class.""" + """Return a string representation of the marker group.""" msg = f"VisualizationMarkers(prim_path={self.prim_path})" msg += f"\n\tCount: {self.count}" msg += f"\n\tNumber of prototypes: {self.num_prototypes}" @@ -158,10 +124,6 @@ def __str__(self) -> str: msg += f"\n\t\t[Index: {index}]: {name}: {marker.to_dict()}" return msg - """ - Properties. - """ - @property def num_prototypes(self) -> int: """The number of marker prototypes available.""" @@ -170,35 +132,20 @@ def num_prototypes(self) -> int: @property def count(self) -> int: """The total number of marker instances.""" - # TODO: Update this when the USD API is available (Isaac Sim 2023.1) - # return self._instancer_manager.GetInstanceCount() return self._count - """ - Operations. - """ - def set_visibility(self, visible: bool): - """Sets the visibility of the markers. - - The method does this through the USD API. - - Args: - visible: flag to set the visibility. - """ - imageable = UsdGeom.Imageable(self._instancer_manager) - if visible: - imageable.MakeVisible() - else: - imageable.MakeInvisible() + """Set marker visibility for all initialized backends.""" + self._is_visible = visible + self._ensure_backends_initialized() + for backend in self._backends: + backend.set_visibility(visible) def is_visible(self) -> bool: - """Checks the visibility of the markers. - - Returns: - True if the markers are visible, False otherwise. - """ - return self._instancer_manager.GetVisibilityAttr().Get() != UsdGeom.Tokens.invisible + """Return whether the marker group is visible.""" + if self._backends: + return any(backend.is_visible() for backend in self._backends) + return self._is_visible def visualize( self, @@ -207,187 +154,158 @@ def visualize( scales: np.ndarray | torch.Tensor | None = None, marker_indices: list[int] | np.ndarray | torch.Tensor | None = None, ): - """Update markers in the viewport. + """Update markers in all initialized visualizer backends. .. note:: - If the prim `PointInstancer` is hidden in the stage, the function will simply return - without updating the markers. This helps in unnecessary computation when the markers - are not visible. - - Whenever updating the markers, the input arrays must have the same number of elements - in the first dimension. If the number of elements is different, the `UsdGeom.PointInstancer` - will raise an error complaining about the mismatch. - - Additionally, the function supports dynamic update of the markers. This means that the - number of markers can change between calls. For example, if you have 24 points that you - want to visualize, you can pass 24 translations, orientations, and scales. If you want to - visualize only 12 points, you can pass 12 translations, orientations, and scales. The - function will automatically update the number of markers in the scene. - - The function will also update the marker prototypes based on their prototype indices. For instance, - if you have two marker prototypes, and you pass the following marker indices: [0, 1, 0, 1], the function - will update the first and third markers with the first prototype, and the second and fourth markers - with the second prototype. This is useful when you want to visualize different markers in the same - scene. The list of marker indices must have the same number of elements as the translations, orientations, - or scales. If the number of elements is different, the function will raise an error. + If the markers are hidden, the function returns without updating + backend marker state. This avoids unnecessary work while debug + visualization is disabled. - .. caution:: - This function will update all the markers instanced from the prototypes. That means - if you have 24 markers, you will need to pass 24 translations, orientations, and scales. + Whenever updating the markers, the input arrays must have the same + number of elements in the first dimension. Backends generally require + all per-marker arrays to describe the same number of marker instances. + + The function supports dynamic updates of the marker count. For example, + if you have 24 points to visualize, you can pass 24 translations, + orientations, and scales. If you later want to visualize only 12 + points, you can pass arrays with 12 rows and the backends will update + the number of marker instances. - If you want to update only a subset of the markers, you will need to handle the indices - yourself and pass the complete arrays to this function. + The function also updates marker prototypes based on prototype indices. + For instance, if there are two marker prototypes and you pass marker + indices ``[0, 1, 0, 1]``, the first and third markers use the first + prototype and the second and fourth markers use the second prototype. + + .. caution:: + This function updates all markers instanced from the prototypes. If + you want to update only a subset of markers, handle the indexing + externally and pass complete arrays to this function. Args: - translations: Translations w.r.t. parent prim frame. Shape is (M, 3). - Defaults to None, which means left unchanged. - orientations: Quaternion orientations (x, y, z, w) w.r.t. parent prim frame. Shape is (M, 4). - Defaults to None, which means left unchanged. - scales: Scale applied before any rotation is applied. Shape is (M, 3). - Defaults to None, which means left unchanged. - marker_indices: Decides which marker prototype to visualize. Shape is (M). - Defaults to None, which means left unchanged provided that the total number of markers - is the same as the previous call. If the number of markers is different, the function - will update the number of markers in the scene. + translations: Translations w.r.t. parent prim frame. Shape is + (M, 3). Defaults to None, which means left unchanged. + orientations: Quaternion orientations (x, y, z, w) w.r.t. parent + prim frame. Shape is (M, 4). Defaults to None, which means left + unchanged. + scales: Scale applied before any rotation is applied. Shape is + (M, 3). Defaults to None, which means left unchanged. + marker_indices: Decides which marker prototype to visualize. Shape + is (M). Defaults to None, which means left unchanged provided + that the total number of markers is the same as the previous + call. If the number of markers is different, the function will + update the number of markers. Raises: ValueError: When input arrays do not follow the expected shapes. ValueError: When the function is called with all None arguments. """ - # check if it is visible (if not then let's not waste time) + self._ensure_backends_initialized() + # If markers are hidden, do not spend time normalizing or dispatching + # marker state to the active backends. if not self.is_visible(): return - # check if we have any markers to visualize - num_markers = 0 - # resolve inputs - # -- position - if translations is not None: - if isinstance(translations, torch.Tensor): - translations = translations.detach().cpu().numpy() - # check that shape is correct - if translations.shape[1] != 3 or len(translations.shape) != 2: - raise ValueError(f"Expected `translations` to have shape (M, 3). Received: {translations.shape}.") - # apply translations - self._instancer_manager.GetPositionsAttr().Set(Vt.Vec3fArray.FromNumpy(translations)) - # update number of markers - num_markers = translations.shape[0] - # -- orientation - if orientations is not None: - if isinstance(orientations, torch.Tensor): - orientations = orientations.detach().cpu().numpy() - # check that shape is correct - if orientations.shape[1] != 4 or len(orientations.shape) != 2: - raise ValueError(f"Expected `orientations` to have shape (M, 4). Received: {orientations.shape}.") - # apply orientations (already in xyzw format expected by USD) - self._instancer_manager.GetOrientationsAttr().Set(Vt.QuathArray.FromNumpy(orientations)) - # update number of markers - num_markers = orientations.shape[0] - # -- scales - if scales is not None: - if isinstance(scales, torch.Tensor): - scales = scales.detach().cpu().numpy() - # check that shape is correct - if scales.shape[1] != 3 or len(scales.shape) != 2: - raise ValueError(f"Expected `scales` to have shape (M, 3). Received: {scales.shape}.") - # apply scales - self._instancer_manager.GetScalesAttr().Set(Vt.Vec3fArray.FromNumpy(scales)) - # update number of markers - num_markers = scales.shape[0] - # -- status - if marker_indices is not None or num_markers != self._count: - # apply marker indices - if marker_indices is not None: - if isinstance(marker_indices, torch.Tensor): - marker_indices = marker_indices.detach().cpu().numpy() - elif isinstance(marker_indices, list): - marker_indices = np.array(marker_indices) - # check that shape is correct - if len(marker_indices.shape) != 1: - raise ValueError(f"Expected `marker_indices` to have shape (M,). Received: {marker_indices.shape}.") - # apply proto indices - self._instancer_manager.GetProtoIndicesAttr().Set(Vt.IntArray.FromNumpy(marker_indices)) - # update number of markers - num_markers = marker_indices.shape[0] - else: - # check that number of markers is not zero - if num_markers == 0: - raise ValueError("Number of markers cannot be zero! Hint: The function was called with no inputs?") - # set all markers to be the first prototype - self._instancer_manager.GetProtoIndicesAttr().Set([0] * num_markers) - # set number of markers - self._count = num_markers - - """ - Helper functions. - """ - - def _add_markers_prototypes(self, markers_cfg: dict[str, sim_utils.SpawnerCfg]): - """Adds markers prototypes to the scene and sets the markers instancer to use them.""" - # add markers based on config - for name, cfg in markers_cfg.items(): - # resolve prim path - marker_prim_path = f"{self.prim_path}/{name}" - # create a child prim for the marker - marker_prim = cfg.func(prim_path=marker_prim_path, cfg=cfg) - # make the asset uninstanceable (in case it is) - # point instancer defines its own prototypes so if an asset is already instanced, this doesn't work. - self._process_prototype_prim(marker_prim) - # add child reference to point instancer - self._instancer_manager.GetPrototypesRel().AddTarget(marker_prim_path) - # check that we loaded all the prototypes - prototypes = self._instancer_manager.GetPrototypesRel().GetTargets() - if len(prototypes) != len(markers_cfg): - raise RuntimeError( - f"Failed to load all the prototypes. Expected: {len(markers_cfg)}. Received: {len(prototypes)}." - ) - def _process_prototype_prim(self, prim: Usd.Prim): - """Process a prim and its descendants to make them suitable for defining prototypes. + norm_translations = self._to_tensor(translations, expected_width=3, name="translations") + norm_orientations = self._to_tensor(orientations, expected_width=4, name="orientations") + norm_scales = self._to_tensor(scales, expected_width=3, name="scales") + norm_marker_indices = self._to_index_tensor(marker_indices) + target_device = self._resolve_target_device( + norm_translations, norm_orientations, norm_scales, norm_marker_indices + ) + if norm_translations is not None: + norm_translations = norm_translations.to(device=target_device) + if norm_orientations is not None: + norm_orientations = norm_orientations.to(device=target_device) + if norm_scales is not None: + norm_scales = norm_scales.to(device=target_device) + if norm_marker_indices is not None: + norm_marker_indices = norm_marker_indices.to(device=target_device) - Point instancer defines its own prototypes so if an asset is already instanced, this doesn't work. - This function checks if the prim at the specified prim path and its descendants are instanced. - If so, it makes the respective prim uninstanceable by disabling instancing on the prim. - - Additionally, it makes the prim invisible to secondary rays. This is useful when we do not want - to see the marker prims on camera images. + num_markers = 0 + for value in (norm_translations, norm_orientations, norm_scales, norm_marker_indices): + if value is not None: + num_markers = value.shape[0] + + if norm_marker_indices is None and num_markers != 0 and num_markers != self._count: + norm_marker_indices = torch.zeros(num_markers, dtype=torch.int32, device=target_device) + elif norm_marker_indices is None and num_markers == 0: + if all(value is None for value in (norm_translations, norm_orientations, norm_scales)): + raise ValueError("Number of markers cannot be zero! Hint: The function was called with no inputs?") + num_markers = self._count + + for backend in self._backends: + backend.visualize(norm_translations, norm_orientations, norm_scales, norm_marker_indices) + + if num_markers != 0: + self._count = num_markers + + def __del__(self): + for backend in getattr(self, "_backends", []): + if hasattr(backend, "close"): + backend.close() + + def _ensure_backends_initialized(self) -> None: + sim = sim_utils.SimulationContext.instance() + if sim is None: + self._ensure_kit_backend() + return - Args: - prim: The prim to check. - """ - # check if prim is valid - if not prim.IsValid(): - raise ValueError(f"Prim at path '{prim.GetPrimAtPath()}' is not valid.") - # iterate over all prims under prim-path - all_prims = [prim] - while len(all_prims) > 0: - # get current prim - child_prim = all_prims.pop(0) - # check if it is physics body -> if so, remove it - if child_prim.HasAPI(UsdPhysics.ArticulationRootAPI): - child_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) - child_prim.RemoveAppliedSchema("PhysxArticulationAPI") - if child_prim.HasAPI(UsdPhysics.RigidBodyAPI): - child_prim.RemoveAPI(UsdPhysics.RigidBodyAPI) - child_prim.RemoveAppliedSchema("PhysxRigidBodyAPI") - if child_prim.IsA(UsdPhysics.Joint): - child_prim.GetAttribute("physics:jointEnabled").Set(False) - # check if prim is instanced -> if so, make it uninstanceable - if child_prim.IsInstance(): - child_prim.SetInstanceable(False) - # check if prim is a mesh -> if so, make it invisible to secondary rays - if child_prim.IsA(UsdGeom.Gprim): - # invisible to secondary rays such as depth images - sim_utils.change_prim_property( - prop_path=f"{child_prim.GetPrimPath().pathString}.primvars:invisibleToSecondaryRays", - value=True, - stage=prim.GetStage(), - type_to_create_if_not_exist=Sdf.ValueTypeNames.Bool, - ) - # add children to list - all_prims += child_prim.GetChildren() - - # remove any physics on the markers because they are only for visualization! - if has_kit(): - import omni.physx.scripts.utils as physx_utils - - physx_utils.removeRigidBodySubtree(prim) + if any(viz.supports_markers() and viz.pumps_app_update() and viz.cfg.enable_markers for viz in sim.visualizers): + self._ensure_kit_backend() + if any( + viz.supports_markers() and not viz.pumps_app_update() and viz.cfg.enable_markers for viz in sim.visualizers + ): + self._ensure_newton_backend() + + def _ensure_kit_backend(self) -> None: + """Create the Kit marker backend if it is not already active.""" + from isaaclab_visualizers.kit.kit_visualization_markers import KitVisualizationMarkers + + if not any(isinstance(backend, KitVisualizationMarkers) for backend in self._backends): + self._backends.append(KitVisualizationMarkers(self.cfg, visible=self._is_visible)) + + def _ensure_newton_backend(self) -> None: + """Create the Newton-family marker backend if it is not already active.""" + from isaaclab_visualizers.newton.newton_visualization_markers import NewtonVisualizationMarkers + + if not any(isinstance(backend, NewtonVisualizationMarkers) for backend in self._backends): + self._backends.append(NewtonVisualizationMarkers(self.cfg, visible=self._is_visible)) + + def _resolve_target_device(self, *values: torch.Tensor | None) -> torch.device: + for value in values: + if value is not None: + return value.device + for backend in self._backends: + if hasattr(backend, "infer_device"): + return backend.infer_device() + return torch.device("cpu") + + @staticmethod + def _to_tensor( + value: np.ndarray | torch.Tensor | None, + expected_width: int, + name: str, + ) -> torch.Tensor | None: + if value is None: + return None + if isinstance(value, np.ndarray): + tensor = torch.from_numpy(value) + else: + tensor = value.detach() + if tensor.ndim != 2 or tensor.shape[1] != expected_width: + raise ValueError(f"Expected `{name}` to have shape (M, {expected_width}). Received: {tuple(tensor.shape)}.") + return tensor.to(dtype=torch.float32) + + @staticmethod + def _to_index_tensor(value: list[int] | np.ndarray | torch.Tensor | None) -> torch.Tensor | None: + if value is None: + return None + if isinstance(value, list): + tensor = torch.tensor(value) + elif isinstance(value, np.ndarray): + tensor = torch.from_numpy(value) + else: + tensor = value.detach() + if tensor.ndim != 1: + raise ValueError(f"Expected `marker_indices` to have shape (M,). Received: {tuple(tensor.shape)}.") + return tensor.to(dtype=torch.int32) diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index 3b15d8a0171e..728a708b4448 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base.py @@ -23,7 +23,6 @@ import isaaclab.sim as sim_utils from isaaclab.physics import PhysicsEvent, PhysicsManager -from isaaclab.utils.version import has_kit from .kernels import reset_envs_kernel, update_outdated_envs_kernel, update_timestamp_kernel @@ -151,17 +150,15 @@ def set_debug_vis(self, debug_vis: bool) -> bool: if debug_vis: # create a subscriber for the post update event if it doesn't exist if self._debug_vis_handle is None: - if has_kit(): - import omni.kit.app # noqa: PLC0415 - - app_interface = omni.kit.app.get_app_interface() - self._debug_vis_handle = app_interface.get_post_update_event_stream().create_subscription_to_pop( - lambda event, obj=weakref.proxy(self): obj._debug_vis_callback(event) - ) + sim_ctx = sim_utils.SimulationContext.instance() + if sim_ctx is not None: + self._debug_vis_handle = sim_ctx.vis_marker_registry.add_debug_vis_callback(self) else: # remove the subscriber if it exists - if self._debug_vis_handle is not None: - self._debug_vis_handle.unsubscribe() + sim_ctx = sim_utils.SimulationContext.instance() + if sim_ctx is not None: + sim_ctx.vis_marker_registry.clear_debug_vis_callback(self) + else: self._debug_vis_handle = None # return success return True @@ -323,8 +320,10 @@ def _initialize_callback(self, event): def _invalidate_initialize_callback(self, event): """Invalidates the scene elements.""" self._is_initialized = False - if self._debug_vis_handle is not None: - self._debug_vis_handle.unsubscribe() + sim_ctx = sim_utils.SimulationContext.instance() + if sim_ctx is not None: + sim_ctx.vis_marker_registry.clear_debug_vis_callback(self) + else: self._debug_vis_handle = None def _on_prim_deletion(self, event) -> None: @@ -358,8 +357,10 @@ def _clear_callbacks(self) -> None: self._prim_deletion_handle.deregister() self._prim_deletion_handle = None # Clear debug visualization - if self._debug_vis_handle is not None: - self._debug_vis_handle.unsubscribe() + sim_ctx = sim_utils.SimulationContext.instance() + if sim_ctx is not None: + sim_ctx.vis_marker_registry.clear_debug_vis_callback(self) + else: self._debug_vis_handle = None """ diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 89be3163359f..d5ef6f64e9c5 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -22,6 +22,7 @@ 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.markers.vis_marker_registry import VisMarkerRegistry from isaaclab.physics import BaseSceneDataProvider, PhysicsManager, SceneDataProvider from isaaclab.physics.scene_data_requirements import ( SceneDataRequirement, @@ -191,6 +192,7 @@ def __init__(self, cfg: SimulationCfg | None = None): 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 + self.vis_marker_registry = VisMarkerRegistry() # Simulation state self._is_playing = False @@ -753,6 +755,11 @@ def update_visualizers(self, dt: float, skip_app_pumping: bool = False) -> None: self.update_scene_data_provider() + # Marker callbacks update VisualizationMarkers state; visualizer step() + # consumes that state later in this method. + if any(viz.supports_markers() for viz in self._visualizers): + self.vis_marker_registry.dispatch_callbacks() + visualizers_to_remove = [] for viz in self._visualizers: try: diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 1f86f32872c9..3b7ca93dcfa3 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -7,14 +7,21 @@ from __future__ import annotations +import sys from typing import Any, cast +import isaaclab_visualizers.kit.kit_visualizer as kit_visualizer +import isaaclab_visualizers.newton.newton_visualization_markers as newton_markers import isaaclab_visualizers.rerun.rerun_visualizer as rerun_visualizer import isaaclab_visualizers.viser.viser_visualizer as viser_visualizer +import numpy as np import pytest +import torch +from isaaclab_visualizers.kit.kit_visualizer_cfg import KitVisualizerCfg from isaaclab_visualizers.rerun.rerun_visualizer_cfg import RerunVisualizerCfg from isaaclab_visualizers.viser.viser_visualizer_cfg import ViserVisualizerCfg +from isaaclab.markers.vis_marker_registry import VisMarkerRegistry from isaaclab.sim.simulation_context import SimulationContext @@ -94,6 +101,9 @@ def requires_forward_before_step(self): def pumps_app_update(self): return self._pumps_app_update + def supports_markers(self): + return False + def _make_context(visualizers, provider=None): ctx = object.__new__(SimulationContext) @@ -168,6 +178,26 @@ def test_update_visualizers_handles_training_pause_loop(): assert viz.step_calls == [0.0, 0.2] +def test_vis_marker_registry_dispatch_allows_callback_mutation(): + registry = VisMarkerRegistry() + calls = [] + + def _remove_other_callback(event): + calls.append(("remove_other", event)) + registry.remove_callback("other") + + def _other_callback(event): + calls.append(("other", event)) + + registry.add_callback("remove_other", _remove_other_callback) + registry.add_callback("other", _other_callback) + + registry.dispatch_callbacks("tick") + + assert calls == [("remove_other", "tick"), ("other", "tick")] + assert "other" not in registry._callbacks + + class _DummyViserSceneDataProvider: def __init__(self): self._metadata = {"num_envs": 4} @@ -229,6 +259,214 @@ def _fake_create_viewer(self, record_to_viser: str | None, metadata: dict | None assert viewer.calls[2] == ("end_frame",) +def test_viser_visualizer_marker_render_failure_does_not_interrupt_state_updates( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + provider = _DummyViserSceneDataProvider() + viewer = _DummyViserViewer() + marker_calls = [] + + def _fake_create_viewer(self, record_to_viser: str | None, metadata: dict | None = None): + self._viewer = viewer + + def _raise_marker_render(*args, **kwargs): + marker_calls.append((args, kwargs)) + raise RuntimeError("marker overlay failed") + + monkeypatch.setattr(viser_visualizer.ViserVisualizer, "_create_viewer", _fake_create_viewer) + monkeypatch.setattr(viser_visualizer, "render_newton_visualization_markers", _raise_marker_render) + + visualizer = viser_visualizer.ViserVisualizer(ViserVisualizerCfg()) + visualizer.initialize(cast(Any, provider)) + + with caplog.at_level("WARNING"): + visualizer.step(0.25) + + assert marker_calls + assert viewer.calls[0][0] == "begin_frame" + assert viewer.calls[1] == ("log_state", {"state_call": 2}) + assert viewer.calls[2] == ("end_frame",) + assert "Marker rendering failed; continuing body updates" in caplog.text + + +def test_newton_marker_mesh_registration_is_per_viewer(monkeypatch: pytest.MonkeyPatch): + marker = object.__new__(newton_markers.NewtonVisualizationMarkers) + marker._registered_meshes = set() + + class _FakeMesh: + vertices = np.zeros((1, 3), dtype=np.float32) + indices = np.zeros((3,), dtype=np.int32) + normals = np.zeros((0, 3), dtype=np.float32) + uvs = np.zeros((0, 2), dtype=np.float32) + + class _FakeViewer: + def __init__(self): + self.meshes = [] + + def log_mesh(self, name, vertices, indices, **kwargs): + self.meshes.append((name, vertices, indices, kwargs)) + + monkeypatch.setattr(newton_markers, "_create_mesh", lambda cfg: _FakeMesh()) + monkeypatch.setattr(newton_markers.wp, "array", lambda value, dtype=None: value) + + spec = newton_markers._NewtonMarkerSpec(renderer="mesh", mesh_type="box", mesh_params={"size": (1.0, 1.0, 1.0)}) + viewer_a = _FakeViewer() + viewer_b = _FakeViewer() + + marker._ensure_mesh_registered(viewer_a, "/Visuals/marker/meshes/arrow", spec) + marker._ensure_mesh_registered(viewer_a, "/Visuals/marker/meshes/arrow", spec) + marker._ensure_mesh_registered(viewer_b, "/Visuals/marker/meshes/arrow", spec) + + assert len(viewer_a.meshes) == 1 + assert len(viewer_b.meshes) == 1 + + +class _FakeNewtonMarkerMesh: + vertices = np.zeros((1, 3), dtype=np.float32) + indices = np.zeros((3,), dtype=np.int32) + normals = np.zeros((0, 3), dtype=np.float32) + uvs = np.zeros((0, 2), dtype=np.float32) + + +class _FakeNewtonMarkerViewer: + def __init__(self): + self.meshes = [] + self.instances = [] + self.lines = [] + + def log_mesh(self, name, vertices, indices, **kwargs): + self.meshes.append((name, vertices, indices, kwargs)) + + def log_instances(self, batch_name, mesh_name, xforms, scales, colors, materials, hidden=False): + self.instances.append( + { + "batch_name": batch_name, + "mesh_name": mesh_name, + "xforms": xforms, + "scales": scales, + "colors": colors, + "materials": materials, + "hidden": hidden, + } + ) + + def log_lines(self, batch_name, starts, ends, colors, width=None, hidden=False): + self.lines.append( + { + "batch_name": batch_name, + "starts": starts, + "ends": ends, + "colors": colors, + "width": width, + "hidden": hidden, + } + ) + + +def _make_newton_marker_for_render( + *, + marker_names: list[str], + translations: torch.Tensor, + marker_indices: torch.Tensor | None = None, + visible: bool = True, +): + marker = object.__new__(newton_markers.NewtonVisualizationMarkers) + marker_cfg_type = type("MarkerCfg", (), {"visual_material": None}) + marker.cfg = type("Cfg", (), {"markers": {name: marker_cfg_type() for name in marker_names}})() + marker.group_id = "/Visuals/marker::test" + marker.visible = visible + marker.translations = translations + marker.orientations = torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32).repeat(translations.shape[0], 1) + marker.scales = torch.ones((translations.shape[0], 3), dtype=torch.float32) + marker.marker_indices = marker_indices + marker.count = translations.shape[0] + marker._registered_meshes = set() + marker._warned_unsupported = set() + return marker + + +def _patch_newton_marker_render_deps(monkeypatch: pytest.MonkeyPatch) -> None: + specs = { + "arrow": newton_markers._NewtonMarkerSpec( + renderer="mesh", + mesh_type="box", + mesh_params={"size": (1.0, 1.0, 1.0)}, + color=(1.0, 1.0, 1.0), + texture=np.zeros((2, 2, 3), dtype=np.uint8), + ), + "sphere": newton_markers._NewtonMarkerSpec(renderer="mesh", mesh_type="sphere", mesh_params={"radius": 1.0}), + "frame": newton_markers._NewtonMarkerSpec(renderer="frame"), + } + + monkeypatch.setattr(newton_markers, "_create_mesh", lambda cfg: _FakeNewtonMarkerMesh()) + monkeypatch.setattr(newton_markers.wp, "array", lambda value, dtype=None: value) + monkeypatch.setattr(newton_markers, "_resolve_newton_marker_cfg", lambda name, marker_cfg, cfg: specs[name]) + + +def test_newton_marker_render_filters_visible_envs(monkeypatch: pytest.MonkeyPatch): + _patch_newton_marker_render_deps(monkeypatch) + translations = torch.arange(8, dtype=torch.float32).unsqueeze(1).repeat(1, 3) + marker = _make_newton_marker_for_render( + marker_names=["arrow"], + translations=translations, + marker_indices=torch.zeros(8, dtype=torch.int32), + ) + viewer = _FakeNewtonMarkerViewer() + + marker.render(viewer, visible_env_ids=[1, 3], num_envs=4) + + assert len(viewer.instances) == 1 + assert viewer.instances[0]["hidden"] is False + assert viewer.instances[0]["xforms"][:, 0].tolist() == [1.0, 3.0, 5.0, 7.0] + + +def test_newton_marker_render_routes_instances_by_prototype(monkeypatch: pytest.MonkeyPatch): + _patch_newton_marker_render_deps(monkeypatch) + translations = torch.arange(4, dtype=torch.float32).unsqueeze(1).repeat(1, 3) + marker = _make_newton_marker_for_render( + marker_names=["arrow", "sphere"], + translations=translations, + marker_indices=torch.tensor([0, 1, 0, 1], dtype=torch.int32), + ) + viewer = _FakeNewtonMarkerViewer() + + marker.render(viewer, visible_env_ids=None, num_envs=4) + + visible_instances = [call for call in viewer.instances if not call["hidden"]] + assert [call["batch_name"] for call in visible_instances] == [ + "/Visuals/marker::test/arrow", + "/Visuals/marker::test/sphere", + ] + assert [call["xforms"].shape[0] for call in visible_instances] == [2, 2] + assert visible_instances[0]["materials"][:, 3].tolist() == [1.0, 1.0] + assert visible_instances[1]["materials"][:, 3].tolist() == [0.0, 0.0] + + +def test_newton_marker_render_hides_unselected_prototypes(monkeypatch: pytest.MonkeyPatch): + _patch_newton_marker_render_deps(monkeypatch) + marker = _make_newton_marker_for_render( + marker_names=["arrow", "sphere", "frame"], + translations=torch.zeros((3, 3), dtype=torch.float32), + marker_indices=torch.zeros(3, dtype=torch.int32), + ) + viewer = _FakeNewtonMarkerViewer() + + marker.render(viewer, visible_env_ids=None, num_envs=3) + + hidden_instances = [call for call in viewer.instances if call["hidden"]] + assert [call["batch_name"] for call in hidden_instances] == ["/Visuals/marker::test/sphere"] + assert viewer.lines == [ + { + "batch_name": "/Visuals/marker::test/frame", + "starts": None, + "ends": None, + "colors": None, + "width": None, + "hidden": True, + } + ] + + @pytest.mark.parametrize( ("cfg_max_visible_envs", "expected_visible"), [ @@ -385,6 +623,112 @@ def get_camera_transforms(self): assert captured["set_world_offsets"] == (0.0, 0.0, 0.0) +def test_rerun_visualizer_marker_failure_still_ends_frame(monkeypatch: pytest.MonkeyPatch): + class _FakeRerunViewer: + def __init__(self): + self.calls = [] + + def is_paused(self): + return False + + def begin_frame(self, sim_time): + self.calls.append(("begin_frame", sim_time)) + + def log_state(self, state): + self.calls.append(("log_state", state)) + + def end_frame(self): + self.calls.append(("end_frame",)) + + class _DummyRerunSceneDataProvider: + def get_metadata(self) -> dict: + return {"num_envs": 4} + + def get_newton_state(self): + return {"ok": True} + + def get_camera_transforms(self): + return {} + + def _raise_marker_render(*args, **kwargs): + raise RuntimeError("marker render failed") + + monkeypatch.setattr(rerun_visualizer, "render_newton_visualization_markers", _raise_marker_render) + + visualizer = rerun_visualizer.RerunVisualizer(RerunVisualizerCfg()) + viewer = _FakeRerunViewer() + visualizer._is_initialized = True + visualizer._is_closed = False + visualizer._viewer = viewer + visualizer._scene_data_provider = _DummyRerunSceneDataProvider() + visualizer._resolved_visible_env_ids = None + + with pytest.raises(RuntimeError, match="marker render failed"): + visualizer.step(0.25) + + assert [call[0] for call in viewer.calls] == ["begin_frame", "log_state", "end_frame"] + + +def test_kit_visualizer_default_camera_source_does_not_require_camera_prim(monkeypatch: pytest.MonkeyPatch): + """Default ``--viz kit`` should work for envs without a camera prim.""" + + class _FakeViewportApi: + def __init__(self): + self.set_active_camera_calls = [] + + def get_active_camera(self): + return "/OmniverseKit_Persp" + + def set_active_camera(self, camera_path): + self.set_active_camera_calls.append(camera_path) + + class _FakeViewportWindow: + def __init__(self): + self.viewport_api = _FakeViewportApi() + + class _FakeStage: + def GetPrimAtPath(self, path): + raise AssertionError(f"default Kit visualizer should not look up camera prims: {path}") + + class _FakeProvider: + def get_usd_stage(self): + return _FakeStage() + + viewport_window = _FakeViewportWindow() + viewport_utility = type( + "ViewportUtility", + (), + { + "create_viewport_window": staticmethod(lambda **kwargs: viewport_window), + "get_active_viewport_window": staticmethod(lambda: viewport_window), + }, + ) + monkeypatch.setitem(sys.modules, "omni", type(sys)("omni")) + monkeypatch.setitem(sys.modules, "omni.kit", type(sys)("omni.kit")) + monkeypatch.setitem(sys.modules, "omni.kit.viewport", type(sys)("omni.kit.viewport")) + monkeypatch.setitem(sys.modules, "omni.kit.viewport.utility", viewport_utility) + monkeypatch.setitem(sys.modules, "omni.ui", type("OmniUi", (), {"DockPosition": object})()) + + applied_camera_poses = [] + monkeypatch.setattr( + kit_visualizer.KitVisualizer, + "_set_viewport_camera", + lambda self, eye, target: applied_camera_poses.append((tuple(eye), tuple(target))), + ) + + cfg = KitVisualizerCfg() + visualizer = kit_visualizer.KitVisualizer(cfg) + visualizer._scene_data_provider = _FakeProvider() + visualizer._runtime_headless = False + + visualizer._setup_viewport() + + assert cfg.cam_source == "cfg" + assert applied_camera_poses == [(cfg.eye, cfg.lookat)] + assert viewport_window.viewport_api.set_active_camera_calls == [] + assert visualizer._controlled_camera_path == "/OmniverseKit_Persp" + + def test_get_cli_visualizer_types_handles_non_string_setting_without_crashing(): ctx = object.__new__(SimulationContext) ctx.get_setting = lambda name: {"types": "newton,kit"} if name == "/isaaclab/visualizer/types" else None diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualization_markers.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualization_markers.py new file mode 100644 index 000000000000..c9f55b413e03 --- /dev/null +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualization_markers.py @@ -0,0 +1,221 @@ +# 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/USD implementation for :class:`VisualizationMarkers`. + +This backend represents markers as :class:`UsdGeom.PointInstancer` prims in the +USD stage. Marker prototypes are created as child prims of the point instancer +and are instanced efficiently through prototype indices. + +.. _UsdGeom.PointInstancer: https://graphics.pixar.com/usd/dev/api/class_usd_geom_point_instancer.html +""" + +from __future__ import annotations + +import logging + +import torch + +import isaaclab.sim as sim_utils +from isaaclab.markers.visualization_markers_cfg import VisualizationMarkersCfg +from isaaclab.utils.version import has_kit + +logger = logging.getLogger(__name__) + + +class KitVisualizationMarkers: + """USD PointInstancer backend for visualization markers. + + This class wraps around the `UsdGeom.PointInstancer`_ for efficient + handling of objects in the USD stage by instancing the created marker + prototype prims. + + A marker prototype prim is a reusable template prim used for defining + variations of objects in the scene. For example, a sphere prim can be used + as a marker prototype prim to create multiple sphere prims at different + locations. The marker prim path is resolved using the marker name from the + :attr:`VisualizationMarkersCfg.markers` dictionary. + + .. _UsdGeom.PointInstancer: https://graphics.pixar.com/usd/dev/api/class_usd_geom_point_instancer.html + """ + + def __init__(self, cfg: VisualizationMarkersCfg, visible: bool = True): + """Initialize the USD point instancer and register marker prototypes. + + When this backend is initialized, the :class:`UsdGeom.PointInstancer` + is created in the stage and the marker prims are registered into it. + + .. note:: + If a prim already exists at the requested path, the next free path + is used for the :class:`UsdGeom.PointInstancer` prim. + """ + self.cfg = cfg + self.stage = sim_utils.get_current_stage() + # Resolve the next free prim path before creating the point instancer. + self.prim_path = sim_utils.get_next_free_prim_path(cfg.prim_path) + self._is_visible = visible + self._count = len(cfg.markers) + + from pxr import Gf, UsdGeom # noqa: PLC0415 + + self._instancer_manager = UsdGeom.PointInstancer.Define(self.stage, self.prim_path) + self._add_markers_prototypes(self.cfg.markers) + # Note: We need to do this the first time to initialize the instancer. + # Otherwise, the instancer is not fully "created" and USD instance + # queries such as GetInstanceIndices() can fail. + self._instancer_manager.GetProtoIndicesAttr().Set(list(range(len(self.cfg.markers)))) + self._instancer_manager.GetPositionsAttr().Set([Gf.Vec3f(0.0)] * len(self.cfg.markers)) + self.set_visibility(visible) + + @property + def count(self) -> int: + return self._count + + def set_visibility(self, visible: bool) -> None: + """Set USD PointInstancer visibility. + + The method does this through the USD API. + """ + from pxr import UsdGeom # noqa: PLC0415 + + self._is_visible = visible + imageable = UsdGeom.Imageable(self._instancer_manager) + if visible: + imageable.MakeVisible() + else: + imageable.MakeInvisible() + + def is_visible(self) -> bool: + """Return USD PointInstancer visibility.""" + from pxr import UsdGeom # noqa: PLC0415 + + return self._instancer_manager.GetVisibilityAttr().Get() != UsdGeom.Tokens.invisible + + def visualize( + self, + translations: torch.Tensor | None, + orientations: torch.Tensor | None, + scales: torch.Tensor | None, + marker_indices: torch.Tensor | None, + ) -> None: + """Write marker transforms to USD PointInstancer attributes. + + Args: + translations: Translations w.r.t. parent prim frame. Shape is + (M, 3). + orientations: Quaternion orientations (x, y, z, w) w.r.t. parent + prim frame. Shape is (M, 4). + scales: Scale applied before any rotation is applied. Shape is + (M, 3). + marker_indices: Decides which marker prototype to visualize. Shape + is (M). + """ + from pxr import Vt # noqa: PLC0415 + + num_markers = 0 + if translations is not None: + translations_np = translations.detach().cpu().numpy() + # Apply translations. + self._instancer_manager.GetPositionsAttr().Set(Vt.Vec3fArray.FromNumpy(translations_np)) + num_markers = translations_np.shape[0] + if orientations is not None: + orientations_np = orientations.detach().cpu().numpy() + # Apply orientations. USD expects quaternion data in xyzw format. + self._instancer_manager.GetOrientationsAttr().Set(Vt.QuathArray.FromNumpy(orientations_np)) + num_markers = orientations_np.shape[0] + if scales is not None: + scales_np = scales.detach().cpu().numpy() + # Apply scales. + self._instancer_manager.GetScalesAttr().Set(Vt.Vec3fArray.FromNumpy(scales_np)) + num_markers = scales_np.shape[0] + if marker_indices is not None or num_markers != self._count: + if marker_indices is not None: + marker_indices_np = marker_indices.detach().cpu().numpy() + # Apply prototype indices. + self._instancer_manager.GetProtoIndicesAttr().Set(Vt.IntArray.FromNumpy(marker_indices_np)) + num_markers = marker_indices_np.shape[0] + elif num_markers != 0: + # Set all markers to the first prototype when the marker count + # changes and explicit marker indices are not provided. + self._instancer_manager.GetProtoIndicesAttr().Set([0] * num_markers) + if num_markers != 0: + self._count = num_markers + + def _add_markers_prototypes(self, markers_cfg: dict[str, sim_utils.SpawnerCfg]) -> None: + """Add marker prototypes to the scene and register them with the point instancer.""" + # Add markers based on config. + for name, cfg in markers_cfg.items(): + # Resolve prim path from the marker name. + marker_prim_path = f"{self.prim_path}/{name}" + # Create a child prim for the marker. + marker_prim = cfg.func(prim_path=marker_prim_path, cfg=cfg) + # Make the asset uninstanceable in case it is already instanced. + # Point instancer defines its own prototypes, so already-instanced + # assets cannot be used directly. + self._process_prototype_prim(marker_prim) + # Add child reference to point instancer. + self._instancer_manager.GetPrototypesRel().AddTarget(marker_prim_path) + + # Check that all prototypes were loaded. + prototypes = self._instancer_manager.GetPrototypesRel().GetTargets() + if len(prototypes) != len(markers_cfg): + raise RuntimeError( + f"Failed to load all the prototypes. Expected: {len(markers_cfg)}. Received: {len(prototypes)}." + ) + + def _process_prototype_prim(self, prim) -> None: + """Process a prim and its descendants to make them suitable for prototypes. + + Point instancer defines its own prototypes, so if an asset is already + instanced, this does not work. This function checks if the prim and its + descendants are instanced. If so, it makes the respective prim + uninstanceable by disabling instancing on the prim. + + Additionally, it makes the prim invisible to secondary rays. This is + useful when marker prims should not appear in camera images. + + Args: + prim: The prim to process. + """ + from pxr import Sdf, UsdGeom, UsdPhysics # noqa: PLC0415 + + if not prim.IsValid(): + raise ValueError(f"Prim at path '{prim.GetPrimAtPath()}' is not valid.") + + # Iterate over all prims under the marker prim path. + all_prims = [prim] + while len(all_prims) > 0: + child_prim = all_prims.pop(0) + # Remove physics from marker prototypes because they are only for + # visualization. + if child_prim.HasAPI(UsdPhysics.ArticulationRootAPI): + child_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + child_prim.RemoveAppliedSchema("PhysxArticulationAPI") + if child_prim.HasAPI(UsdPhysics.RigidBodyAPI): + child_prim.RemoveAPI(UsdPhysics.RigidBodyAPI) + child_prim.RemoveAppliedSchema("PhysxRigidBodyAPI") + if child_prim.IsA(UsdPhysics.Joint): + child_prim.GetAttribute("physics:jointEnabled").Set(False) + # Point instancer defines its own instancing, so nested instances + # must be made uninstanceable. + if child_prim.IsInstance(): + child_prim.SetInstanceable(False) + # Make renderable prims invisible to secondary rays such as depth + # images. + if child_prim.IsA(UsdGeom.Gprim): + sim_utils.change_prim_property( + prop_path=f"{child_prim.GetPrimPath().pathString}.primvars:invisibleToSecondaryRays", + value=True, + stage=prim.GetStage(), + type_to_create_if_not_exist=Sdf.ValueTypeNames.Bool, + ) + all_prims += child_prim.GetChildren() + + # Remove any remaining physics on the markers because they are only for + # visualization. + if has_kit(): + import omni.physx.scripts.utils as physx_utils # noqa: PLC0415 + + physx_utils.removeRigidBodySubtree(prim) diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index ced2935897ae..701a21c79984 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -172,7 +172,7 @@ def is_training_paused(self) -> bool: def supports_markers(self) -> bool: """Kit viewport supports marker visualization through Omni UI rendering.""" - return True + return bool(self.cfg.enable_markers) def supports_live_plots(self) -> bool: """Kit backend can host live plot widgets via viewport UI panels.""" diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualization_markers.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualization_markers.py new file mode 100644 index 000000000000..9d222ed71a61 --- /dev/null +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualization_markers.py @@ -0,0 +1,531 @@ +# 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-family implementation for :class:`VisualizationMarkers`.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Literal + +import numpy as np +import torch +import warp as wp +from newton import Axis, Mesh + +import isaaclab.sim as sim_utils +from isaaclab.markers.visualization_markers_cfg import VisualizationMarkersCfg +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.math import quat_apply + +logger = logging.getLogger(__name__) + +_OMNIPBR_DEFAULTS = { + "diffuse_color_constant": (0.2, 0.2, 0.2), + "diffuse_tint": (1.0, 1.0, 1.0), +} +_UNBOUND_DEFAULT_FALLBACK_GRAY = (0.18, 0.18, 0.18) +_DEX_CUBE_TEXTURE_URL = f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/DexCube/Materials/dex_cube_mod.png" + + +@dataclass(frozen=True) +class _NewtonMarkerSpec: + renderer: Literal["mesh", "frame", "none"] + mesh_type: Literal["arrow", "box", "textured_box", "sphere", "cylinder", "capsule", "cone"] | None = None + mesh_params: dict[str, float | tuple[float, float, float]] | None = None + scale: tuple[float, float, float] | None = None + color: tuple[float, float, float] | None = None + texture: Any | None = None + + +@dataclass(frozen=True) +class _MeshData: + vertices: np.ndarray + indices: np.ndarray + normals: np.ndarray + uvs: np.ndarray + + +def render_newton_visualization_markers(viewer, visible_env_ids: list[int] | None, num_envs: int) -> None: + """Render all active Newton visualization marker groups into a Newton-family viewer.""" + sim = sim_utils.SimulationContext.instance() + if sim is None: + return + + for marker in sim.vis_marker_registry.get_groups().values(): + if isinstance(marker, NewtonVisualizationMarkers): + marker.render(viewer, visible_env_ids=visible_env_ids, num_envs=num_envs) + + +class NewtonVisualizationMarkers: + """Newton-family backend for visualization markers.""" + + def __init__(self, cfg: VisualizationMarkersCfg, visible: bool = True): + self.cfg = cfg + self.group_id = f"{cfg.prim_path}::{id(self)}" + self.visible = visible + self.translations: torch.Tensor | None = None + self.orientations: torch.Tensor | None = None + self.scales: torch.Tensor | None = None + self.marker_indices: torch.Tensor | None = None + self.count = len(cfg.markers) + self._registered_meshes: set[tuple[int, str]] = set() + self._warned_unsupported: set[str] = set() + + sim = sim_utils.SimulationContext.instance() + if sim is not None: + sim.vis_marker_registry.set_group(self.group_id, self) + + def close(self) -> None: + """Remove marker backend from the simulation marker registry.""" + sim = sim_utils.SimulationContext.instance() + if sim is not None: + sim.vis_marker_registry.remove_group(self.group_id) + + def infer_device(self) -> torch.device: + """Infer the device from current marker state.""" + for value in (self.translations, self.orientations, self.scales, self.marker_indices): + if value is not None: + return value.device + return torch.device("cpu") + + def set_visibility(self, visible: bool) -> None: + """Set marker visibility.""" + self.visible = visible + + def is_visible(self) -> bool: + """Return whether this marker group is visible.""" + return self.visible + + def visualize( + self, + translations: torch.Tensor | None, + orientations: torch.Tensor | None, + scales: torch.Tensor | None, + marker_indices: torch.Tensor | None, + ) -> None: + """Update marker state consumed by Newton-family visualizers.""" + if translations is not None: + self.translations = translations.detach() + self.count = translations.shape[0] + if orientations is not None: + self.orientations = orientations.detach() + self.count = orientations.shape[0] + if scales is not None: + self.scales = scales.detach() + self.count = scales.shape[0] + if marker_indices is not None: + self.marker_indices = marker_indices.detach().to(dtype=torch.int32) + self.count = marker_indices.shape[0] + elif self.count != 0: + self.marker_indices = torch.zeros(self.count, dtype=torch.int32, device=self.infer_device()) + + def render(self, viewer, visible_env_ids: list[int] | None, num_envs: int) -> None: + """Render marker state to a Newton viewer.""" + state = _filter_marker_state(self, visible_env_ids=visible_env_ids, num_envs=num_envs) + if state["count"] == 0: + for name, marker_cfg in self.cfg.markers.items(): + self._hide_batch(viewer, name, _resolve_newton_marker_cfg(name, marker_cfg, self.cfg)) + return + + translations = state["translations"] + if translations is None: + return + orientations = state["orientations"] + if orientations is None: + orientations = torch.tensor([[0.0, 0.0, 0.0, 1.0]], device=translations.device).repeat(state["count"], 1) + scales = state["scales"] + if scales is None: + scales = torch.ones((state["count"], 3), dtype=torch.float32, device=translations.device) + marker_indices = state["marker_indices"] + if marker_indices is None: + marker_indices = torch.zeros(state["count"], dtype=torch.int64, device=translations.device) + + for proto_index, (name, marker_cfg) in enumerate(self.cfg.markers.items()): + newton_cfg = _resolve_newton_marker_cfg(name, marker_cfg, self.cfg) + batch_name = f"{self.group_id}/{name}" + selected = marker_indices == proto_index + if not state["visible"] or int(selected.sum().item()) == 0: + self._hide_batch(viewer, name, newton_cfg) + continue + + if newton_cfg.renderer == "none": + unsupported_key = f"{self.group_id}:{name}" + if unsupported_key not in self._warned_unsupported: + logger.warning( + "[NewtonVisualizationMarkers] Unsupported marker prototype '%s' in group '%s'; skipping.", + name, + self.group_id, + ) + self._warned_unsupported.add(unsupported_key) + continue + + selected_translations = translations[selected] + selected_orientations = orientations[selected] + default_scale = newton_cfg.scale or _extract_scale_hint(marker_cfg) + selected_scales = scales[selected] * torch.tensor( + default_scale, dtype=torch.float32, device=scales.device + ).unsqueeze(0) + + if newton_cfg.renderer == "mesh": + mesh_name = f"{self.group_id}/meshes/{name}" + self._ensure_mesh_registered(viewer, mesh_name, newton_cfg) + color = newton_cfg.color or _extract_color(marker_cfg) + colors = torch.tensor(color, dtype=torch.float32, device=scales.device).repeat( + selected_scales.shape[0], 1 + ) + materials = torch.zeros((selected_scales.shape[0], 4), dtype=torch.float32, device=scales.device) + if newton_cfg.texture is not None: + # ViewerGL gates texture sampling with material.w. Rerun and + # Viser ignore this flag but consume the mesh texture. + materials[:, 3] = 1.0 + xforms = torch.cat((selected_translations, selected_orientations), dim=1).detach().cpu().numpy() + viewer.log_instances( + batch_name, + mesh_name, + wp.array(xforms.astype(np.float32), dtype=wp.transform), + wp.array(selected_scales.detach().cpu().numpy().astype(np.float32), dtype=wp.vec3), + wp.array(colors.detach().cpu().numpy().astype(np.float32), dtype=wp.vec3), + wp.array(materials.detach().cpu().numpy().astype(np.float32), dtype=wp.vec4), + hidden=False, + ) + elif newton_cfg.renderer == "frame": + starts, ends, colors = _build_frame_lines(selected_translations, selected_orientations, selected_scales) + width = max(float(selected_scales.mean().item()) * 0.05, 0.0025) + viewer.log_lines( + batch_name, + wp.array(starts.detach().cpu().numpy().astype(np.float32), dtype=wp.vec3), + wp.array(ends.detach().cpu().numpy().astype(np.float32), dtype=wp.vec3), + wp.array(colors.detach().cpu().numpy().astype(np.float32), dtype=wp.vec3), + width=width, + hidden=False, + ) + + def _hide_batch(self, viewer, name: str, newton_cfg: _NewtonMarkerSpec) -> None: + batch_name = f"{self.group_id}/{name}" + if newton_cfg.renderer == "mesh" and newton_cfg.mesh_type is not None: + mesh_name = f"{self.group_id}/meshes/{name}" + self._ensure_mesh_registered(viewer, mesh_name, newton_cfg) + viewer.log_instances(batch_name, mesh_name, None, None, None, None, hidden=True) + elif newton_cfg.renderer == "frame": + viewer.log_lines(batch_name, None, None, None, hidden=True) + + def _ensure_mesh_registered(self, viewer, mesh_name: str, newton_cfg: _NewtonMarkerSpec) -> None: + # The marker backend is shared by all Newton-family visualizers. Mesh + # registration is viewer-local, so the same marker mesh must be logged + # once per viewer (for example, once for Rerun and once for Viser). + registered_key = (id(viewer), mesh_name) + if registered_key in self._registered_meshes or newton_cfg.mesh_type is None: + return + mesh = _create_mesh(newton_cfg) + viewer.log_mesh( + mesh_name, + wp.array(mesh.vertices.astype(np.float32), dtype=wp.vec3), + wp.array(mesh.indices.astype(np.int32), dtype=wp.int32), + normals=wp.array(mesh.normals.astype(np.float32), dtype=wp.vec3) if mesh.normals.size else None, + uvs=wp.array(mesh.uvs.astype(np.float32), dtype=wp.vec2) if mesh.uvs.size else None, + texture=newton_cfg.texture, + hidden=True, + ) + self._registered_meshes.add(registered_key) + + +def _resolve_newton_marker_cfg(name: str, marker_cfg: object, cfg: VisualizationMarkersCfg) -> _NewtonMarkerSpec: + del name, cfg + return _infer_newton_marker_cfg(marker_cfg) + + +def _infer_newton_marker_cfg(marker_cfg: object) -> _NewtonMarkerSpec: + cfg_type = type(marker_cfg).__name__ + + if cfg_type == "SphereCfg": + return _NewtonMarkerSpec(renderer="mesh", mesh_type="sphere", mesh_params={"radius": float(marker_cfg.radius)}) + if cfg_type == "CuboidCfg": + return _NewtonMarkerSpec( + renderer="mesh", mesh_type="box", mesh_params={"size": tuple(float(v) for v in marker_cfg.size)} + ) + if cfg_type == "CylinderCfg": + return _NewtonMarkerSpec( + renderer="mesh", + mesh_type="cylinder", + mesh_params={"radius": float(marker_cfg.radius), "height": float(marker_cfg.height)}, + ) + if cfg_type == "CapsuleCfg": + return _NewtonMarkerSpec( + renderer="mesh", + mesh_type="capsule", + mesh_params={"radius": float(marker_cfg.radius), "height": float(marker_cfg.height)}, + ) + if cfg_type == "ConeCfg": + return _NewtonMarkerSpec( + renderer="mesh", + mesh_type="cone", + mesh_params={"radius": float(marker_cfg.radius), "height": float(marker_cfg.height)}, + ) + + if cfg_type == "UsdFileCfg": + usd_path = str(marker_cfg.usd_path).lower() + default_scale = _extract_scale_hint(marker_cfg) + if usd_path.endswith("arrow_x.usd"): + return _NewtonMarkerSpec( + renderer="mesh", + mesh_type="arrow", + mesh_params={"base_radius": 0.08, "base_height": 0.7, "cap_radius": 0.16, "cap_height": 0.3}, + scale=(default_scale[0], default_scale[1] * 2.5, default_scale[2] * 2.5), + ) + if usd_path.endswith("frame_prim.usd"): + return _NewtonMarkerSpec(renderer="frame", scale=default_scale) + if "dexcube" in usd_path or "dex_cube" in usd_path: + # TODO: Remove this specialized DexCube mesh code when general + # UsdFileCfg-to-Newton mesh conversion is supported. + # DexCube USDs are roughly 6 cm wide. Keep scale separate so task + # configs such as scale=(1.2, 1.2, 1.2) still apply naturally. + return _NewtonMarkerSpec( + renderer="mesh", + mesh_type="textured_box", + mesh_params={"size": (0.06, 0.06, 0.06)}, + color=(1.0, 1.0, 1.0), + texture=_DEX_CUBE_TEXTURE_URL, + ) + + # TODO: Add generic UsdFileCfg -> Newton mesh extraction for mesh-backed USD marker assets. + # For now, only common marker USDs are mapped to lightweight Newton-native fallbacks. + + return _NewtonMarkerSpec(renderer="none") + + +def _create_mesh(newton_cfg: _NewtonMarkerSpec): + mesh_params = newton_cfg.mesh_params or {} + if newton_cfg.mesh_type == "arrow": + return Mesh.create_arrow( + float(mesh_params["base_radius"]), + float(mesh_params["base_height"]), + cap_radius=float(mesh_params["cap_radius"]), + cap_height=float(mesh_params["cap_height"]), + up_axis=Axis.X, + ) + if newton_cfg.mesh_type == "box": + size = mesh_params["size"] + return Mesh.create_box(float(size[0]) * 0.5, float(size[1]) * 0.5, float(size[2]) * 0.5) + if newton_cfg.mesh_type == "textured_box": + return _create_textured_box_mesh(mesh_params["size"]) + if newton_cfg.mesh_type == "sphere": + return Mesh.create_sphere(radius=float(mesh_params["radius"])) + if newton_cfg.mesh_type == "cylinder": + return Mesh.create_cylinder( + float(mesh_params["radius"]), + float(mesh_params["height"]) * 0.5, + up_axis=Axis.Z, + ) + if newton_cfg.mesh_type == "capsule": + return Mesh.create_capsule( + float(mesh_params["radius"]), + float(mesh_params["height"]) * 0.5, + up_axis=Axis.Z, + ) + if newton_cfg.mesh_type == "cone": + return Mesh.create_cone( + float(mesh_params["radius"]), + float(mesh_params["height"]) * 0.5, + up_axis=Axis.Z, + ) + raise ValueError(f"Unsupported Newton mesh type: {newton_cfg.mesh_type}") + + +def _create_textured_box_mesh(size: tuple[float, float, float]) -> _MeshData: + # TODO: Remove this specialized DexCube mesh code when general + # UsdFileCfg-to-Newton mesh conversion is supported. + half = np.asarray(size, dtype=np.float32) * 0.5 + usd_vertices = np.asarray( + [ + (-1.0, -1.0, 1.0), + (-1.0, 1.0, 1.0), + (-1.0, 1.0, -1.0), + (-1.0, -1.0, -1.0), + (-1.0, -1.0, -1.0), + (-1.0, 1.0, -1.0), + (1.0, 1.0, -1.0), + (1.0, -1.0, -1.0), + (1.0, -1.0, -1.0), + (1.0, 1.0, -1.0), + (1.0, 1.0, 1.0), + (1.0, -1.0, 1.0), + (1.0, -1.0, 1.0), + (1.0, 1.0, 1.0), + (-1.0, 1.0, 1.0), + (-1.0, -1.0, 1.0), + (-1.0, -1.0, -1.0), + (1.0, -1.0, -1.0), + (1.0, -1.0, 1.0), + (-1.0, -1.0, 1.0), + (1.0, 1.0, -1.0), + (-1.0, 1.0, -1.0), + (-1.0, 1.0, 1.0), + (1.0, 1.0, 1.0), + ], + dtype=np.float32, + ) + uvs = np.asarray( + [ + (1.0, 0.333333), + (1.0, 0.666667), + (0.5, 0.666667), + (0.5, 0.333333), + (0.5, 0.666667), + (0.5, 1.0), + (0.0, 1.0), + (0.0, 0.666667), + (0.5, 0.333333), + (0.5, 0.666667), + (0.0, 0.666667), + (0.0, 0.333333), + (1.0, 0.0), + (1.0, 0.333333), + (0.5, 0.333333), + (0.5, 0.0), + (0.5, 0.0), + (0.5, 0.333333), + (0.0, 0.333333), + (0.0, 0.0), + (1.0, 0.666667), + (1.0, 1.0), + (0.5, 1.0), + (0.5, 0.666667), + ], + dtype=np.float32, + ) + indices: list[int] = [] + for base in range(0, 24, 4): + indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]) + return _MeshData( + vertices=usd_vertices * half, + indices=np.asarray(indices, dtype=np.int32), + normals=np.zeros((0, 3), dtype=np.float32), + uvs=uvs, + ) + + +def _filter_marker_state( + marker: NewtonVisualizationMarkers, + visible_env_ids: list[int] | None, + num_envs: int, +) -> dict[str, Any]: + if visible_env_ids is None or marker.count == 0 or num_envs <= 0 or marker.count % num_envs != 0: + return { + "visible": marker.visible, + "translations": marker.translations, + "orientations": marker.orientations, + "scales": marker.scales, + "marker_indices": marker.marker_indices, + "count": marker.count, + } + + keep: list[int] = [] + repeat_count = marker.count // num_envs + for block_idx in range(repeat_count): + base = block_idx * num_envs + for env_id in visible_env_ids: + idx = base + env_id + if idx < marker.count: + keep.append(idx) + + if len(keep) == marker.count: + return { + "visible": marker.visible, + "translations": marker.translations, + "orientations": marker.orientations, + "scales": marker.scales, + "marker_indices": marker.marker_indices, + "count": marker.count, + } + + index = torch.tensor(keep, dtype=torch.long, device=marker.infer_device()) + return { + "visible": marker.visible, + "translations": marker.translations.index_select(0, index) if marker.translations is not None else None, + "orientations": marker.orientations.index_select(0, index) if marker.orientations is not None else None, + "scales": marker.scales.index_select(0, index) if marker.scales is not None else None, + "marker_indices": marker.marker_indices.index_select(0, index) if marker.marker_indices is not None else None, + "count": len(keep), + } + + +def _extract_scale_hint(marker_cfg: object) -> tuple[float, float, float]: + scale = marker_cfg.scale if type(marker_cfg).__name__ == "UsdFileCfg" else None + if scale is None: + return (1.0, 1.0, 1.0) + return tuple(float(v) for v in scale) + + +def _extract_color(marker_cfg: object) -> tuple[float, float, float]: + material_cfg = marker_cfg.visual_material + if material_cfg is None: + return _UNBOUND_DEFAULT_FALLBACK_GRAY + + if color := _extract_omnipbr_like_color(material_cfg): + return color + + material_type = type(material_cfg).__name__ + if material_type == "PreviewSurfaceCfg": + return _extract_rgb(material_cfg.diffuse_color) or _UNBOUND_DEFAULT_FALLBACK_GRAY + if material_type == "GlassMdlCfg": + return _extract_rgb(material_cfg.glass_color) or _UNBOUND_DEFAULT_FALLBACK_GRAY + + return _UNBOUND_DEFAULT_FALLBACK_GRAY + + +def _extract_omnipbr_like_color(material_cfg: object) -> tuple[float, float, float] | None: + material_type = type(material_cfg).__name__ + if material_type == "MdlFileCfg": + if not str(material_cfg.mdl_path).lower().endswith("omnipbr.mdl"): + return None + brightness = material_cfg.albedo_brightness + if brightness is not None: + diffuse_constant = (float(brightness), float(brightness), float(brightness)) + else: + diffuse_constant = _OMNIPBR_DEFAULTS["diffuse_color_constant"] + diffuse_tint = _OMNIPBR_DEFAULTS["diffuse_tint"] + else: + return None + + return ( + diffuse_constant[0] * diffuse_tint[0], + diffuse_constant[1] * diffuse_tint[1], + diffuse_constant[2] * diffuse_tint[2], + ) + + +def _extract_rgb(value: Any) -> tuple[float, float, float] | None: + if value is None: + return None + try: + rgb = tuple(float(v) for v in value) + except TypeError: + return None + if len(rgb) < 3: + return None + return (rgb[0], rgb[1], rgb[2]) + + +def _build_frame_lines( + translations: torch.Tensor, + orientations: torch.Tensor, + scales: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + unit_axes = ( + torch.eye(3, dtype=torch.float32, device=translations.device).unsqueeze(0).repeat(translations.shape[0], 1, 1) + ) + scaled_axes = unit_axes * scales.unsqueeze(1) + repeated_quats = orientations.unsqueeze(1).repeat(1, 3, 1).reshape(-1, 4) + rotated_axes = quat_apply(repeated_quats, scaled_axes.reshape(-1, 3)).reshape(-1, 3, 3) + starts = translations.unsqueeze(1).repeat(1, 3, 1).reshape(-1, 3) + ends = (translations.unsqueeze(1) + rotated_axes).reshape(-1, 3) + colors = torch.tensor( + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.35, 1.0]], + dtype=torch.float32, + device=translations.device, + ).repeat(translations.shape[0], 1) + return starts, ends, colors diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 8c8bb0bed9d8..b548a3e5f4f3 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -18,6 +18,7 @@ from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds, resolve_visible_env_indices +from .newton_visualization_markers import render_newton_visualization_markers from .newton_visualizer_cfg import NewtonVisualizerCfg logger = logging.getLogger(__name__) @@ -268,6 +269,7 @@ def __init__(self, cfg: NewtonVisualizerCfg): self._scene_data_provider = None self._last_camera_pose: tuple[tuple[float, float, float], tuple[float, float, float]] | None = None self._headless_no_viewer = False + self._resolved_visible_env_ids: list[int] | None = None def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: """Initialize viewer resources and bind scene data provider. @@ -335,8 +337,10 @@ 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) - _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._resolved_visible_env_ids = resolve_visible_env_indices(self._env_ids, self.cfg.max_visible_envs, num_envs) + num_visualized_envs = ( + len(self._resolved_visible_env_ids) if self._resolved_visible_env_ids is not None else num_envs + ) self._log_initialization_table( logger=logger, title="NewtonVisualizer Configuration", @@ -374,6 +378,7 @@ def step(self, dt: float) -> None: self._update_camera_from_usd_path() self._state = self._scene_data_provider.get_newton_state() + num_envs = int(self._scene_data_provider.get_metadata().get("num_envs", 0)) contacts = None if self._viewer.show_contacts: @@ -401,6 +406,10 @@ def step(self, dt: float) -> None: self._viewer.log_contacts(contacts, self._state) except RuntimeError as exc: logger.debug(f"[NewtonVisualizer] Failed to log contacts: {exc}") + if self.cfg.enable_markers: + render_newton_visualization_markers( + self._viewer, self._resolved_visible_env_ids, num_envs=num_envs + ) self._viewer.end_frame() else: self._viewer._update() @@ -475,8 +484,8 @@ def _update_camera_from_usd_path(self) -> None: self._apply_camera_pose(pose) def supports_markers(self) -> bool: - """Newton OpenGL viewer does not implement Isaac Lab marker primitives.""" - return False + """Newton OpenGL viewer supports Isaac Lab markers through viewer-side meshes and lines.""" + return bool(self.cfg.enable_markers) def supports_live_plots(self) -> bool: """Newton OpenGL viewer does not provide live-plot panels.""" diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py index 5390802df69d..5600ad2ee9c4 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py @@ -20,6 +20,7 @@ from isaaclab.visualizers.base_visualizer import BaseVisualizer +from isaaclab_visualizers.newton.newton_visualization_markers import render_newton_visualization_markers from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds, resolve_visible_env_indices from .rerun_visualizer_cfg import RerunVisualizerCfg @@ -133,6 +134,7 @@ def __init__(self, cfg: RerunVisualizerCfg): self._state = None self._scene_data_provider = None self._last_camera_pose: tuple[tuple[float, float, float], tuple[float, float, float]] | None = None + self._resolved_visible_env_ids: list[int] | None = None def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: """Initialize rerun viewer and bind scene data provider. @@ -196,8 +198,10 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._viewer.scaling = 1.0 self._viewer._paused = False - _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._resolved_visible_env_ids = resolve_visible_env_indices(self._env_ids, self.cfg.max_visible_envs, num_envs) + num_visualized_envs = ( + len(self._resolved_visible_env_ids) if self._resolved_visible_env_ids is not None else num_envs + ) self._log_initialization_table( logger=logger, title="RerunVisualizer Configuration", @@ -235,16 +239,22 @@ def step(self, dt: float) -> None: self._update_camera_from_usd_path() self._state = self._scene_data_provider.get_newton_state() + num_envs = int(self._scene_data_provider.get_metadata().get("num_envs", 0)) if not self._viewer.is_paused(): self._viewer.begin_frame(self._sim_time) - if self._state is not None: - body_q = getattr(self._state, "body_q", None) - if hasattr(body_q, "shape") and body_q.shape[0] == 0: - self._viewer.end_frame() - return - self._viewer.log_state(self._state) - self._viewer.end_frame() + try: + if self._state is not None: + body_q = getattr(self._state, "body_q", None) + if hasattr(body_q, "shape") and body_q.shape[0] == 0: + return + self._viewer.log_state(self._state) + if self.cfg.enable_markers: + render_newton_visualization_markers( + self._viewer, self._resolved_visible_env_ids, num_envs=num_envs + ) + finally: + self._viewer.end_frame() def close(self) -> None: """Close viewer/session resources.""" @@ -323,8 +333,8 @@ def _update_camera_from_usd_path(self) -> None: self._apply_camera_pose(pose) def supports_markers(self) -> bool: - """Rerun backend currently does not expose Isaac Lab marker primitives.""" - return False + """Rerun backend supports Isaac Lab markers through Newton viewer primitives.""" + return bool(self.cfg.enable_markers) def supports_live_plots(self) -> bool: """Rerun backend currently does not expose Isaac Lab live-plot widgets.""" diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py index a629ab8b2fed..44a0e92c1d70 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py @@ -19,6 +19,7 @@ from isaaclab.visualizers.base_visualizer import BaseVisualizer +from isaaclab_visualizers.newton.newton_visualization_markers import render_newton_visualization_markers from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds, resolve_visible_env_indices from .viser_visualizer_cfg import ViserVisualizerCfg @@ -129,6 +130,8 @@ def __init__(self, cfg: ViserVisualizerCfg): self._active_record_path: str | None = None self._last_camera_pose: tuple[tuple[float, float, float], tuple[float, float, float]] | None = None self._pending_camera_pose: tuple[tuple[float, float, float], tuple[float, float, float]] | None = None + self._resolved_visible_env_ids: list[int] | None = None + self._warned_marker_render_failure = False def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: """Initialize viewer resources and bind scene data provider. @@ -151,8 +154,12 @@ 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_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 + self._resolved_visible_env_ids = resolve_visible_env_indices( + self._env_ids, self.cfg.max_visible_envs, num_envs_meta + ) + num_visualized_envs = ( + len(self._resolved_visible_env_ids) if self._resolved_visible_env_ids is not None else num_envs_meta + ) viewer_url = _viser_web_viewer_url(self.cfg.port) self._log_initialization_table( logger=logger, @@ -183,10 +190,26 @@ def step(self, dt: float) -> None: self._apply_pending_camera_pose() self._state = self._scene_data_provider.get_newton_state() + num_envs = int(self._scene_data_provider.get_metadata().get("num_envs", 0)) self._sim_time += dt self._viewer.begin_frame(self._sim_time) - self._viewer.log_state(self._state) - self._viewer.end_frame() + try: + self._viewer.log_state(self._state) + if self.cfg.enable_markers: + self._render_markers(num_envs) + finally: + self._viewer.end_frame() + + def _render_markers(self, num_envs: int) -> None: + """Render marker overlays without letting them interrupt Viser body updates.""" + try: + render_newton_visualization_markers(self._viewer, self._resolved_visible_env_ids, num_envs=num_envs) + except Exception as exc: + if not self._warned_marker_render_failure: + logger.warning("[ViserVisualizer] Marker rendering failed; continuing body updates: %s", exc) + self._warned_marker_render_failure = True + else: + logger.debug("[ViserVisualizer] Marker rendering failed: %s", exc) def close(self) -> None: """Close viewer resources and finalize optional recording.""" @@ -223,8 +246,8 @@ def is_training_paused(self) -> bool: return False def supports_markers(self) -> bool: - """Viser backend currently does not expose Isaac Lab marker primitives.""" - return False + """Viser backend supports Isaac Lab markers through Newton viewer primitives.""" + return bool(self.cfg.enable_markers) def supports_live_plots(self) -> bool: """Viser backend currently does not expose Isaac Lab live-plot widgets.""" From fa5959feb911664b11318cc3296903fe6c7d7d56 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Tue, 5 May 2026 14:53:51 -0700 Subject: [PATCH 002/133] Improves hanging flakiness in CI tests (#5479) # Description - Increase the CI startup-hang grace period from 45s to 120s so slow but valid Kit startup is not killed prematurely. - Make `SurfaceGripper` fail fast on non-CPU simulation backends before loading the surface gripper extension. - Skip the CI-only `SurfaceGripperView` CPU initialization path that can deadlock, while keeping CUDA fail-fast coverage. ## 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 - [ ] 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 --- .../changelog.d/test-articulation-timeout.rst | 6 ++++++ .../assets/surface_gripper/surface_gripper.py | 6 +++--- .../isaaclab_physx/test/assets/test_surface_gripper.py | 10 ++++++++++ tools/conftest.py | 8 ++++---- 4 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 source/isaaclab_physx/changelog.d/test-articulation-timeout.rst diff --git a/source/isaaclab_physx/changelog.d/test-articulation-timeout.rst b/source/isaaclab_physx/changelog.d/test-articulation-timeout.rst new file mode 100644 index 000000000000..e0c1b96870c2 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/test-articulation-timeout.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_physx.assets.SurfaceGripper` initialization on + non-CPU simulation backends to raise before loading the surface gripper + extension, avoiding hangs during startup. diff --git a/source/isaaclab_physx/isaaclab_physx/assets/surface_gripper/surface_gripper.py b/source/isaaclab_physx/isaaclab_physx/assets/surface_gripper/surface_gripper.py index 590289feb659..6662582dac7c 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/surface_gripper/surface_gripper.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/surface_gripper/surface_gripper.py @@ -443,9 +443,6 @@ def _initialize_impl(self) -> None: Use `--device cpu` to run the simulation on CPU. """ - enable_extension("isaacsim.robot.surface_gripper") - from isaacsim.robot.surface_gripper import GripperView - # Check that we are using the CPU backend. if self._device != "cpu": raise Exception( @@ -453,6 +450,9 @@ def _initialize_impl(self) -> None: " `--device cpu` to run the simulation on CPU." ) + enable_extension("isaacsim.robot.surface_gripper") + from isaacsim.robot.surface_gripper import GripperView + # obtain the first prim in the regex expression (all others are assumed to be a copy of this) template_prim = sim_utils.find_first_matching_prim(self._cfg.prim_path) if template_prim is None: diff --git a/source/isaaclab_physx/test/assets/test_surface_gripper.py b/source/isaaclab_physx/test/assets/test_surface_gripper.py index e85a4a8415cc..c075821bb985 100644 --- a/source/isaaclab_physx/test/assets/test_surface_gripper.py +++ b/source/isaaclab_physx/test/assets/test_surface_gripper.py @@ -9,6 +9,8 @@ """Launch Isaac Sim Simulator first.""" +import os + from isaaclab.app import AppLauncher # launch omniverse app @@ -35,6 +37,10 @@ # from isaacsim.robot.surface_gripper import GripperView +_RUNNING_CI = ( + os.environ.get("CI") == "true" or os.environ.get("GITHUB_ACTIONS") == "true" or os.environ.get("GITLAB_CI") +) + def generate_surface_gripper_cfgs( kinematic_enabled: bool = False, @@ -158,6 +164,10 @@ def sim(request): @pytest.mark.parametrize("device", ["cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @pytest.mark.isaacsim_ci +@pytest.mark.skipif( + _RUNNING_CI, + reason="Isaac Sim SurfaceGripperView initialization can deadlock in CI; keep CUDA fail-fast coverage only.", +) def test_initialization(sim, num_articulations, device, add_ground_plane) -> None: """Test initialization for articulation with a surface gripper. diff --git a/tools/conftest.py b/tools/conftest.py index bf92d62f6c46..55b00ce44afa 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -33,16 +33,16 @@ def pytest_ignore_collect(collection_path, config): on-disk cache is populated. """ -STARTUP_DEADLINE = 45 +STARTUP_DEADLINE = 120 """Seconds to wait for AppLauncher init or pytest collection before declaring a startup hang. AppLauncher prints ``[ISAACLAB] AppLauncher initialization complete`` to ``sys.__stderr__`` (never suppressed) when Kit finishes initializing, and pytest prints ``collected N items`` to stdout after collection. If neither appears -within this deadline the process is treated as hung. 45 s is above any -legitimate Kit startup (typically 30--60 s) while still catching real hangs -without wasting the full hard timeout. +within this deadline the process is treated as hung. Kit startup can exceed +60 s on cold CI workers, so this catches real startup hangs without killing +legitimate slow launches. """ STARTUP_HANG_RETRIES = 2 From 2d52d620dca239ba2fa59ee2e5de42ba98097cc4 Mon Sep 17 00:00:00 2001 From: hujc Date: Tue, 5 May 2026 15:52:45 -0700 Subject: [PATCH 003/133] Docs: update contributing.rst and PR template for changelog fragments (#5478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #5434 (fragment-based changelog system). Two contributor-facing references still pointed at the old "edit CHANGELOG.rst directly" workflow: - **`docs/source/refs/contributing.rst`** — *Maintaining a changelog and extension.toml* section described per-version editing of CHANGELOG.rst with manual SemVer bumps. - **`.github/PULL_REQUEST_TEMPLATE.md`** — checklist asked contributors to update the changelog and bump extension.toml directly. Replaced only the parts that talk about direct editing; section/style guidance (Added/Changed/Deprecated/Removed/Fixed, past tense, the sample bullets themselves) stays intact. ## Test plan - [x] Pre-commit clean - [ ] Verify Build Latest Docs CI step renders the new section correctly cc @kellyguo11 — addresses the doc gaps flagged after #5434 merged. --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- docs/source/refs/contributing.rst | 32 ++++++++++++++++--------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ee9fa4ebdc5e..c19d35fb7e79 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -47,7 +47,7 @@ To upload images to a PR -- simply drag and drop an image while in edit mode and - [ ] 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 a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there ## Type of change - Test change ## 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 --------- Signed-off-by: HuiDong Chen Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../changelog.d/huidongc-flaky-mark.skip | 0 .../test/rendering_test_utils.py | 42 +++++++++++++------ .../test/test_rendering_dexsuite_kuka.py | 1 - .../test_rendering_dexsuite_kuka_kitless.py | 1 - 4 files changed, 30 insertions(+), 14 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/huidongc-flaky-mark.skip diff --git a/source/isaaclab_tasks/changelog.d/huidongc-flaky-mark.skip b/source/isaaclab_tasks/changelog.d/huidongc-flaky-mark.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index 788064900f85..f79f55c553a5 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -66,13 +66,17 @@ # Parametrization: (physics_backend, renderer, data_type) # --------------------------------------------------------------------------- -# OVRTX kitless paths can segfault on GitHub Actions runners; keep warp/Kit paths in CI. -_SKIP_ON_GITHUB_ACTIONS = os.environ.get("GITHUB_ACTIONS") == "true" -_SKIP_ON_GITHUB_ACTIONS_MARK = pytest.mark.skipif( - _SKIP_ON_GITHUB_ACTIONS, - reason="Skipped on GitHub Actions until the test can run on GitHub Actions.", +# OVRTX kitless paths can segfault on CI runners; keep warp/Kit paths in CI. +_SKIP_ON_CI = any(os.environ.get(name) == "true" for name in ("CI", "GITHUB_ACTIONS", "GITLAB_CI")) +_SKIP_ON_CI_MARK = pytest.mark.skipif( + _SKIP_ON_CI, + reason="Skipped on CI runners until the test can run on CI runners.", ) +# Let's just accept the fact that low-resolution camera outputs from RTX renderers are not deterministic enough to pass +# golden image testing on every CI run. +_FLAKY_MARK = pytest.mark.flaky(max_runs=3, min_passes=1) + PHYSICS_RENDERER_AOV_COMBINATIONS = [ # physx + isaacsim_rtx_renderer pytest.param( @@ -80,42 +84,49 @@ "isaacsim_rtx_renderer", "rgb", id="physx-isaacsim_rtx-rgb", + marks=_FLAKY_MARK, ), pytest.param( "physx", "isaacsim_rtx_renderer", "albedo", id="physx-isaacsim_rtx-albedo", + marks=_FLAKY_MARK, ), pytest.param( "physx", "isaacsim_rtx_renderer", "depth", id="physx-isaacsim_rtx-depth", + marks=_FLAKY_MARK, ), pytest.param( "physx", "isaacsim_rtx_renderer", "simple_shading_constant_diffuse", id="physx-isaacsim_rtx-simple_shading_constant_diffuse", + marks=_FLAKY_MARK, ), pytest.param( "physx", "isaacsim_rtx_renderer", "simple_shading_diffuse_mdl", id="physx-isaacsim_rtx-simple_shading_diffuse_mdl", + marks=_FLAKY_MARK, ), pytest.param( "physx", "isaacsim_rtx_renderer", "simple_shading_full_mdl", id="physx-isaacsim_rtx-simple_shading_full_mdl", + marks=_FLAKY_MARK, ), pytest.param( "physx", "isaacsim_rtx_renderer", "semantic_segmentation", id="physx-isaacsim_rtx-semantic_segmentation", + marks=_FLAKY_MARK, ), # newton + isaacsim_rtx_renderer pytest.param( @@ -123,42 +134,49 @@ "isaacsim_rtx_renderer", "rgb", id="newton-isaacsim_rtx-rgb", + marks=_FLAKY_MARK, ), pytest.param( "newton", "isaacsim_rtx_renderer", "albedo", id="newton-isaacsim_rtx-albedo", + marks=_FLAKY_MARK, ), pytest.param( "newton", "isaacsim_rtx_renderer", "depth", id="newton-isaacsim_rtx-depth", + marks=_FLAKY_MARK, ), pytest.param( "newton", "isaacsim_rtx_renderer", "simple_shading_constant_diffuse", id="newton-isaacsim_rtx-simple_shading_constant_diffuse", + marks=_FLAKY_MARK, ), pytest.param( "newton", "isaacsim_rtx_renderer", "simple_shading_diffuse_mdl", id="newton-isaacsim_rtx-simple_shading_diffuse_mdl", + marks=_FLAKY_MARK, ), pytest.param( "newton", "isaacsim_rtx_renderer", "simple_shading_full_mdl", id="newton-isaacsim_rtx-simple_shading_full_mdl", + marks=_FLAKY_MARK, ), pytest.param( "newton", "isaacsim_rtx_renderer", "semantic_segmentation", id="newton-isaacsim_rtx-semantic_segmentation", + marks=_FLAKY_MARK, ), # physx + newton_renderer (warp) pytest.param( @@ -182,49 +200,49 @@ "ovrtx_renderer", "rgb", id="newton-ovrtx-rgb", - marks=_SKIP_ON_GITHUB_ACTIONS_MARK, + marks=_SKIP_ON_CI_MARK, ), pytest.param( "newton", "ovrtx_renderer", "albedo", id="newton-ovrtx-albedo", - marks=_SKIP_ON_GITHUB_ACTIONS_MARK, + marks=_SKIP_ON_CI_MARK, ), pytest.param( "newton", "ovrtx_renderer", "depth", id="newton-ovrtx-depth", - marks=_SKIP_ON_GITHUB_ACTIONS_MARK, + marks=_SKIP_ON_CI_MARK, ), pytest.param( "newton", "ovrtx_renderer", "simple_shading_constant_diffuse", id="newton-ovrtx-simple_shading_constant_diffuse", - marks=_SKIP_ON_GITHUB_ACTIONS_MARK, + marks=_SKIP_ON_CI_MARK, ), pytest.param( "newton", "ovrtx_renderer", "simple_shading_diffuse_mdl", id="newton-ovrtx-simple_shading_diffuse_mdl", - marks=_SKIP_ON_GITHUB_ACTIONS_MARK, + marks=_SKIP_ON_CI_MARK, ), pytest.param( "newton", "ovrtx_renderer", "simple_shading_full_mdl", id="newton-ovrtx-simple_shading_full_mdl", - marks=_SKIP_ON_GITHUB_ACTIONS_MARK, + marks=_SKIP_ON_CI_MARK, ), pytest.param( "newton", "ovrtx_renderer", "semantic_segmentation", id="newton-ovrtx-semantic_segmentation", - marks=_SKIP_ON_GITHUB_ACTIONS_MARK, + marks=_SKIP_ON_CI_MARK, ), # newton + newton_renderer (warp) pytest.param( diff --git a/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka.py b/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka.py index 623c1e08c233..0400c3386e63 100644 --- a/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka.py +++ b/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka.py @@ -31,7 +31,6 @@ _attach_comparison_properties_fixture = make_attach_comparison_properties_fixture(_COMPARISON_SCORES) -@pytest.mark.flaky(max_runs=3, min_passes=1) @pytest.mark.parametrize("physics_backend,renderer,data_type", PHYSICS_RENDERER_AOV_COMBINATIONS) def test_rendering_dexsuite_kuka(physics_backend, renderer, data_type): """Test dexsuite kuka allegro lift environment rendering correctness.""" diff --git a/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka_kitless.py b/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka_kitless.py index f76d43364ab5..15afbee806b1 100644 --- a/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka_kitless.py +++ b/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka_kitless.py @@ -27,7 +27,6 @@ _require_ovrtx_install_fixture = make_require_ovrtx_install_fixture() -@pytest.mark.flaky(max_runs=3, min_passes=1) @pytest.mark.parametrize("physics_backend,renderer,data_type", KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS) def test_rendering_dexsuite_kuka_kitless(physics_backend, renderer, data_type): """Camera output must match golden images (Dexsuite Kuka-Allegro Lift, single camera).""" From efd9d1e742f6ef5040e44d98a9046320832e8f70 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Wed, 6 May 2026 17:56:50 +0200 Subject: [PATCH 005/133] perf: add PrepareForReuse to FabricFrameView, remove sync_usd_on_fabric_write (#5380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replace the `sync_usd_on_fabric_write` workaround in `FabricFrameView` with proper `PrepareForReuse()` calls on the Fabric `PrimSelection`. This tells the renderer (FSD/Storm) that Fabric data has changed, so the next rendered frame reflects updated transforms — eliminating the need to copy Fabric writes back to USD. ## Motivation The existing `sync_usd_on_fabric_write` flag worked by mirroring every Fabric write back to USD, which defeated the performance benefits of Fabric. With `PrepareForReuse()`, the rendering pipeline is properly notified of Fabric data changes without any USD writeback. Additionally, the old code incorrectly fell back to USD for CPU devices — Warp handles CPU Fabric buffers correctly, so the fallback was unnecessary. This addresses two of the issues raised in @pbarejko Piotr's review of PR #4923: - **Issue #1** (USD write-back): Fabric writes no longer sync back to USD - **Issue #4** (PrepareForReuse): Renderer notification via `PrepareForReuse()` instead of USD writeback ## Changes ### Core (FabricFrameView) - Call `_prepare_for_reuse()` in write paths (`set_world_poses`, `set_scales`) to notify the renderer - Remove `sync_usd_on_fabric_write` parameter (accepted via `**kwargs` for backward compat) - Remove incorrect CPU/device fallback warnings — Warp handles CPU Fabric buffers correctly - Add `_rebuild_fabric_arrays()` for topology change recovery when `PrepareForReuse()` returns True, with assertion guarding the prim-count invariant ### Camera - Remove `sync_usd_on_fabric_write=True` from FrameView construction in `camera.py` ## Benchmark Results 1024 prims, 50 iterations, NVIDIA L40 GPU: | Operation | USD (ms) | Fabric (ms) | Speedup | |---|---|---|---| | Get World Poses | 14.71 | 0.07 | **203x** | | Set World Poses | 40.75 | 0.16 | **259x** | | Interleaved Set→Get | 55.90 | 0.24 | **232x** | | Get Local Poses | 11.08 | 11.12 | 1.0x | | Set Local Poses | 16.14 | 16.28 | 1.0x | Local poses fall back to USD (expected — Fabric only accelerates world poses via `omni:fabric:worldMatrix`). ## Tests Added | Test | What it validates | |------|------------------| | `test_camera_pose_update_reflected_in_render` | Camera pose changes propagate to rendered depth (close vs far) for CPU/GPU, tiled/non-tiled | | `test_fabric_set_world_does_not_write_back_to_usd` | Fabric writes stay in Fabric, USD prim unchanged | | `test_set_world_updates_local` (xfail) | Documents Issue #5: `set_world_poses` doesn't update local pose in Fabric mode | ## Test Results | Test Suite | Passed | Skipped | Xfailed | Total | |---|---|---|---|---| | Fabric contract tests (`test_views_xform_prim_fabric.py`) | 17 | 16 | 1 | 34 | | USD contract tests (`test_views_xform_prim.py`) | 45 | 0 | 0 | 45 | | Camera render test (`test_tiled_camera.py`) | 8 | 0 | 0 | 8 | ## Type of change - Performance improvement (removes redundant USD writeback on Fabric operations) ## 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 - [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 *No doc changes needed (parameter wasn't referenced in any docs)* --- CONTRIBUTORS.md | 1 + .../fix-fabric-prepare-for-reuse.rst | 8 + .../isaaclab/sensors/camera/camera.py | 7 +- .../isaaclab/sim/views/usd_frame_view.py | 3 +- source/isaaclab/test/sensors/test_camera.py | 66 +++++++++ .../test_multi_mesh_ray_caster_camera.py | 4 +- .../test/sensors/test_ray_caster_camera.py | 4 +- .../fix-fabric-prepare-for-reuse.rst | 12 ++ .../sim/views/fabric_frame_view.py | 135 ++++++++++++----- .../test/sim/test_views_xform_prim_fabric.py | 137 +++++++++++++++++- 10 files changed, 323 insertions(+), 54 deletions(-) create mode 100644 source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst create mode 100644 source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 82c5eb49ba92..a13693c64171 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -144,6 +144,7 @@ Guidelines for modifications: * Patrick Yin * Paul Reeves * Peter Du +* Peter Verswyvelen * Philipp Reist * Piotr Barejko * Pulkit Goyal diff --git a/source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst b/source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst new file mode 100644 index 000000000000..20a6d385c094 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Updated :class:`~isaaclab.sensors.camera.Camera` to construct its internal + :class:`~isaaclab.sim.views.FrameView` without the now-removed + ``sync_usd_on_fabric_write`` kwarg. USD attributes on camera prims are + no longer kept in sync with Fabric writes; read poses through the view's + getters instead. diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index be52668dbd6c..675ee4a7bb7c 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -18,8 +18,7 @@ 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 -from isaaclab.renderers.camera_render_spec import CameraRenderSpec +from isaaclab.renderers import BaseRenderer, CameraRenderSpec from isaaclab.sim.views import FrameView from isaaclab.utils import to_camel_case from isaaclab.utils.math import ( @@ -380,9 +379,7 @@ def _initialize_impl(self): # references to prims located in the stage. sim_ctx.render_context.ensure_prepare_stage(self.stage, self._num_envs) - # 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 = FrameView(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) # Check that sizes are correct if self._view.count != self._num_envs: raise RuntimeError( diff --git a/source/isaaclab/isaaclab/sim/views/usd_frame_view.py b/source/isaaclab/isaaclab/sim/views/usd_frame_view.py index 7730c3dd735d..88392d54b2a0 100644 --- a/source/isaaclab/isaaclab/sim/views/usd_frame_view.py +++ b/source/isaaclab/isaaclab/sim/views/usd_frame_view.py @@ -72,8 +72,7 @@ def __init__( 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``. + construction when callers pass backend-specific options. Raises: ValueError: If any matched prim is not Xformable or doesn't have standardized diff --git a/source/isaaclab/test/sensors/test_camera.py b/source/isaaclab/test/sensors/test_camera.py index daed8e95773d..99c93fec7323 100644 --- a/source/isaaclab/test/sensors/test_camera.py +++ b/source/isaaclab/test/sensors/test_camera.py @@ -1161,6 +1161,72 @@ def cleanup(self, render_data): Renderer._registry.pop(backend, None) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_camera_pose_update_reflected_in_render(setup_camera_device, device): + """Camera pose changes via FrameView should be visible in rendered depth. + + Moves the camera close then far, renders depth, and verifies that the mean + valid depth from the far position is significantly larger (>1.5×) than the + close position. This validates that Fabric-side pose writes (via + PrepareForReuse) and USD writes are correctly propagated to the RTX + renderer. + """ + sim, _unused_cam_cfg, dt = setup_camera_device + + cam_cfg = CameraCfg( + prim_path="/World/PoseTestCam", + height=128, + width=256, + update_period=0, + update_latest_camera_pose=True, + data_types=["distance_to_camera"], + spawn=sim_utils.PinholeCameraCfg( + focal_length=24.0, + focus_distance=400.0, + horizontal_aperture=20.955, + clipping_range=(0.1, 1.0e5), + ), + ) + camera = Camera(cam_cfg) + try: + sim.reset() + + target = torch.tensor([[0.0, 0.0, 0.0]], dtype=torch.float32, device=camera.device) + max_range = cam_cfg.spawn.clipping_range[1] + + # -- close position -- + eyes_close = torch.tensor([[2.0, 2.0, 2.0]], dtype=torch.float32, device=camera.device) + camera.set_world_poses_from_view(eyes_close, target) + sim.step() + camera.update(dt) + depth_close = camera.data.output["distance_to_camera"].clone() + + # -- far position -- + eyes_far = torch.tensor([[8.0, 8.0, 8.0]], dtype=torch.float32, device=camera.device) + camera.set_world_poses_from_view(eyes_far, target) + sim.step() + camera.update(dt) + depth_far = camera.data.output["distance_to_camera"].clone() + + # -- validate -- + valid_close = depth_close[depth_close < max_range] + valid_far = depth_far[depth_far < max_range] + + assert valid_close.numel() > 0, "No valid close-range depth pixels" + assert valid_far.numel() > 0, "No valid far-range depth pixels" + + mean_close = valid_close.mean().item() + mean_far = valid_far.mean().item() + + assert mean_far > mean_close * 1.5, ( + f"Far depth ({mean_far:.2f}) should be > 1.5× close depth ({mean_close:.2f}). " + "Camera pose change may not be reaching the renderer." + ) + finally: + del camera + + def _populate_scene(): """Add prims to the scene.""" # Ground-plane 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 8657c938c691..7e7efe16d091 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 @@ -752,11 +752,11 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_simulation): # set camera position camera_warp.set_world_poses_from_view( - eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_warp.device), + eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_warp.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_warp.device), ) camera_usd.set_world_poses_from_view( - eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_usd.device), + eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_usd.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_usd.device), ) diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index a913d38dd833..752734936934 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -898,11 +898,11 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_sim, focal_length_ # set camera position camera_warp.set_world_poses_from_view( - eyes=torch.tensor([[0.001, 0.0, 5.0]], device=camera_warp.device), + eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_warp.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_warp.device), ) camera_usd.set_world_poses_from_view( - eyes=torch.tensor([[0.001, 0.0, 5.0]], device=camera_usd.device), + eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_usd.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_usd.device), ) diff --git a/source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst b/source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst new file mode 100644 index 000000000000..e7d842da72bd --- /dev/null +++ b/source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst @@ -0,0 +1,12 @@ +Changed +^^^^^^^ + +* **Breaking:** Removed the ``sync_usd_on_fabric_write`` keyword argument from + :class:`~isaaclab_physx.sim.views.FabricFrameView`. Fabric writes + (``set_world_poses``, ``set_scales``) now notify the renderer via + ``PrepareForReuse()`` on the underlying ``PrimSelection`` instead of writing + back to USD, which is ~200x faster and avoids the stale USD shadow state the + old path produced. Callers passing ``sync_usd_on_fabric_write=True`` should + remove the argument; if they relied on USD reflecting Fabric writes, they + should now read Fabric poses directly via the view's getters or refresh USD + explicitly. 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 index de65e8501793..1bcff86d57ac 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py @@ -23,6 +23,12 @@ logger = logging.getLogger(__name__) +# TODO: extend this to ``cuda:N`` once we wire up multi-GPU support for the view. +# Recent Kit / USDRT releases do support multi-GPU ``SelectPrims``, but the +# rest of the FabricFrameView wiring (selections, indexed arrays, etc.) still +# assumes a single device — to be tackled in a follow-up. +_fabric_supported_devices = ("cpu", "cuda", "cuda:0") + def _to_float32_2d(a: wp.array | torch.Tensor) -> wp.array | torch.Tensor: """Ensure array is compatible with Fabric kernels (2-D float32). @@ -47,9 +53,15 @@ class FabricFrameView(BaseFrameView): 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. + When Fabric is enabled, world-pose and scale operations use Warp kernels + operating on ``omni:fabric:worldMatrix``. All other operations delegate + to the internal USD view. + + After every Fabric write (``set_world_poses``, ``set_scales``), + :meth:`PrepareForReuse` is called on the ``PrimSelection`` to notify + the FSD renderer that Fabric data has changed and to detect topology + changes that require rebuilding internal mappings. Read operations + do not call PrepareForReuse to avoid unnecessary renderer invalidation. Pose getters return :class:`~isaaclab.utils.warp.ProxyArray`. Setters accept ``wp.array``. """ @@ -59,27 +71,32 @@ def __init__( prim_path: str, device: str = "cpu", validate_xform_ops: bool = True, - sync_usd_on_fabric_write: bool = False, stage: Usd.Stage | None = None, + **kwargs, ): + """Initialize the view. + + Args: + prim_path: USD prim-path pattern to match. + device: Device for Warp arrays (``"cpu"`` or ``"cuda:0"``). + validate_xform_ops: Whether to validate prim xform-ops. + stage: USD stage; defaults to the current sim context's stage. + **kwargs: Additional keyword arguments (ignored). Matches the signature of + :class:`~isaaclab.sim.views.UsdFrameView` so that the top-level + :class:`~isaaclab.sim.views.FrameView` factory can forward backend-agnostic + kwargs without each backend having to know about every option. + """ 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"): + if self._use_fabric and self._device not in _fabric_supported_devices: logger.warning( f"Fabric mode is not supported on device '{self._device}'. " - "USDRT SelectPrims and Warp fabric arrays only support cuda:0. " + "USDRT SelectPrims and Warp fabric arrays are currently " + f"only supported on {', '.join(_fabric_supported_devices)}. " "Falling back to standard USD operations. This may impact performance." ) self._use_fabric = False @@ -136,6 +153,8 @@ def set_world_poses(self, positions=None, orientations=None, indices=None): if not self._fabric_initialized: self._initialize_fabric() + self._prepare_for_reuse() + indices_wp = self._resolve_indices_wp(indices) count = indices_wp.shape[0] @@ -167,8 +186,6 @@ def set_world_poses(self, positions=None, orientations=None, indices=None): 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: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: if not self._use_fabric: @@ -231,6 +248,8 @@ def set_scales(self, scales, indices=None): if not self._fabric_initialized: self._initialize_fabric() + self._prepare_for_reuse() + indices_wp = self._resolve_indices_wp(indices) count = indices_wp.shape[0] @@ -258,8 +277,6 @@ def set_scales(self, scales, indices=None): 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: @@ -297,6 +314,56 @@ def get_scales(self, indices=None): wp.synchronize() return scales_wp + # ------------------------------------------------------------------ + # Internal — PrepareForReuse (renderer notification + topology tracking) + # ------------------------------------------------------------------ + + def _prepare_for_reuse(self) -> None: + """Call PrepareForReuse on the PrimSelection to notify the renderer. + + PrepareForReuse serves two purposes: + + 1. **Renderer notification**: Tells FSD/Storm that Fabric data has + been (or will be) modified, so the next rendered frame reflects + the updated transforms. + 2. **Topology change detection**: Returns True when Fabric's + internal memory layout changed (e.g., prims added/removed). + In that case, view-to-fabric index mappings and fabricarrays + must be rebuilt. + """ + if self._fabric_selection is None: + return + + topology_changed = self._fabric_selection.PrepareForReuse() + if topology_changed: + logger.info("Fabric topology changed — rebuilding view-to-fabric index mapping.") + self._rebuild_fabric_arrays() + + def _rebuild_fabric_arrays(self) -> None: + """Rebuild fabricarray and view↔fabric mappings after a topology change. + + Note: Only index mappings and fabricarrays are rebuilt. Position/orientation/scale + buffers are *not* resized because ``self.count`` is derived from the USD prim-path + pattern (via ``_usd_view.count``) and does not change when Fabric rearranges its + internal memory layout. The assertion below guards this invariant. + """ + assert self.count == self._default_view_indices.shape[0], ( + f"Prim count changed ({self.count} vs {self._default_view_indices.shape[0]}). " + "Fabric topology change added/removed tracked prims — full re-initialization required." + ) + self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=self._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=self._fabric_device, + ) + wp.synchronize() + + self._fabric_world_matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") + # ------------------------------------------------------------------ # Internal — Fabric initialization # ------------------------------------------------------------------ @@ -337,34 +404,25 @@ def _initialize_fabric(self) -> None: ) 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" + # The constructor should have taken care of this, but double check here to avoid regressions + assert self._device in _fabric_supported_devices 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, + device=self._device, ) - self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=fabric_device) + self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=self._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, + device=self._device, ) wp.synchronize() @@ -376,13 +434,17 @@ def _initialize_fabric(self) -> None: 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_device = self._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.""" + """Sync Fabric world matrices from USD once, on the first read. + + ``set_world_poses`` and ``set_scales`` each set ``_fabric_usd_sync_done`` + themselves, so no explicit flag assignment is needed here. + """ if not self._fabric_initialized: self._initialize_fabric() @@ -391,13 +453,8 @@ def _sync_fabric_from_usd_once(self) -> None: orientations_usd = orientations_usd_ta.warp 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.""" 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 index 4376c0e0b8ea..f0c18ccb98c7 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -21,8 +21,9 @@ import pytest # noqa: E402 import torch # noqa: E402 +import warp as wp # noqa: E402 from frame_view_contract_utils import * # noqa: F401, F403, E402 -from frame_view_contract_utils import CHILD_OFFSET, ViewBundle # noqa: E402 +from frame_view_contract_utils import CHILD_OFFSET, ViewBundle, test_set_world_updates_local # noqa: E402 from isaaclab_physx.sim.views import FabricFrameView as FrameView # noqa: E402 from pxr import Gf, UsdGeom # noqa: E402 @@ -45,8 +46,6 @@ def test_setup_teardown(): 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") # ------------------------------------------------------------------ @@ -95,7 +94,7 @@ def factory(num_envs: int, device: str) -> ViewBundle: 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) + view = FrameView("/World/Parent_.*/Child", device=device) return ViewBundle( view=view, get_parent_pos=_get_parent_positions, @@ -104,3 +103,133 @@ def factory(num_envs: int, device: str) -> ViewBundle: ) return factory + + +# ------------------------------------------------------------------ +# Override shared contract test with expected failure for Fabric. +# FabricFrameView.set_world_poses writes to Fabric worldMatrix only; the local +# pose (read via USD) does not reflect the change because there is no +# Fabric → USD writeback for local poses. This is tracked as Issue #5 +# (localMatrix: set_local_poses falls back to USD). +# ------------------------------------------------------------------ + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.xfail( + reason=( + "Issue #5: FabricFrameView.set_world_poses writes to Fabric worldMatrix only. " + "get_local_poses reads from stale USD because there is no Fabric→USD " + "writeback for local poses." + ), + strict=True, +) +def test_set_world_updates_local(device, view_factory): # noqa: F811 + """Override the shared test to mark it as expected failure.""" + from frame_view_contract_utils import test_set_world_updates_local as _impl # noqa: PLC0415 + + _impl(device, view_factory) + + +# ------------------------------------------------------------------ +# Fabric-specific tests (not in shared contract) +# ------------------------------------------------------------------ + + +@wp.kernel +def _fill_position(out: wp.array(dtype=wp.float32, ndim=2), x: float, y: float, z: float): + i = wp.tid() + out[i, 0] = wp.float32(x) + out[i, 1] = wp.float32(y) + out[i, 2] = wp.float32(z) + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_fabric_set_world_does_not_write_back_to_usd(device, view_factory): + """Verify that set_world_poses in Fabric mode does NOT sync back to USD. + + This confirms the removal of sync_usd_on_fabric_write. After calling + set_world_poses, the USD prim's xformOps should still contain the + original (stale) values. + """ + bundle = view_factory(1, device) + view = bundle.view + + # Capture the original USD world position BEFORE any Fabric write + stage = sim_utils.get_current_stage() + prim = stage.GetPrimAtPath(view.prim_paths[0]) + xform_cache = UsdGeom.XformCache() + usd_tf_before = xform_cache.GetLocalToWorldTransform(prim) + usd_t_before = usd_tf_before.ExtractTranslation() + orig_usd_pos = torch.tensor([float(usd_t_before[0]), float(usd_t_before[1]), float(usd_t_before[2])]) + + # Write to Fabric — move to (99, 99, 99) + new_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_pos, 99.0, 99.0, 99.0], device=device) + view.set_world_poses(positions=new_pos) + + # Verify Fabric has the new position + fab_pos, _ = view.get_world_poses() + pos_torch = wp.to_torch(fab_pos) + assert torch.allclose(pos_torch, torch.tensor([[99.0, 99.0, 99.0]], device=device), atol=0.1), ( + f"Fabric should have new position, got {pos_torch}" + ) + + # Verify USD still has the ORIGINAL position (no writeback). Equality, not + # approximate — USD should literally not have moved, so any drift would + # indicate a residual writeback path. + xform_cache_after = UsdGeom.XformCache() + usd_tf_after = xform_cache_after.GetLocalToWorldTransform(prim) + usd_t_after = usd_tf_after.ExtractTranslation() + usd_pos_after = torch.tensor([float(usd_t_after[0]), float(usd_t_after[1]), float(usd_t_after[2])]) + assert torch.allclose(usd_pos_after, orig_usd_pos, atol=0.0), ( + f"USD should still have original position {orig_usd_pos}, but got {usd_pos_after}. " + f"sync_usd_on_fabric_write may not have been fully removed." + ) + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_fabric_rebuild_after_topology_change(device, view_factory, monkeypatch): + """Forcing the topology-changed branch on a write triggers + :meth:`_rebuild_fabric_arrays` and leaves the view in a state where + subsequent writes/reads still produce correct data. + + Real ``PrimSelection.PrepareForReuse`` reports topology change only when + Fabric reallocates internally, which is hard to provoke from a unit test. + Instead we monkeypatch ``_prepare_for_reuse`` on the instance to always + take the rebuild branch and verify the view remains usable. + """ + bundle = view_factory(2, device) + view = bundle.view + + # First write — initializes Fabric and binds _fabric_selection. + initial = wp.zeros((2, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=2, inputs=[initial, 1.0, 2.0, 3.0], device=device) + view.set_world_poses(positions=initial) + + rebuild_calls = [] + real_rebuild = view._rebuild_fabric_arrays + + def spy_rebuild(): + rebuild_calls.append(True) + real_rebuild() + + def force_topology_changed(): + if view._fabric_selection is not None: + view._fabric_selection.PrepareForReuse() + spy_rebuild() + + monkeypatch.setattr(view, "_prepare_for_reuse", force_topology_changed) + + # Trigger another write — goes through the forced topology-change branch. + new = wp.zeros((2, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=2, inputs=[new, 4.0, 5.0, 6.0], device=device) + view.set_world_poses(positions=new) + + assert rebuild_calls, "Forced topology-change branch did not invoke _rebuild_fabric_arrays" + + # Read back — proves the rebuilt _view_to_fabric and _fabric_world_matrices + # are still consistent. + ret_pos, _ = view.get_world_poses() + pos_torch = wp.to_torch(ret_pos) + expected = torch.tensor([[4.0, 5.0, 6.0], [4.0, 5.0, 6.0]], device=device) + assert torch.allclose(pos_torch, expected, atol=1e-7), f"Read after rebuild failed on {device}: {pos_torch}" From b258e87c7c6e8c6695b8ce15d547530199eb0e10 Mon Sep 17 00:00:00 2001 From: John Date: Wed, 6 May 2026 15:30:42 -0700 Subject: [PATCH 006/133] Update pytorch3d installation command in locomanipulation SDG documentation (#5506) # Description This PR changes the PyTorch3d installation command in the locomanipulation SDG policy training / rollout to use git and install pytorch3d from source. Fixes # (issue) NV bug 6115836 ## Type of change - Bug fix (non-breaking change which fixes an issue) ## 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/overview/imitation-learning/humanoids_imitation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/overview/imitation-learning/humanoids_imitation.rst b/docs/source/overview/imitation-learning/humanoids_imitation.rst index 694c3225dbf1..ae9989ddd8de 100644 --- a/docs/source/overview/imitation-learning/humanoids_imitation.rst +++ b/docs/source/overview/imitation-learning/humanoids_imitation.rst @@ -629,7 +629,7 @@ Then, from the **Isaac-GR00T** directory, install GR00T N1.5 and its dependencie uv pip install -e . uv pip install wheel MAX_JOBS=4 uv pip install --no-build-isolation flash-attn==2.7.1.post4 - MAX_JOBS=4 uv pip install --no-build-isolation pytorch3d + MAX_JOBS=4 uv pip install --no-build-isolation 'git+https://github.com/facebookresearch/pytorch3d.git@v0.7.9' uv pip install diffusers decord zmq Convert dataset to LeRobot format From 7b44452e9fa2893522332d70f542f6d232dc8e09 Mon Sep 17 00:00:00 2001 From: r-schmitt <139814266+r-schmitt@users.noreply.github.com> Date: Thu, 7 May 2026 11:53:13 -0400 Subject: [PATCH 007/133] use RendererCfg as default renderer_cfg in CameraCfg (#5521) # Description the camera config was importing `isaaclab_physx.renderers` because the default render_cfg was set to that config. this PR sets that to RendererConfig to remove the import, but provides a get_default_render_config method to the backend_utils to lazily import the config if needed. this is called __post_init__ on the camera config to replace the generic config as soon as possible to avoid downstream issues referencing the renderer config. this action can be moved to the factory if downstream references are cleaned up. ## Type of change - Refactor to remove imports in cfg class ## 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: nvsekkin <72572910+nvsekkin@users.noreply.github.com> --- .../rschmitt_default_cameracfg_renderer.rst | 11 ++++++ .../isaaclab/sensors/camera/camera.py | 3 +- .../isaaclab/sensors/camera/camera_cfg.py | 12 +++++-- .../sensors/camera/tiled_camera_cfg.py | 8 +++++ .../isaaclab/isaaclab/utils/backend_utils.py | 36 +++++++++++++++++++ 5 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 source/isaaclab/changelog.d/rschmitt_default_cameracfg_renderer.rst diff --git a/source/isaaclab/changelog.d/rschmitt_default_cameracfg_renderer.rst b/source/isaaclab/changelog.d/rschmitt_default_cameracfg_renderer.rst new file mode 100644 index 000000000000..e11891e307b5 --- /dev/null +++ b/source/isaaclab/changelog.d/rschmitt_default_cameracfg_renderer.rst @@ -0,0 +1,11 @@ +Added +^^^^^ + +* Added :meth:`~isaaclab.utils.backend_utils.get_default_renderer_cfg`. to lazy load the IsaacRtxRendererCfg + +Changed +^^^^^^^ + +* :class:`~isaaclab.sensors.camera.CameraCfg` now defaults its render_cfg to :class:`~isaaclab.renderers.RenderCfg` + :meth:`~isaaclab.utils.backend_utils.get_default_renderer_cfg` is called during __post_init__ to replace + the generic RenderCfg with the default config :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg` diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 675ee4a7bb7c..c481002e524e 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -113,7 +113,8 @@ def __init__(self, cfg: CameraCfg): # IsaacRtxRendererCfg overrides to flip /isaaclab/render/rtx_sensors. The # flag must be set pre-sim.reset() because SimulationContext.is_rendering # and several env classes read it before the renderer's __init__ runs. - if self.cfg.renderer_cfg.renderer_type == "isaac_rtx": + renderer_type = getattr(self.cfg.renderer_cfg, "renderer_type", None) + if renderer_type == "isaac_rtx": get_settings_manager().set_bool("/isaaclab/render/rtx_sensors", True) # Compute camera orientation (convention conversion) and spawn diff --git a/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py b/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py index 5ee6cf30b6f6..3ecec15d11d3 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py @@ -9,8 +9,6 @@ from dataclasses import MISSING, field from typing import TYPE_CHECKING, Literal -from isaaclab_physx.renderers import IsaacRtxRendererCfg - from isaaclab.renderers import RendererCfg from isaaclab.sim import FisheyeCameraCfg, PinholeCameraCfg from isaaclab.utils import configclass @@ -191,7 +189,7 @@ class OffsetCfg: on :attr:`renderer_cfg` instead. """ - renderer_cfg: RendererCfg = field(default_factory=IsaacRtxRendererCfg) + renderer_cfg: RendererCfg = field(default_factory=RendererCfg) """Renderer configuration for camera sensor.""" def __post_init__(self): @@ -201,6 +199,14 @@ def __post_init__(self): :class:`DeprecationWarning` and is copied onto ``self.renderer_cfg`` when that cfg defines the same-named field. """ + # TODO when Camera.__init__ moves rtx_sensor setting out of camera initialization + # the default renderer config instantiation can be moved into the render factory + # and get_default_render_cfg method can be removed from backend_utils + renderer_type = getattr(self.renderer_cfg, "renderer_type", None) + if renderer_type == "default": + from isaaclab.utils.backend_utils import get_default_renderer_cfg + + self.renderer_cfg = get_default_renderer_cfg() # Forwarded by name: any same-named field on ``renderer_cfg`` will receive the value. for field_name, default in _DEPRECATED_RENDERER_FIELD_DEFAULTS.items(): value = getattr(self, field_name) diff --git a/source/isaaclab/isaaclab/sensors/camera/tiled_camera_cfg.py b/source/isaaclab/isaaclab/sensors/camera/tiled_camera_cfg.py index d35ff285ff13..e200468daa78 100644 --- a/source/isaaclab/isaaclab/sensors/camera/tiled_camera_cfg.py +++ b/source/isaaclab/isaaclab/sensors/camera/tiled_camera_cfg.py @@ -27,6 +27,14 @@ class TiledCameraCfg(CameraCfg): class_type: type["TiledCamera"] | str = "{DIR}.tiled_camera:TiledCamera" def __post_init__(self): + # TODO when Camera.__init__ moves rtx_sensor setting out of camera initialization + # the default renderer config instantiation can be moved into the render factory + # and get_default_render_cfg method can be removed from backend_utils + renderer_type = getattr(self.renderer_cfg, "renderer_type", None) + if renderer_type == "default": + from isaaclab.utils.backend_utils import get_default_renderer_cfg + + self.renderer_cfg = get_default_renderer_cfg() warnings.warn( "TiledCameraCfg is deprecated. Use CameraCfg directly — " "Camera now includes TiledCamera's vectorized rendering optimizations.", diff --git a/source/isaaclab/isaaclab/utils/backend_utils.py b/source/isaaclab/isaaclab/utils/backend_utils.py index 9f69a66aa04d..ddc627171ae1 100644 --- a/source/isaaclab/isaaclab/utils/backend_utils.py +++ b/source/isaaclab/isaaclab/utils/backend_utils.py @@ -3,12 +3,48 @@ # # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + import importlib import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from isaaclab.renderers.renderer_cfg import RendererCfg logger = logging.getLogger(__name__) +def get_default_renderer_cfg() -> RendererCfg: + """Return the default :class:`~isaaclab.renderers.renderer_cfg.RendererCfg` for cameras. + + Lazily imports :mod:`isaaclab_physx.renderers` and returns a new + :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg` instance. + + Returns: + A new default Isaac RTX renderer configuration. + + Raises: + ImportError: If :mod:`isaaclab_physx.renderers` cannot be imported or does not + expose ``IsaacRtxRendererCfg``. + """ + try: + renderers_mod = importlib.import_module("isaaclab_physx.renderers") + except ImportError as e: + raise ImportError( + "The default camera renderer configuration requires the optional 'isaaclab_physx' " + "package (import 'isaaclab_physx.renderers'). Install isaaclab_physx or set " + "CameraCfg.renderer_cfg explicitly." + ) from e + try: + default_cls = renderers_mod.IsaacRtxRendererCfg + except AttributeError as e: + raise ImportError( + "Module 'isaaclab_physx.renderers' is available but does not define 'IsaacRtxRendererCfg'." + ) from e + return default_cls() + + class FactoryBase: """A generic factory class that dynamically loads backends.""" From b582dab8734eb25dce9fe1b1b044cdf7aeb65a5d Mon Sep 17 00:00:00 2001 From: vidurv-nvidia Date: Thu, 7 May 2026 12:32:35 -0700 Subject: [PATCH 008/133] Refactors schema cfgs to separate solver-common from PhysX-specific fields (#5275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Splits IsaacLab's USD-physics cfg classes into solver-common base classes and backend-specific subclasses, and refactors the writers (`modify_*_properties`, `spawn_rigid_body_material`) so that schema application is data-driven rather than hard-coded per-class. Prepares the schema layer for multi-backend support (PhysX today, Newton/Mjc next) without polluting base classes with silently-ignored fields or stamping backend-specific schemas onto prims that didn't opt in. ## Architecture Two layered concepts: 1. **Per-declaring-class routing.** Each cfg field's USD namespace is determined by the class that declares it (walking the MRO). Base-class fields write under `physics:*`; subclass fields write under their own namespace (`physxRigidBody:*`, etc.). When a `PhysxRigidBodyPropertiesCfg` instance is written, base fields still go under `physics:*` because `_usd_namespace` is read from the declaring class via `__dict__`, not via `getattr` (which would hit the subclass override). 2. **Per-field exceptions.** Some "universal physics" fields have no USD path except through a backend-namespaced attribute today (e.g., `disable_gravity` only exists at `physxRigidBody:disableGravity`). These are declared as `_usd_field_exceptions = {applied_schema: (namespace, [fields...])}` on the base class; the writer applies the exception schema only when one of the listed fields is non-None. The single helper `_apply_namespaced_schemas(prim, cfg, cfg_dict)` in `schemas.py` does both passes for every writer (rigid body, collision, articulation root, joint drive, mesh collision, rigid-body material). ## Design constraints **One cfg class per spawner slot.** Spawners (`UsdFileCfg`, `MeshCuboidCfg`, etc.) carry a single field for each property group: `rigid_props: RigidBodyBaseCfg | None`, `collision_props: CollisionBaseCfg | None`, `joint_drive_props: JointDriveBaseCfg | None`, etc. The user cannot pass two cfgs into the same slot, so the cfg class hierarchy must be **single-rooted per spawner field** — one base class per group, with backend-specific subclasses below. This rules out a "PhysX cfg sits next to a Newton cfg as siblings" design and drives several placement decisions: | Constraint | Consequence | |---|---| | Universal-physics fields must be reachable from any backend's cfg | Goes on the **base** class, not a sibling backend cfg. Users on Newton-only deployments can use `RigidBodyBaseCfg(disable_gravity=True)` without importing `isaaclab_physx`. | | A PhysX-namespaced field whose semantics are universal (e.g., `disable_gravity`) | Lives on the base but routes to the PhysX namespace via `_usd_field_exceptions`. The base stays backend-clean; the writer dispatches the PhysX write only when the field is non-None. | | Writer logic must not branch on cfg subclass | Every writer is the same code path regardless of subclass. The cfg metadata (`_usd_namespace`, `_usd_applied_schema`, `_usd_field_exceptions`) drives behavior; the writer is a pure data interpreter. | | Adding a new backend (Newton, Mjc) | Requires a new subclass with its own `_usd_namespace` / `_usd_applied_schema`. No spawner-side changes, no writer-side changes, no base-cfg-side changes. | | A field has multiple USD paths today (one PhysX-namespaced, one Newton-namespaced) | Belongs on the **PhysX subclass**, not the base. A future `NewtonArticulationRootPropertiesCfg` will own the same conceptual field on the Newton side. ("Rule 2" — e.g., `enabled_self_collisions`.) | | A field has only one USD path today, namespaced under PhysX, but the conceptual quantity is universal | Belongs on the **base** with an `_usd_field_exceptions` entry. ("Rule 1" — e.g., `disable_gravity`, `articulation_enabled`, `contact_offset`, `rest_offset`, `max_joint_velocity`.) When Newton ships its own native attribute, the exception namespace switches transparently with no API change. | ## Field placement ### Base (solver-common) classes — `physics:*` namespace via `UsdPhysics.*API` | Cfg class | Field | USD attribute | |---|---|---| | `RigidBodyBaseCfg` | `rigid_body_enabled` | `physics:rigidBodyEnabled` | | `RigidBodyBaseCfg` | `kinematic_enabled` | `physics:kinematicEnabled` | | `CollisionBaseCfg` | `collision_enabled` | `physics:collisionEnabled` | | `MassPropertiesCfg` | `mass` | `physics:mass` | | `MassPropertiesCfg` | `density` | `physics:density` | | `RigidBodyMaterialBaseCfg` | `static_friction` | `physics:staticFriction` | | `RigidBodyMaterialBaseCfg` | `dynamic_friction` | `physics:dynamicFriction` | | `RigidBodyMaterialBaseCfg` | `restitution` | `physics:restitution` | | `JointDriveBaseCfg` | `drive_type` | `drive::physics:type` | | `JointDriveBaseCfg` | `max_force` | `drive::physics:maxForce` | | `JointDriveBaseCfg` | `stiffness` | `drive::physics:stiffness` | | `JointDriveBaseCfg` | `damping` | `drive::physics:damping` | | `MeshCollisionBaseCfg` | `mesh_approximation_name` | `physics:approximation` (token) | | `ArticulationRootBaseCfg` | `fix_root_link` | (synthesizes `UsdPhysics.FixedJoint`) | `JointDriveBaseCfg` and `MeshCollisionBaseCfg` use the typed `UsdPhysics.DriveAPI` / `UsdPhysics.MeshCollisionAPI` accessors at the writer level (multi-instance namespace and `TfToken` with `allowedTokens`, respectively); all other base fields flow through the helper's per-class routing. ### PhysX subclasses — `physx*:*` namespaces, `Physx*API` schemas | Cfg class | `_usd_namespace` | `_usd_applied_schema` | Adds fields | |---|---|---|---| | `PhysxRigidBodyPropertiesCfg` | `physxRigidBody` | `PhysxRigidBodyAPI` | `linear_damping`, `angular_damping`, `max_linear_velocity`, `max_angular_velocity`, `max_depenetration_velocity`, `max_contact_impulse`, `enable_gyroscopic_forces`, `retain_accelerations`, solver iter counts, sleep / stabilization thresholds | | `PhysxCollisionPropertiesCfg` | `physxCollision` | `PhysxCollisionAPI` | `torsional_patch_radius`, `min_torsional_patch_radius` | | `PhysxArticulationRootPropertiesCfg` | `physxArticulation` | `PhysxArticulationAPI` | `enabled_self_collisions`, solver iter counts, sleep / stabilization thresholds | | `PhysxJointDrivePropertiesCfg` | `physxJoint` | `PhysxJointAPI` | (currently empty; reserved for future PhysX-only knobs) | | `PhysxRigidBodyMaterialCfg` | `physxMaterial` | `PhysxMaterialAPI` | `compliant_contact_stiffness`, `compliant_contact_damping`, `friction_combine_mode`, `restitution_combine_mode` | | `PhysxConvexHullPropertiesCfg` | `physxConvexHullCollision` | `PhysxConvexHullCollisionAPI` | `hull_vertex_limit`, `min_thickness` | | `PhysxConvexDecompositionPropertiesCfg` | `physxConvexDecompositionCollision` | `PhysxConvexDecompositionCollisionAPI` | hull / voxel / shrink-wrap tunables | | `PhysxTriangleMeshPropertiesCfg` | `physxTriangleMeshCollision` | `PhysxTriangleMeshCollisionAPI` | `weld_tolerance` | | `PhysxTriangleMeshSimplificationPropertiesCfg` | `physxTriangleMeshSimplificationCollision` | `PhysxTriangleMeshSimplificationCollisionAPI` | `simplification_metric`, `weld_tolerance` | | `PhysxSDFMeshPropertiesCfg` | `physxSDFMeshCollision` | `PhysxSDFMeshCollisionAPI` | `sdf_margin`, `sdf_narrow_band_thickness`, `sdf_resolution`, etc. | ### `_usd_field_exceptions` table These fields are declared on a *base* class but the only USD path today goes through a non-base namespace. Each entry says: "if any listed field on this cfg is non-None, apply the exception schema and write that one attribute under the exception namespace." All other fields on the cfg follow the per-declaring-class routing rule. | Base cfg class | Exception schema | Namespace | Field(s) | Why on the base | |---|---|---|---|---| | `RigidBodyBaseCfg` | `PhysxRigidBodyAPI` | `physxRigidBody` | `disable_gravity` | Per-body gravity exclusion is universal physics; PhysX honors per-body, Newton consumes the same attribute via the bridge resolver (scene-level today; per-body fix is a Newton-side kernel change, not a cfg-API change) | | `CollisionBaseCfg` | `PhysxCollisionAPI` | `physxCollision` | `contact_offset`, `rest_offset` | Collision-pair generation distance and rest gap are universal physics; Newton importer consumes both via PhysX bridge to populate `Model.shape_collision_radius` / `_thickness` (`import_usd.py:2104, 2111`) | | `ArticulationRootBaseCfg` | `PhysxArticulationAPI` | `physxArticulation` | `articulation_enabled` | PhysX honors at sim time; IsaacLab Newton wrapper reads it as a spawn-time guard at `rigid_object.py:1035`. Universal user-facing intent | | `JointDriveBaseCfg` | `PhysxJointAPI` | `physxJoint` | `max_joint_velocity` | Sole USD path to `Model.joint_velocity_limit` in Newton (no `newton:*` equivalent today). The exception namespace switches transparently when Newton ships `newton:maxJointVelocity` as a registered applied API | When any exception field is non-None, the corresponding `Physx*API` schema is applied to the prim. When all exception fields are None, no PhysX schema is stamped — Newton-targeted prims authored from `*BaseCfg` stay free of PhysX schemas they didn't opt in to. ## Field renames (with deprecation aliases) To enforce the convention that python `snake_case` cfg field names map identity-style to USD `camelCase` attribute names, two legacy fields were renamed. Both keep the old name as a deprecation alias forwarded via `__post_init__` (emits `DeprecationWarning`, scheduled for removal in 5.0). | Old name | New name | USD attribute | |---|---|---| | `JointDriveBaseCfg.max_velocity` | `max_joint_velocity` | `physxJoint:maxJointVelocity` | | `JointDriveBaseCfg.max_effort` | `max_force` | `drive::physics:maxForce` | ## Type of change - New feature (non-breaking change which adds functionality) - Breaking change (existing functionality will not work without user modification) The split is non-breaking at the spawner-cfg level — every base-class type accepts any subclass via polymorphism, and every legacy `RigidBodyPropertiesCfg` / `JointDrivePropertiesCfg` / `CollisionPropertiesCfg` / `ArticulationRootPropertiesCfg` / `MeshCollisionPropertiesCfg` / `RigidBodyMaterialCfg` / `FixedTendonPropertiesCfg` / `SpatialTendonPropertiesCfg` import path continues to work via deprecation-alias subclasses and `__getattr__` shims on `isaaclab.sim`, `isaaclab.sim.schemas`, and `isaaclab.sim.schemas.schemas_cfg`. Direct attribute access to the renamed fields still works through deprecation aliases. Removal scheduled for 5.0. The breaking aspect: cfg classes in `isaaclab_physx.sim.schemas` and `isaaclab_physx.sim.spawners.materials` are physically relocated. Anyone importing from internal paths (rather than `isaaclab.sim`) needs to update. ## Migration ```python # Before import isaaclab.sim as sim_utils rigid_props = sim_utils.RigidBodyPropertiesCfg(disable_gravity=True, linear_damping=0.1) joint_props = sim_utils.JointDrivePropertiesCfg(max_effort=80.0, max_velocity=5.0) collision_props = sim_utils.CollisionPropertiesCfg(contact_offset=0.02, torsional_patch_radius=1.0) material = sim_utils.RigidBodyMaterialCfg(static_friction=0.7, compliant_contact_stiffness=1000.0) # After (PhysX-targeted) import isaaclab.sim as sim_utils from isaaclab_physx.sim.schemas import ( PhysxRigidBodyPropertiesCfg, PhysxJointDrivePropertiesCfg, PhysxCollisionPropertiesCfg, ) from isaaclab_physx.sim.spawners.materials import PhysxRigidBodyMaterialCfg rigid_props = PhysxRigidBodyPropertiesCfg(disable_gravity=True, linear_damping=0.1) joint_props = PhysxJointDrivePropertiesCfg(max_force=80.0, max_joint_velocity=5.0) collision_props = PhysxCollisionPropertiesCfg(contact_offset=0.02, torsional_patch_radius=1.0) material = PhysxRigidBodyMaterialCfg(static_friction=0.7, compliant_contact_stiffness=1000.0) # After (Newton-targeted — base classes only, no PhysX schemas applied) from isaaclab.sim.schemas import RigidBodyBaseCfg, JointDriveBaseCfg, CollisionBaseCfg from isaaclab.sim.spawners.materials import RigidBodyMaterialBaseCfg rigid_props = RigidBodyBaseCfg(disable_gravity=True) # only base + exception fields available joint_props = JointDriveBaseCfg(max_force=80.0, max_joint_velocity=5.0) material = RigidBodyMaterialBaseCfg(static_friction=0.7) ``` Spawner type annotations remain unchanged — they accept any subclass via polymorphism. ## Internal helper ```python def _apply_namespaced_schemas(prim, cfg, cfg_dict): # 1. Per-field exceptions: pop listed fields, apply exception schema if any non-None, # write under exception namespace. # 2. Per-declaring-class routing: walk MRO to find each remaining field's owner class; # write under that class's _usd_namespace; apply that class's _usd_applied_schema. ``` Used by all five `modify_*_properties` writers and `spawn_rigid_body_material`. Replaced ~125 lines of duplicated gating logic with a single ~30-line helper. ## Side change: configclass `source/isaaclab/isaaclab/utils/configclass.py:_process_mutable_types` now detects string-form `ClassVar` annotations under PEP 563 (`from __future__ import annotations`) so it doesn't wrap `ClassVar[dict]` defaults in `field(default_factory=...)`. Matches Python stdlib `dataclasses` semantics. No pre-existing IsaacLab class used `ClassVar` inside a `@configclass` block, so the change has no effect on existing code; it enables the `ClassVar` metadata pattern this PR introduces. ## Test plan - [x] `test_schemas.py` (38 → 40 tests): all schema-cfg classes write correct attributes under the right namespace; PhysX schemas are NOT applied when only base/UsdPhysics fields are set; deprecation aliases (`max_velocity` → `max_joint_velocity`, `max_effort` → `max_force`) forward correctly and emit `DeprecationWarning`. **40 passed.** - [x] `test_schemas_shim.py`: legacy import paths (`isaaclab.sim.schemas.RigidBodyPropertiesCfg` etc.) resolve via `__getattr__` shims. **All passing.** - [x] `test_articulation.py`, `test_rigid_object_iface.py`, `test_valid_configs.py`, `test_spawn_*` — no regressions. - [x] Full suite (`./isaaclab.sh -t`): 8768/9205 pass, 437 unrelated baseline failures (rendering, `omni.physics.tensors.api` missing, OSC controller, `install_ci`, `pyglet`, Newton env-path, Anymal-C determinism). Zero new regressions; +123 passing tests vs. earlier state. - [x] `./isaaclab.sh -f` (pre-commit) clean. ## Supersedes Together with #5276, supersedes #4847 and #5203 with a cleaner schema-layer design. ## 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 (changelog fragments under `source/isaaclab/changelog.d/`) - [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 (fragment-based system) 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: ooctipus --- .../vidur-cfg-exception-table.minor.rst | 27 + .../vidur-rebalance-cfg-placement.minor.rst | 122 +++ source/isaaclab/isaaclab/sim/__init__.py | 69 +- source/isaaclab/isaaclab/sim/__init__.pyi | 12 +- .../sim/converters/mesh_converter_cfg.py | 4 +- .../isaaclab/isaaclab/sim/schemas/__init__.py | 51 +- .../isaaclab/sim/schemas/__init__.pyi | 34 +- .../isaaclab/isaaclab/sim/schemas/schemas.py | 490 +++++++----- .../isaaclab/sim/schemas/schemas_cfg.py | 656 ++++++++------- .../sim/spawners/from_files/from_files_cfg.py | 2 +- .../sim/spawners/materials/__init__.py | 28 +- .../sim/spawners/materials/__init__.pyi | 4 +- .../spawners/materials/physics_materials.py | 52 +- .../materials/physics_materials_cfg.py | 75 +- .../isaaclab/sim/spawners/spawner_cfg.py | 4 +- source/isaaclab/isaaclab/utils/configclass.py | 10 +- source/isaaclab/test/sim/test_schemas.py | 646 ++++++++++++++- source/isaaclab/test/sim/test_schemas_shim.py | 172 ++++ .../vidur-feature-usd-proprties-refactor.skip | 0 .../test/assets/test_articulation.py | 10 +- .../vidur-rebalance-cfg-placement.minor.rst | 77 ++ .../isaaclab_physx/sim/__init__.pyi | 10 +- .../isaaclab_physx/sim/schemas/__init__.pyi | 54 +- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 755 +++++++++++++++++- .../sim/spawners/materials/__init__.pyi | 4 + .../materials/physics_materials_cfg.py | 83 ++ .../test/assets/test_articulation.py | 10 +- 27 files changed, 2762 insertions(+), 699 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidur-cfg-exception-table.minor.rst create mode 100644 source/isaaclab/changelog.d/vidur-rebalance-cfg-placement.minor.rst create mode 100644 source/isaaclab/test/sim/test_schemas_shim.py create mode 100644 source/isaaclab_newton/changelog.d/vidur-feature-usd-proprties-refactor.skip create mode 100644 source/isaaclab_physx/changelog.d/vidur-rebalance-cfg-placement.minor.rst diff --git a/source/isaaclab/changelog.d/vidur-cfg-exception-table.minor.rst b/source/isaaclab/changelog.d/vidur-cfg-exception-table.minor.rst new file mode 100644 index 000000000000..de2d19065b61 --- /dev/null +++ b/source/isaaclab/changelog.d/vidur-cfg-exception-table.minor.rst @@ -0,0 +1,27 @@ +Changed +^^^^^^^ + +* Cleaned up the schema-cfg base classes to no longer carry PhysX namespace metadata. + :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`, + :class:`~isaaclab.sim.schemas.CollisionBaseCfg`, + :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg`, and + :class:`~isaaclab.sim.schemas.JointDriveBaseCfg` now declare ``_usd_namespace = None`` and + ``_usd_applied_schema = None``. Per-field PhysX overrides for fields whose only USD path + today is the ``physx*:*`` namespace (``disable_gravity``, ``contact_offset``, + ``rest_offset``, ``articulation_enabled``, ``max_velocity``) are declared via a new + ``_usd_field_exceptions`` mapping ``applied_schema -> (namespace, {cfg_field: usd_attr})``. + When any listed field is non-None at write time, the writer applies that schema and writes + the attribute under the exception namespace; otherwise the schema is not stamped onto the + prim. PhysX subclasses (:class:`PhysxRigidBodyPropertiesCfg`, + :class:`PhysxCollisionPropertiesCfg`, :class:`PhysxArticulationRootPropertiesCfg`, + :class:`PhysxJointDrivePropertiesCfg`) now self-declare ``_usd_namespace`` and + ``_usd_applied_schema`` for their own fields. Observable behavior on standard inputs is + unchanged. +* Consolidated the per-writer schema-application loop in + :mod:`isaaclab.sim.schemas` into a single shared helper ``_apply_namespaced_schemas``. + ``modify_articulation_root_properties``, ``modify_rigid_body_properties``, + ``modify_collision_properties``, ``modify_joint_drive_properties``, + ``modify_mesh_collision_properties``, and ``spawn_rigid_body_material`` all delegate to the + helper after writing their typed-API ``UsdPhysics`` fields. The canonical exception-table + + main-namespace gating logic now lives in one place instead of being duplicated across + six call sites. diff --git a/source/isaaclab/changelog.d/vidur-rebalance-cfg-placement.minor.rst b/source/isaaclab/changelog.d/vidur-rebalance-cfg-placement.minor.rst new file mode 100644 index 000000000000..72be33772d9d --- /dev/null +++ b/source/isaaclab/changelog.d/vidur-rebalance-cfg-placement.minor.rst @@ -0,0 +1,122 @@ +Added +^^^^^ + +* Added :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`, the solver-common + base class for rigid-body physics materials. Carries the ``UsdPhysics.MaterialAPI`` standard + fields (``static_friction``, ``dynamic_friction``, ``restitution``). The PhysX-specific + compliant-contact and combine-mode fields moved to + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. +* Added :class:`~isaaclab.sim.schemas.CollisionBaseCfg`, the solver-common base class for + collision properties. Carries :attr:`collision_enabled` (``UsdPhysics.CollisionAPI``) plus + :attr:`contact_offset` and :attr:`rest_offset` whose USD attributes are PhysX-namespaced + but are consumed by Newton's importer via the PhysX bridge resolver + (``import_usd.py:2104, 2111``). +* Added :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg`, the solver-common base class + for articulation root properties (``fix_root_link``, ``articulation_enabled``). +* Added :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg`, the solver-common base class for + mesh collision properties carrying ``mesh_approximation_name`` (writes + ``physics:approximation`` via :class:`UsdPhysics.MeshCollisionAPI`). The class-level + ``_usd_applied_schema`` metadata replaces the deprecated ``usd_api`` / ``physx_api`` + instance-field dispatch. + +Changed +^^^^^^^ + +* Moved the ``max_velocity`` field from :class:`~isaaclab_physx.sim.schemas.PhysxJointDrivePropertiesCfg` + to :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`. The field is the only USD path to set + Newton's ``Model.joint_velocity_limit`` and is consumed by Newton's importer. The USD + attribute written is unchanged (``physxJoint:maxJointVelocity``); existing code using + ``PhysxJointDrivePropertiesCfg(max_velocity=...)`` continues to work because the field + is inherited. +* Moved the ``disable_gravity`` field from :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyPropertiesCfg` + to :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`. PhysX honors per-body via + ``physxRigidBody:disableGravity``; Newton currently honors at scene level (partial), + documented in the field docstring. Existing code using + ``PhysxRigidBodyPropertiesCfg(disable_gravity=...)`` continues to work via inheritance. +* Documented :attr:`~isaaclab.sim.schemas.ArticulationRootPropertiesCfg.articulation_enabled` + and :attr:`~isaaclab.sim.schemas.ArticulationRootPropertiesCfg.enabled_self_collisions` + to lock their placement for the future :class:`ArticulationRootBaseCfg` / + ``PhysxArticulationRootPropertiesCfg`` split: ``articulation_enabled`` stays on the base + (single-namespace USD with verified Newton consumer); ``enabled_self_collisions`` moves + to the PhysX subclass (dual-namespace USD, with a future Newton sibling cfg owning the + ``newton:*`` namespace). +* Changed the defaults of :attr:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg.compliant_contact_stiffness`, + :attr:`compliant_contact_damping`, :attr:`friction_combine_mode`, and + :attr:`restitution_combine_mode` from concrete values (``0.0``, ``0.0``, ``"average"``, + ``"average"``) to ``None``. PhysX engine defaults match the previous concrete values, so + user-observable simulation behavior is unchanged; the difference is that these attributes + are now authored on the prim only when the user explicitly sets them (consistent with the + rest of the consumption-gated cfg layer). +* Relocated :class:`RigidBodyMaterialCfg` to :mod:`isaaclab_physx.sim.spawners.materials` and + split its fields between the new :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` + (UsdPhysics-standard friction/restitution) and + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` + (PhysX-specific compliant-contact and combine-mode fields). A forwarding shim on + :mod:`isaaclab.sim.spawners.materials` and :mod:`isaaclab.sim` preserves existing imports. +* Refactored :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material` to be + metadata-driven: it reads ``_usd_applied_schema``, ``_usd_namespace``, and + ``_usd_attr_name_map`` from the cfg class and gates ``PhysxMaterialAPI`` application on + whether the user authored at least one PhysX-namespaced field with a non-``None`` value. + Previously, the writer applied ``PhysxMaterialAPI`` unconditionally on every material spawn. +* Relocated :class:`CollisionPropertiesCfg` to :mod:`isaaclab_physx.sim.schemas` and split + its fields between the new :class:`~isaaclab.sim.schemas.CollisionBaseCfg` (solver-common + ``collision_enabled`` plus the PhysX-namespaced but Newton-consumed + ``contact_offset`` / ``rest_offset``) and + :class:`~isaaclab_physx.sim.schemas.PhysxCollisionPropertiesCfg` (PhysX-only + ``torsional_patch_radius`` / ``min_torsional_patch_radius``). A forwarding shim on + :mod:`isaaclab.sim.schemas`, :mod:`isaaclab.sim.schemas.schemas_cfg`, and + :mod:`isaaclab.sim` preserves existing imports. +* Refactored :func:`~isaaclab.sim.schemas.modify_collision_properties` to be metadata-driven + and to gate ``PhysxCollisionAPI`` application on whether the user authored at least one + PhysX-namespaced field with a non-``None`` value. Previously, the writer applied + ``PhysxCollisionAPI`` unconditionally on every collision prim, stamping the schema onto + Newton-targeted prims that only set ``collision_enabled``. +* Relocated :class:`ArticulationRootPropertiesCfg` to :mod:`isaaclab_physx.sim.schemas` and + split its fields between the new :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` + (solver-common ``fix_root_link`` plus the PhysX-namespaced ``articulation_enabled`` which + is consumed by the IL Newton wrapper as a spawn-time guard) and + :class:`~isaaclab_physx.sim.schemas.PhysxArticulationRootPropertiesCfg` + (``enabled_self_collisions`` and PhysX TGS solver iter / sleep / stabilization thresholds). + A forwarding shim on :mod:`isaaclab.sim.schemas`, + :mod:`isaaclab.sim.schemas.schemas_cfg`, and :mod:`isaaclab.sim` preserves existing imports. +* Refactored :func:`~isaaclab.sim.schemas.modify_articulation_root_properties` to be + metadata-driven and to gate ``PhysxArticulationAPI`` application on whether the user + authored at least one PhysX-namespaced field with a non-``None`` value. Previously, the + writer applied ``PhysxArticulationAPI`` unconditionally on every articulation root, + stamping the schema onto Newton-targeted prims that only set ``fix_root_link``. +* Relocated :class:`MeshCollisionPropertiesCfg`, :class:`ConvexHullPropertiesCfg`, + :class:`ConvexDecompositionPropertiesCfg`, :class:`TriangleMeshPropertiesCfg`, + :class:`TriangleMeshSimplificationPropertiesCfg`, and :class:`SDFMeshPropertiesCfg` to + :mod:`isaaclab_physx.sim.schemas`. :class:`BoundingCubePropertiesCfg` and + :class:`BoundingSpherePropertiesCfg` stay in core because they author no PhysX schema. + A forwarding shim preserves existing imports. +* Refactored :func:`~isaaclab.sim.schemas.modify_mesh_collision_properties` to be + metadata-driven. The writer now reads ``_usd_applied_schema`` and ``_usd_namespace`` from + the cfg class instead of consulting instance-level ``usd_api`` / ``physx_api`` fields. + The standard :class:`UsdPhysics.MeshCollisionAPI` is always applied; PhysX cooking + schemas (``PhysxConvexHullCollisionAPI`` etc.) are gated on at least one + PhysX-namespaced tuning field being set. +* Relocated :class:`FixedTendonPropertiesCfg` and :class:`SpatialTendonPropertiesCfg` to + :mod:`isaaclab_physx.sim.schemas` as :class:`PhysxFixedTendonPropertiesCfg` and + :class:`PhysxSpatialTendonPropertiesCfg`. Tendons are a PhysX-only feature; no Newton + equivalent exists. A forwarding shim on :mod:`isaaclab.sim.schemas`, + :mod:`isaaclab.sim.schemas.schemas_cfg`, and :mod:`isaaclab.sim` preserves existing + imports. + +Deprecated +^^^^^^^^^^ + +* Deprecated the ``usd_api`` and ``physx_api`` instance attributes on the mesh-collision + cfg classes in favor of class-level ``_usd_applied_schema`` metadata. Reading these + attributes still works through one minor version but emits a ``DeprecationWarning``. + Scheduled for removal in 5.0. + +Fixed +^^^^^ + +* Fixed :meth:`~isaaclab.sim.schemas.modify_joint_drive_properties` and + :meth:`~isaaclab.sim.schemas.modify_rigid_body_properties` so that ``PhysxJointAPI`` and + ``PhysxRigidBodyAPI`` are applied only when the user authored at least one PhysX-namespaced + field with a non-``None`` value. Previously, schema application was gated on class-level + metadata being defined, which caused Newton-targeted prims to receive PhysX schemas even + when the user only set base ``UsdPhysics``-standard fields. diff --git a/source/isaaclab/isaaclab/sim/__init__.py b/source/isaaclab/isaaclab/sim/__init__.py index 3c75a3548956..9c140a2507cf 100644 --- a/source/isaaclab/isaaclab/sim/__init__.py +++ b/source/isaaclab/isaaclab/sim/__init__.py @@ -28,4 +28,71 @@ from isaaclab.utils.module import lazy_export -lazy_export() +_stub_getattr, _stub_dir, __all__ = lazy_export() + +# Names that moved out of this package into ``isaaclab_physx.sim.schemas``. +# Resolved lazily on first access so importing ``isaaclab.sim`` does not +# require ``isaaclab_physx`` to be installed. +_PHYSX_FORWARDS_SCHEMAS = frozenset({ + "RigidBodyPropertiesCfg", + "JointDrivePropertiesCfg", + "PhysxRigidBodyPropertiesCfg", + "PhysxJointDrivePropertiesCfg", + "CollisionPropertiesCfg", + "PhysxCollisionPropertiesCfg", + "PhysXCollisionPropertiesCfg", + "PhysxDeformableCollisionPropertiesCfg", + "ArticulationRootPropertiesCfg", + "PhysxArticulationRootPropertiesCfg", + "MeshCollisionPropertiesCfg", + "ConvexHullPropertiesCfg", + "ConvexDecompositionPropertiesCfg", + "TriangleMeshPropertiesCfg", + "TriangleMeshSimplificationPropertiesCfg", + "SDFMeshPropertiesCfg", + "PhysxConvexHullPropertiesCfg", + "PhysxConvexDecompositionPropertiesCfg", + "PhysxTriangleMeshPropertiesCfg", + "PhysxTriangleMeshSimplificationPropertiesCfg", + "PhysxSDFMeshPropertiesCfg", + "FixedTendonPropertiesCfg", + "SpatialTendonPropertiesCfg", + "PhysxFixedTendonPropertiesCfg", + "PhysxSpatialTendonPropertiesCfg", +}) + +# Names that moved out of this package into ``isaaclab_physx.sim.spawners.materials``. +_PHYSX_FORWARDS_MATERIALS = frozenset({ + "RigidBodyMaterialCfg", + "PhysxRigidBodyMaterialCfg", +}) + +_PHYSX_FORWARDS = _PHYSX_FORWARDS_SCHEMAS | _PHYSX_FORWARDS_MATERIALS + + +def __getattr__(name): + if name in _PHYSX_FORWARDS_SCHEMAS: + try: + from isaaclab_physx.sim.schemas import schemas_cfg as _physx_cfg + except ImportError as e: + raise ImportError( + f"'isaaclab.sim.{name}' has moved to 'isaaclab_physx.sim.schemas'." + " Install the isaaclab_physx extension or update your import. This forwarding" + " shim is scheduled for removal in 5.0." + ) from e + return getattr(_physx_cfg, name) + if name in _PHYSX_FORWARDS_MATERIALS: + try: + from isaaclab_physx.sim.spawners.materials import physics_materials_cfg as _physx_mat_cfg + except ImportError as e: + raise ImportError( + f"'isaaclab.sim.{name}' has moved to 'isaaclab_physx.sim.spawners.materials'." + " Install the isaaclab_physx extension or update your import. This forwarding" + " shim is scheduled for removal in 5.0." + ) from e + return getattr(_physx_mat_cfg, name) + return _stub_getattr(name) + + +def __dir__(): + return sorted(set(_stub_dir()) | _PHYSX_FORWARDS) diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index a718ccdcb989..e1d9f535a207 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -36,14 +36,14 @@ __all__ = [ "ArticulationRootPropertiesCfg", "BoundingCubePropertiesCfg", "BoundingSpherePropertiesCfg", - "CollisionPropertiesCfg", + "CollisionBaseCfg", "ConvexDecompositionPropertiesCfg", "ConvexHullPropertiesCfg", "FixedTendonPropertiesCfg", - "JointDrivePropertiesCfg", + "JointDriveBaseCfg", "MassPropertiesCfg", "MeshCollisionPropertiesCfg", - "RigidBodyPropertiesCfg", + "RigidBodyBaseCfg", "SDFMeshPropertiesCfg", "SpatialTendonPropertiesCfg", "TriangleMeshPropertiesCfg", @@ -202,14 +202,14 @@ from .schemas import ( ArticulationRootPropertiesCfg, BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, - CollisionPropertiesCfg, + CollisionBaseCfg, ConvexDecompositionPropertiesCfg, ConvexHullPropertiesCfg, FixedTendonPropertiesCfg, - JointDrivePropertiesCfg, + JointDriveBaseCfg, MassPropertiesCfg, MeshCollisionPropertiesCfg, - RigidBodyPropertiesCfg, + RigidBodyBaseCfg, SDFMeshPropertiesCfg, SpatialTendonPropertiesCfg, TriangleMeshPropertiesCfg, diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py index 767f6dd04583..549aecab2eff 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py @@ -19,7 +19,7 @@ class MeshConverterCfg(AssetConverterBaseCfg): If None, then no mass properties will be added. """ - rigid_props: schemas_cfg.RigidBodyPropertiesCfg = None + rigid_props: schemas_cfg.RigidBodyBaseCfg = None """Rigid body properties to apply to the USD. Defaults to None. Note: @@ -32,7 +32,7 @@ class MeshConverterCfg(AssetConverterBaseCfg): Note: If None, then no collision properties will be added. """ - mesh_collision_props: schemas_cfg.MeshCollisionPropertiesCfg = None + mesh_collision_props: schemas_cfg.MeshCollisionBaseCfg = None """Mesh approximation properties to apply to all collision meshes in the USD. Note: If None, then no mesh approximation properties will be added. diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.py b/source/isaaclab/isaaclab/sim/schemas/__init__.py index f56ca862de59..2692196d4829 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.py +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.py @@ -34,4 +34,53 @@ from isaaclab.utils.module import lazy_export -lazy_export() +_stub_getattr, _stub_dir, __all__ = lazy_export() + +# Names that moved out of this module into ``isaaclab_physx.sim.schemas``. +# Resolved lazily on first access so importing ``isaaclab.sim.schemas`` does +# not require ``isaaclab_physx`` to be installed. +_PHYSX_FORWARDS = frozenset({ + "RigidBodyPropertiesCfg", + "JointDrivePropertiesCfg", + "PhysxRigidBodyPropertiesCfg", + "PhysxJointDrivePropertiesCfg", + "CollisionPropertiesCfg", + "PhysxCollisionPropertiesCfg", + "PhysXCollisionPropertiesCfg", + "PhysxDeformableCollisionPropertiesCfg", + "ArticulationRootPropertiesCfg", + "PhysxArticulationRootPropertiesCfg", + "MeshCollisionPropertiesCfg", + "ConvexHullPropertiesCfg", + "ConvexDecompositionPropertiesCfg", + "TriangleMeshPropertiesCfg", + "TriangleMeshSimplificationPropertiesCfg", + "SDFMeshPropertiesCfg", + "PhysxConvexHullPropertiesCfg", + "PhysxConvexDecompositionPropertiesCfg", + "PhysxTriangleMeshPropertiesCfg", + "PhysxTriangleMeshSimplificationPropertiesCfg", + "PhysxSDFMeshPropertiesCfg", + "FixedTendonPropertiesCfg", + "SpatialTendonPropertiesCfg", + "PhysxFixedTendonPropertiesCfg", + "PhysxSpatialTendonPropertiesCfg", +}) + + +def __getattr__(name): + if name in _PHYSX_FORWARDS: + try: + from isaaclab_physx.sim.schemas import schemas_cfg as _physx_cfg + except ImportError as e: + raise ImportError( + f"'isaaclab.sim.schemas.{name}' has moved to 'isaaclab_physx.sim.schemas'." + " Install the isaaclab_physx extension or update your import. This forwarding" + " shim is scheduled for removal in 5.0." + ) from e + return getattr(_physx_cfg, name) + return _stub_getattr(name) + + +def __dir__(): + return sorted(set(_stub_dir()) | _PHYSX_FORWARDS) diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index f413b3ded12d..9a90ed0d810d 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -21,21 +21,14 @@ __all__ = [ "modify_mesh_collision_properties", "modify_rigid_body_properties", "modify_spatial_tendon_properties", - "ArticulationRootPropertiesCfg", + "ArticulationRootBaseCfg", "BoundingCubePropertiesCfg", "BoundingSpherePropertiesCfg", - "CollisionPropertiesCfg", - "ConvexDecompositionPropertiesCfg", - "ConvexHullPropertiesCfg", - "FixedTendonPropertiesCfg", - "JointDrivePropertiesCfg", + "CollisionBaseCfg", + "JointDriveBaseCfg", "MassPropertiesCfg", - "MeshCollisionPropertiesCfg", - "RigidBodyPropertiesCfg", - "SDFMeshPropertiesCfg", - "SpatialTendonPropertiesCfg", - "TriangleMeshPropertiesCfg", - "TriangleMeshSimplificationPropertiesCfg", + "MeshCollisionBaseCfg", + "RigidBodyBaseCfg", ] from .schemas import ( @@ -58,19 +51,12 @@ from .schemas import ( modify_spatial_tendon_properties, ) from .schemas_cfg import ( - ArticulationRootPropertiesCfg, + ArticulationRootBaseCfg, BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, - CollisionPropertiesCfg, - ConvexDecompositionPropertiesCfg, - ConvexHullPropertiesCfg, - FixedTendonPropertiesCfg, - JointDrivePropertiesCfg, + CollisionBaseCfg, + JointDriveBaseCfg, MassPropertiesCfg, - MeshCollisionPropertiesCfg, - RigidBodyPropertiesCfg, - SDFMeshPropertiesCfg, - SpatialTendonPropertiesCfg, - TriangleMeshPropertiesCfg, - TriangleMeshSimplificationPropertiesCfg, + MeshCollisionBaseCfg, + RigidBodyBaseCfg, ) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 0f97b542e031..70f129413e5b 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -6,9 +6,9 @@ # needed to import for allowing type-hinting: Usd.Stage | None from __future__ import annotations +import dataclasses import logging import math -from typing import Any from pxr import Usd, UsdPhysics @@ -46,21 +46,167 @@ } -PHYSX_MESH_COLLISION_CFGS = [ - schemas_cfg.ConvexDecompositionPropertiesCfg, - schemas_cfg.ConvexHullPropertiesCfg, - schemas_cfg.TriangleMeshPropertiesCfg, - schemas_cfg.TriangleMeshSimplificationPropertiesCfg, - schemas_cfg.SDFMeshPropertiesCfg, -] +# Lazy accessors. These lists were used by the legacy ``usd_api`` / ``physx_api`` instance- +# field dispatch in ``modify_mesh_collision_properties``. The new metadata-driven writer +# does not consult them, but they are preserved as a public API so external code that +# imported them keeps working. The PhysX leaves now live in ``isaaclab_physx``; we resolve +# them lazily so this module does not import ``isaaclab_physx`` at load time. +def _get_physx_mesh_collision_cfgs() -> list: + from isaaclab_physx.sim.schemas import schemas_cfg as _physx_cfg + + return [ + _physx_cfg.PhysxConvexHullPropertiesCfg, + _physx_cfg.PhysxConvexDecompositionPropertiesCfg, + _physx_cfg.PhysxTriangleMeshPropertiesCfg, + _physx_cfg.PhysxTriangleMeshSimplificationPropertiesCfg, + _physx_cfg.PhysxSDFMeshPropertiesCfg, + # legacy deprecation aliases + _physx_cfg.ConvexHullPropertiesCfg, + _physx_cfg.ConvexDecompositionPropertiesCfg, + _physx_cfg.TriangleMeshPropertiesCfg, + _physx_cfg.TriangleMeshSimplificationPropertiesCfg, + _physx_cfg.SDFMeshPropertiesCfg, + ] + + +class _LazyList: + """Lazy list whose contents are produced on first access. + + Used to keep the public ``PHYSX_MESH_COLLISION_CFGS`` / ``USD_MESH_COLLISION_CFGS`` symbols + resolvable for callers that imported them, without triggering an ``isaaclab_physx`` import + at this module's load time. + """ + + def __init__(self, factory): + self._factory = factory + self._cache = None + + def _resolved(self): + if self._cache is None: + self._cache = list(self._factory()) + return self._cache + + def __iter__(self): + return iter(self._resolved()) + + def __contains__(self, item): + return item in self._resolved() + + def __len__(self): + return len(self._resolved()) + + def __getitem__(self, index): + return self._resolved()[index] + + +PHYSX_MESH_COLLISION_CFGS = _LazyList(_get_physx_mesh_collision_cfgs) + +USD_MESH_COLLISION_CFGS = _LazyList( + lambda: [ + schemas_cfg.BoundingCubePropertiesCfg, + schemas_cfg.BoundingSpherePropertiesCfg, + ] +) + + +""" +Schema-application helper. +""" + + +def _get_field_declaring_class(cfg_class: type, field_name: str) -> type | None: + """Return the most-base class in the MRO that declares ``field_name``. + + Each cfg field is owned by a single class in the hierarchy (the one whose body + contains its annotation). This function walks the MRO in reverse so a base class + declaration wins over a subclass redeclaration with the same name -- the field's + USD namespace follows where it semantically lives, not where it was last + overridden for default values. + """ + for cls in reversed(cfg_class.__mro__): + if field_name in getattr(cls, "__annotations__", {}): + return cls + return None + + +def _apply_namespaced_schemas(prim, cfg, cfg_dict: dict) -> None: + """Route every cfg field to its declaring class's namespace and apply schemas. + + The helper handles the common ``AddAppliedSchema`` + namespaced-attribute write + logic shared by every metadata-driven writer. Caller is responsible for popping + fields that need typed-API writes (multi-instance ``UsdPhysics.DriveAPI``, + ``TfToken`` attributes with ``allowedTokens``) out of ``cfg_dict`` first. + + USD attribute names are derived by snake_case -> camelCase conversion of cfg field + names. The codebase enforces this as a convention: any cfg field whose + snake_case name does not produce the correct USD camelCase attr is renamed (with a + deprecation alias forwarded in ``__post_init__``) rather than mapped via metadata. + + Two passes: + + 1. **Per-field exceptions** -- ``cfg._usd_field_exceptions`` is a mapping + ``applied_schema -> (namespace, [cfg_field, ...])``. For each schema, if any + listed field is non-None, the schema is applied (once) and each non-None field is + written under that schema's namespace. Fields are popped from ``cfg_dict``. + 2. **Per-declaring-class routing** -- each remaining non-None field is grouped by the + class that declares it (walking the MRO). Each group writes under that class's + ``_usd_namespace`` and applies that class's ``_usd_applied_schema`` (if any). This + means base-class fields go under the base namespace (e.g. ``physics:*``) even when + the cfg instance is a PhysX subclass -- the subclass's ``_usd_namespace = + "physxRigidBody"`` only governs *its own* fields. + + Args: + prim: The USD prim to author on. + cfg: The cfg instance carrying the metadata. + cfg_dict: A mutable dict view of the cfg's non-metadata fields. Modified in place. -USD_MESH_COLLISION_CFGS = [ - schemas_cfg.BoundingCubePropertiesCfg, - schemas_cfg.BoundingSpherePropertiesCfg, - schemas_cfg.ConvexDecompositionPropertiesCfg, - schemas_cfg.ConvexHullPropertiesCfg, - schemas_cfg.TriangleMeshSimplificationPropertiesCfg, -] + Raises: + ValueError: If a non-None field's declaring class does not define ``_usd_namespace``. + """ + cfg_class = type(cfg) + + # 1. Per-field exceptions (overrides per-class routing for codeless-PhysX-namespace + # fields like ``disable_gravity`` on RigidBodyBaseCfg). + field_exceptions = getattr(cfg, "_usd_field_exceptions", {}) or {} + for applied_schema, (exc_ns, fields) in field_exceptions.items(): + triggered: list[tuple[str, object]] = [] + for cfg_field in fields: + if cfg_field in cfg_dict: + value = cfg_dict.pop(cfg_field) + if value is not None: + triggered.append((to_camel_case(cfg_field, "cC"), value)) + if not triggered: + continue + if applied_schema and applied_schema not in prim.GetAppliedSchemas(): + prim.AddAppliedSchema(applied_schema) + for usd_attr, value in triggered: + safe_set_attribute_on_usd_prim(prim, f"{exc_ns}:{usd_attr}", value, camel_case=False) + + # 2. Group remaining non-None writes by declaring class. + by_class: dict[type, list[tuple[str, object]]] = {} + for cfg_field, value in list(cfg_dict.items()): + if value is None: + continue + decl_class = _get_field_declaring_class(cfg_class, cfg_field) + if decl_class is None: + continue + by_class.setdefault(decl_class, []).append((to_camel_case(cfg_field, "cC"), value)) + + for decl_class, writes in by_class.items(): + # Read namespace/schema from the declaring class's own ``__dict__`` (not via + # ``getattr``) so subclass overrides don't leak into base-field routing. + namespace = decl_class.__dict__.get("_usd_namespace", None) + applied_schema = decl_class.__dict__.get("_usd_applied_schema", None) + if namespace is None: + raise ValueError( + f"{decl_class.__name__} declares fields {[a for a, _ in writes]} but does" + " not define '_usd_namespace'. Add '_usd_namespace' to the class metadata" + " or route the fields via '_usd_field_exceptions'." + ) + if applied_schema and applied_schema not in prim.GetAppliedSchemas(): + prim.AddAppliedSchema(applied_schema) + for usd_attr, value in writes: + safe_set_attribute_on_usd_prim(prim, f"{namespace}:{usd_attr}", value, camel_case=False) """ @@ -69,7 +215,7 @@ def define_articulation_root_properties( - prim_path: str, cfg: schemas_cfg.ArticulationRootPropertiesCfg, stage: Usd.Stage | None = None + prim_path: str, cfg: schemas_cfg.ArticulationRootBaseCfg, stage: Usd.Stage | None = None ): """Apply the articulation root schema on the input prim and set its properties. @@ -103,7 +249,7 @@ def define_articulation_root_properties( @apply_nested def modify_articulation_root_properties( - prim_path: str, cfg: schemas_cfg.ArticulationRootPropertiesCfg, stage: Usd.Stage | None = None + prim_path: str, cfg: schemas_cfg.ArticulationRootBaseCfg, stage: Usd.Stage | None = None ) -> bool: """Modify PhysX parameters for an articulation root prim. @@ -153,21 +299,14 @@ def modify_articulation_root_properties( # check if prim has articulation applied on it if not UsdPhysics.ArticulationRootAPI(articulation_prim): return False - # ensure PhysX articulation API is applied - applied_schemas = articulation_prim.GetAppliedSchemas() - if "PhysxArticulationAPI" not in applied_schemas: - articulation_prim.AddAppliedSchema("PhysxArticulationAPI") - # convert to dict - cfg = cfg.to_dict() - # extract non-USD properties - fix_root_link = cfg.pop("fix_root_link", None) + # convert to dict, filtering out class metadata (underscore-prefixed keys) + cfg_dict = {f.name: getattr(cfg, f.name) for f in dataclasses.fields(cfg)} + # extract writer-side (non-USD) properties + fix_root_link = cfg_dict.pop("fix_root_link", None) - # set into physx api (prim attributes under physxArticulation:*) - for attr_name, value in cfg.items(): - safe_set_attribute_on_usd_prim( - articulation_prim, f"physxArticulation:{to_camel_case(attr_name, 'cC')}", value, camel_case=False - ) + # apply per-field exceptions + main-namespace writes + _apply_namespaced_schemas(articulation_prim, cfg, cfg_dict) # fix root link based on input # we do the fixed joint processing later to not interfere with setting other properties @@ -242,9 +381,7 @@ def modify_articulation_root_properties( """ -def define_rigid_body_properties( - prim_path: str, cfg: schemas_cfg.RigidBodyPropertiesCfg, stage: Usd.Stage | None = None -): +def define_rigid_body_properties(prim_path: str, cfg: schemas_cfg.RigidBodyBaseCfg, stage: Usd.Stage | None = None): """Apply the rigid body schema on the input prim and set its properties. See :func:`modify_rigid_body_properties` for more details on how the properties are set. @@ -277,7 +414,7 @@ def define_rigid_body_properties( @apply_nested def modify_rigid_body_properties( - prim_path: str, cfg: schemas_cfg.RigidBodyPropertiesCfg, stage: Usd.Stage | None = None + prim_path: str, cfg: schemas_cfg.RigidBodyBaseCfg, stage: Usd.Stage | None = None ) -> bool: """Modify PhysX parameters for a rigid body prim. @@ -315,25 +452,14 @@ def modify_rigid_body_properties( # check if prim has rigid-body applied on it if not UsdPhysics.RigidBodyAPI(rigid_body_prim): return False - # retrieve the USD rigid-body api - usd_rigid_body_api = UsdPhysics.RigidBodyAPI(rigid_body_prim) - # ensure PhysX rigid body API is applied - applied_schemas = rigid_body_prim.GetAppliedSchemas() - if "PhysxRigidBodyAPI" not in applied_schemas: - rigid_body_prim.AddAppliedSchema("PhysxRigidBodyAPI") - - # convert to dict - cfg = cfg.to_dict() - # set into USD API - for attr_name in ["rigid_body_enabled", "kinematic_enabled"]: - value = cfg.pop(attr_name, None) - safe_set_attribute_on_usd_schema(usd_rigid_body_api, attr_name, value, camel_case=True) - # set into PhysX API (prim attributes under physxRigidBody:*) - for attr_name, value in cfg.items(): - safe_set_attribute_on_usd_prim( - rigid_body_prim, f"physxRigidBody:{to_camel_case(attr_name, 'cC')}", value, camel_case=False - ) - # success + # convert to dict, filtering out class metadata (underscore-prefixed keys) + cfg_dict = {f.name: getattr(cfg, f.name) for f in dataclasses.fields(cfg)} + + # All fields routed by the helper via per-declaring-class lookup: base + # ``rigid_body_enabled`` / ``kinematic_enabled`` go under ``physics:*``; + # ``disable_gravity`` via field exceptions; PhysX-subclass fields under + # ``physxRigidBody:*``. + _apply_namespaced_schemas(rigid_body_prim, cfg, cfg_dict) return True @@ -412,29 +538,21 @@ def modify_collision_properties( # check if prim has collision applied on it if not UsdPhysics.CollisionAPI(collider_prim): return False - # retrieve the USD collision api - usd_collision_api = UsdPhysics.CollisionAPI(collider_prim) - # ensure PhysX collision API is applied - applied_schemas = collider_prim.GetAppliedSchemas() - if "PhysxCollisionAPI" not in applied_schemas: - collider_prim.AddAppliedSchema("PhysxCollisionAPI") - + # dispatch nested mesh-collision cfg if present (preserve legacy behavior) mesh_collision_cfg = getattr(cfg, "mesh_collision_property", None) if mesh_collision_cfg is not None: modify_mesh_collision_properties(prim_path, mesh_collision_cfg, stage) - # convert to dict - cfg = cfg.to_dict() - # pop the mesh_collision_property since it is already set - cfg.pop("mesh_collision_property", None) - # set into USD API - for attr_name in ["collision_enabled"]: - value = cfg.pop(attr_name, None) - safe_set_attribute_on_usd_schema(usd_collision_api, attr_name, value, camel_case=True) - # set into PhysX API (prim attributes under physxCollision:*) - for attr_name, value in cfg.items(): - safe_set_attribute_on_usd_prim( - collider_prim, f"physxCollision:{to_camel_case(attr_name, 'cC')}", value, camel_case=False - ) + + # convert to dict, filtering out class metadata (underscore-prefixed keys) + cfg_dict = {f.name: getattr(cfg, f.name) for f in dataclasses.fields(cfg)} + # pop the mesh_collision_property since it is already dispatched above + cfg_dict.pop("mesh_collision_property", None) + + # All fields routed by the helper via per-declaring-class lookup: base + # ``collision_enabled`` goes under ``physics:*``; ``contact_offset`` / + # ``rest_offset`` via field exceptions; PhysX-subclass fields under + # ``physxCollision:*``. + _apply_namespaced_schemas(collider_prim, cfg, cfg_dict) # success return True @@ -513,15 +631,10 @@ def modify_mass_properties(prim_path: str, cfg: schemas_cfg.MassPropertiesCfg, s # check if prim has mass API applied on it if not UsdPhysics.MassAPI(rigid_prim): return False - # retrieve the USD mass api - usd_physics_mass_api = UsdPhysics.MassAPI(rigid_prim) - # convert to dict - cfg = cfg.to_dict() - # set into USD API - for attr_name in ["mass", "density"]: - value = cfg.pop(attr_name, None) - safe_set_attribute_on_usd_schema(usd_physics_mass_api, attr_name, value, camel_case=True) + # ``mass`` / ``density`` (``physics:*``) routed via the helper's per-declaring-class lookup. + cfg_dict = {f.name: getattr(cfg, f.name) for f in dataclasses.fields(cfg)} + _apply_namespaced_schemas(rigid_prim, cfg, cfg_dict) # success return True @@ -612,7 +725,7 @@ def activate_contact_sensors(prim_path: str, threshold: float = 0.0, stage: Usd. @apply_nested def modify_joint_drive_properties( - prim_path: str, cfg: schemas_cfg.JointDrivePropertiesCfg, stage: Usd.Stage | None = None + prim_path: str, cfg: schemas_cfg.JointDriveBaseCfg, stage: Usd.Stage | None = None ) -> bool: """Modify PhysX parameters for a joint prim. @@ -671,54 +784,49 @@ def modify_joint_drive_properties( usd_drive_api = UsdPhysics.DriveAPI(prim, drive_api_name) if not usd_drive_api: usd_drive_api = UsdPhysics.DriveAPI.Apply(prim, drive_api_name) - # ensure PhysX joint API is applied - if "PhysxJointAPI" not in applied_schemas_str: - prim.AddAppliedSchema("PhysxJointAPI") - - # mapping from configuration name to USD attribute name - cfg_to_usd_map = { - "max_velocity": "max_joint_velocity", - "max_effort": "max_force", - "drive_type": "type", - } - # convert to dict - cfg = cfg.to_dict() + + # ``drive_type`` is a permanent inline carve-out: the USD attribute is named ``type`` + # (a Python keyword-like name we cannot use as a cfg field). All other solver-common + # joint-drive fields follow the snake_case = camelCase convention. + # convert to dict, filtering out class metadata (underscore-prefixed keys) + cfg_dict = {f.name: getattr(cfg, f.name) for f in dataclasses.fields(cfg)} # ensure_drives_exist: if both stiffness and damping are zero on the authored drive, # set a minimal stiffness so that backends like Newton recognise the drive as active. - ensure_drives = cfg.pop("ensure_drives_exist", False) - if ensure_drives and cfg["stiffness"] is None and cfg["damping"] is None: + ensure_drives = cfg_dict.pop("ensure_drives_exist", False) + if ensure_drives and cfg_dict["stiffness"] is None and cfg_dict["damping"] is None: # read the current values from the drive cur_stiffness = usd_drive_api.GetStiffnessAttr().Get() cur_damping = usd_drive_api.GetDampingAttr().Get() if (cur_stiffness is None or cur_stiffness == 0.0) and (cur_damping is None or cur_damping == 0.0): - cfg["stiffness"] = 1e-3 + cfg_dict["stiffness"] = 1e-3 # check if linear drive is_linear_drive = prim.IsA(UsdPhysics.PrismaticJoint) # convert values for angular drives from radians to degrees units if not is_linear_drive: - if cfg["max_velocity"] is not None: - # rad / s --> deg / s - cfg["max_velocity"] = cfg["max_velocity"] * 180.0 / math.pi - if cfg["stiffness"] is not None: + if cfg_dict.get("max_joint_velocity") is not None: + # rad / s --> deg / s (PhysX angular convention is degrees) + cfg_dict["max_joint_velocity"] = cfg_dict["max_joint_velocity"] * 180.0 / math.pi + if cfg_dict["stiffness"] is not None: # N-m/rad --> N-m/deg - cfg["stiffness"] = cfg["stiffness"] * math.pi / 180.0 - if cfg["damping"] is not None: + cfg_dict["stiffness"] = cfg_dict["stiffness"] * math.pi / 180.0 + if cfg_dict["damping"] is not None: # N-m-s/rad --> N-m-s/deg - cfg["damping"] = cfg["damping"] * math.pi / 180.0 - - # set into PhysX API (prim attributes under physxJoint:*) - for attr_name in ["max_velocity"]: - value = cfg.pop(attr_name, None) - usd_attr_name = cfg_to_usd_map[attr_name] - safe_set_attribute_on_usd_prim( - prim, f"physxJoint:{to_camel_case(usd_attr_name, 'cC')}", value, camel_case=False - ) - # set into USD API - for attr_name, attr_value in cfg.items(): - attr_name = cfg_to_usd_map.get(attr_name, attr_name) - safe_set_attribute_on_usd_schema(usd_drive_api, attr_name, attr_value, camel_case=True) + cfg_dict["damping"] = cfg_dict["damping"] * math.pi / 180.0 + + # set into USD API (solver-common properties; UsdPhysics.DriveAPI fields). Pop only + # the solver-common fields here; the helper handles the PhysX-namespaced remainder. + for attr_name in ["drive_type", "max_force", "stiffness", "damping"]: + if attr_name not in cfg_dict: + continue + attr_value = cfg_dict.pop(attr_name) + usd_attr_name = "type" if attr_name == "drive_type" else attr_name + safe_set_attribute_on_usd_schema(usd_drive_api, usd_attr_name, attr_value, camel_case=True) + + # apply per-field exceptions (max_velocity -> physxJoint:maxJointVelocity) + any + # PhysX-subclass main-namespace writes + _apply_namespaced_schemas(prim, cfg, cfg_dict) return True @@ -730,7 +838,7 @@ def modify_joint_drive_properties( @apply_nested def modify_fixed_tendon_properties( - prim_path: str, cfg: schemas_cfg.FixedTendonPropertiesCfg, stage: Usd.Stage | None = None + prim_path: str, cfg: schemas_cfg.PhysxFixedTendonPropertiesCfg, stage: Usd.Stage | None = None ) -> bool: """Modify PhysX parameters for a fixed tendon attachment prim. @@ -794,7 +902,7 @@ def modify_fixed_tendon_properties( @apply_nested def modify_spatial_tendon_properties( - prim_path: str, cfg: schemas_cfg.SpatialTendonPropertiesCfg, stage: Usd.Stage | None = None + prim_path: str, cfg: schemas_cfg.PhysxSpatialTendonPropertiesCfg, stage: Usd.Stage | None = None ) -> bool: """Modify PhysX parameters for a spatial tendon attachment prim. @@ -858,75 +966,19 @@ def modify_spatial_tendon_properties( """ -def _get_physx_collision_namespace(schema_name: str) -> str: - """Convert PhysX schema name to attribute namespace used on the prim.""" - if not schema_name: - raise ValueError("PhysX schema name must be provided for mesh collision properties.") - schema_name = schema_name.removesuffix("API") - return schema_name[0].lower() + schema_name[1:] - - -def _get_usd_mesh_collision_api(api_name: str): - """Resolve the USD mesh collision API from a string name.""" - if not api_name: - raise ValueError("USD schema name must be provided for mesh collision properties.") - usd_api = getattr(UsdPhysics, api_name, None) - if usd_api is None: - raise ValueError(f"USD schema '{api_name}' not found in UsdPhysics.") - return usd_api - - -def extract_mesh_collision_api_and_attrs( - cfg: schemas_cfg.MeshCollisionPropertiesCfg, -) -> tuple[tuple[str, Any], dict[str, Any]]: - """Extract the mesh collision API type/value and custom attributes from the configuration. - - Args: - cfg: The configuration for the mesh collision properties. - - Returns: - A tuple of ((api_type, api_value), custom_attrs). api_type is "usd" or "physx"; - api_value is the USD API class (callable) or PhysX schema name string. - - Raises: - ValueError: When neither USD nor PhysX API can be determined to be used. - """ - custom_attrs = { - key: value - for key, value in cfg.to_dict().items() - if value is not None and key not in ["usd_api", "physx_api", "mesh_approximation_name"] - } - - use_usd_api = False - use_physx_api = False - - if len(custom_attrs) > 0 and type(cfg) in PHYSX_MESH_COLLISION_CFGS: - use_physx_api = True - elif len(custom_attrs) == 0: - if type(cfg) in USD_MESH_COLLISION_CFGS: - use_usd_api = True - else: - use_physx_api = True - elif len(custom_attrs) > 0 and type(cfg) in USD_MESH_COLLISION_CFGS: - raise ValueError("Args are specified but the USD Mesh API doesn't support them!") - - if use_usd_api and getattr(cfg, "usd_api", None): - return ("usd", cfg.usd_api), custom_attrs - if use_physx_api and getattr(cfg, "physx_api", None): - return ("physx", cfg.physx_api), custom_attrs - raise ValueError("Either USD or PhysX API should be used for modifying mesh collision attributes!") - - def define_mesh_collision_properties( - prim_path: str, cfg: schemas_cfg.MeshCollisionPropertiesCfg, stage: Usd.Stage | None = None + prim_path: str, cfg: schemas_cfg.MeshCollisionBaseCfg, stage: Usd.Stage | None = None ): """Apply the mesh collision schema on the input prim and set its properties. - See :func:`modify_collision_mesh_properties` for more details on how the properties are set. + + See :func:`modify_mesh_collision_properties` for more details on how the properties are set. + Args: - prim_path : The prim path where to apply the mesh collision schema. - cfg : The configuration for the mesh collision properties. - stage : The stage where to find the prim. Defaults to None, in which case the + prim_path: The prim path where to apply the mesh collision schema. + cfg: The configuration for the mesh collision properties. + stage: The stage where to find the prim. Defaults to None, in which case the current stage is used. + Raises: ValueError: When the prim path is not valid. """ @@ -939,36 +991,43 @@ def define_mesh_collision_properties( if not prim.IsValid(): raise ValueError(f"Prim path '{prim_path}' is not valid.") - (api_type, api_value), _ = extract_mesh_collision_api_and_attrs(cfg=cfg) - - if api_type == "usd": - usd_api_class = _get_usd_mesh_collision_api(api_value) - if not usd_api_class(prim): - usd_api_class.Apply(prim) - else: - if api_value not in prim.GetAppliedSchemas(): - prim.AddAppliedSchema(api_value) + # Always apply the standard ``UsdPhysics.MeshCollisionAPI`` so the approximation token is + # writable. The PhysX cooking schema (if any) is applied lazily by the writer below + # only when the user authored at least one PhysX-namespaced tuning field. + if not UsdPhysics.MeshCollisionAPI(prim): + UsdPhysics.MeshCollisionAPI.Apply(prim) modify_mesh_collision_properties(prim_path=prim_path, cfg=cfg, stage=stage) @apply_nested def modify_mesh_collision_properties( - prim_path: str, cfg: schemas_cfg.MeshCollisionPropertiesCfg, stage: Usd.Stage | None = None + prim_path: str, cfg: schemas_cfg.MeshCollisionBaseCfg, stage: Usd.Stage | None = None ) -> bool: """Set properties for the mesh collision of a prim. - These properties are based on either the `Phsyx the `UsdPhysics.MeshCollisionAPI` schema. + + Metadata-driven writer. The standard ``UsdPhysics.MeshCollisionAPI`` is applied + unconditionally (it is the carrier of the ``physics:approximation`` token). The + PhysX cooking schema declared by ``_usd_applied_schema`` (e.g. + ``PhysxConvexHullCollisionAPI``) is gated on the user authoring at least one + non-``None`` namespaced tuning field, mirroring the gating used by the other + consumption-gated writers (rigid body, joint drive, collision, articulation root). + .. note:: - This function is decorated with :func:`apply_nested` that sets the properties to all the prims - (that have the schema applied on them) under the input prim path. - .. UsdPhysics.MeshCollisionAPI: https://openusd.org/release/api/class_usd_physics_mesh_collision_a_p_i.html + This function is decorated with :func:`apply_nested` that sets the properties to + all the prims (that have the schema applied on them) under the input prim path. + + .. _UsdPhysics.MeshCollisionAPI: https://openusd.org/release/api/class_usd_physics_mesh_collision_a_p_i.html + Args: - prim_path : The prim path of the rigid body. This prim should be a Mesh prim. - cfg : The configuration for the mesh collision properties. - stage : The stage where to find the prim. Defaults to None, in which case the + prim_path: The prim path of the rigid body. This prim should be a Mesh prim. + cfg: The configuration for the mesh collision properties. + stage: The stage where to find the prim. Defaults to None, in which case the current stage is used. + Returns: True if the properties were successfully set, False otherwise. + Raises: ValueError: When the mesh approximation name is invalid. """ @@ -981,8 +1040,12 @@ def modify_mesh_collision_properties( # we need MeshCollisionAPI to set mesh collision approximation attribute if not UsdPhysics.MeshCollisionAPI(prim): UsdPhysics.MeshCollisionAPI.Apply(prim) - # convert mesh approximation string to token - approximation_name = cfg.mesh_approximation_name + + # convert to dict, filtering out class metadata (underscore-prefixed keys) + cfg_dict = {f.name: getattr(cfg, f.name) for f in dataclasses.fields(cfg)} + + # write the standard ``physics:approximation`` token via UsdPhysics.MeshCollisionAPI + approximation_name = cfg_dict.pop("mesh_approximation_name", "none") if approximation_name not in MESH_APPROXIMATION_TOKENS: raise ValueError( f"Invalid mesh approximation name: '{approximation_name}'. " @@ -993,23 +1056,14 @@ def modify_mesh_collision_properties( UsdPhysics.MeshCollisionAPI(prim), "Approximation", approximation_token, camel_case=False ) - (api_type, api_value), custom_attrs = extract_mesh_collision_api_and_attrs(cfg=cfg) - - if api_type == "usd": - usd_api_class = _get_usd_mesh_collision_api(api_value) - mesh_collision_api = usd_api_class(prim) - if not mesh_collision_api: - return False - for attr_name, value in custom_attrs.items(): - camel_case = attr_name != "Attribute" - safe_set_attribute_on_usd_schema(mesh_collision_api, attr_name, value, camel_case=camel_case) - else: - if api_value not in prim.GetAppliedSchemas(): - return False - attr_namespace = _get_physx_collision_namespace(api_value) - for attr_name, value in custom_attrs.items(): - attr_token = attr_name if attr_name == "Attribute" else to_camel_case(attr_name, "cC") - safe_set_attribute_on_usd_prim(prim, f"{attr_namespace}:{attr_token}", value, camel_case=False) + # The standard ``UsdPhysics.MeshCollisionAPI`` is already applied above. The base + # ``MeshCollisionBaseCfg`` declares ``_usd_applied_schema = "MeshCollisionAPI"`` so the + # helper would re-apply (idempotent) if any base-namespace write fired. PhysX cooking + # subclasses (ConvexHull / TriangleMesh / SDF / ...) override the schema and namespace + # to author their tuning fields under e.g. ``physxConvexHullCollision:*``; the helper + # gates ``Physx*CollisionAPI`` application on at least one non-None tuning field, so + # Newton-targeted prims stay free of PhysX cooking schemas they did not opt in to. + _apply_namespaced_schemas(prim, cfg, cfg_dict) # success return True diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index 69cbc8bd5304..eae040435429 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -5,14 +5,95 @@ from __future__ import annotations -from typing import Literal +import warnings +from typing import ClassVar, Literal from isaaclab.utils import configclass +# Names that moved out of this submodule into ``isaaclab_physx.sim.schemas.schemas_cfg``. +# Resolved lazily so callers using ``from isaaclab.sim.schemas.schemas_cfg import +# RigidBodyPropertiesCfg`` continue to work without importing ``isaaclab_physx`` at module +# load time. +_PHYSX_FORWARDS = frozenset( + { + "RigidBodyPropertiesCfg", + "JointDrivePropertiesCfg", + "PhysxRigidBodyPropertiesCfg", + "PhysxJointDrivePropertiesCfg", + "CollisionPropertiesCfg", + "PhysxCollisionPropertiesCfg", + "PhysXCollisionPropertiesCfg", + "PhysxDeformableCollisionPropertiesCfg", + "ArticulationRootPropertiesCfg", + "PhysxArticulationRootPropertiesCfg", + "MeshCollisionPropertiesCfg", + "ConvexHullPropertiesCfg", + "ConvexDecompositionPropertiesCfg", + "TriangleMeshPropertiesCfg", + "TriangleMeshSimplificationPropertiesCfg", + "SDFMeshPropertiesCfg", + "PhysxConvexHullPropertiesCfg", + "PhysxConvexDecompositionPropertiesCfg", + "PhysxTriangleMeshPropertiesCfg", + "PhysxTriangleMeshSimplificationPropertiesCfg", + "PhysxSDFMeshPropertiesCfg", + "FixedTendonPropertiesCfg", + "SpatialTendonPropertiesCfg", + "PhysxFixedTendonPropertiesCfg", + "PhysxSpatialTendonPropertiesCfg", + } +) + + +def __getattr__(name): + if name in _PHYSX_FORWARDS: + try: + from isaaclab_physx.sim.schemas import schemas_cfg as _physx_cfg + except ImportError as e: + raise ImportError( + f"'isaaclab.sim.schemas.schemas_cfg.{name}' has moved to" + " 'isaaclab_physx.sim.schemas.schemas_cfg'. Install the isaaclab_physx" + " extension or update your import. This forwarding shim is scheduled for" + " removal in 5.0." + ) from e + return getattr(_physx_cfg, name) + raise AttributeError(f"module 'isaaclab.sim.schemas.schemas_cfg' has no attribute {name!r}") + + +def _deprecate_field_alias(cfg, alias: str, canonical: str) -> None: + """Forward a deprecated cfg field to its canonical replacement. + + If ``alias`` is set on the cfg instance, emit a ``DeprecationWarning`` and copy the + value to ``canonical`` (when ``canonical`` is unset). The alias is then nulled so + downstream metadata-driven writers see only the canonical name. + """ + value = getattr(cfg, alias, None) + if value is None: + return + warnings.warn( + f"'{alias}' is deprecated; use '{canonical}' instead. The alias is scheduled for removal in 5.0.", + DeprecationWarning, + stacklevel=3, + ) + if getattr(cfg, canonical, None) is None: + setattr(cfg, canonical, value) + setattr(cfg, alias, None) + @configclass -class ArticulationRootPropertiesCfg: - """Properties to apply to the root of an articulation. +class ArticulationRootBaseCfg: + """Solver-common properties to apply to the root of an articulation. + + Carries :attr:`fix_root_link` (writer-side; materializes a + :class:`UsdPhysics.FixedJoint` between the world frame and the root link) and + :attr:`articulation_enabled` whose only USD path today is the PhysX-namespaced + ``physxArticulation:articulationEnabled`` attribute. The base class itself + declares no USD namespace; the writer consults :attr:`_usd_field_exceptions` + to route ``articulation_enabled`` to its non-base namespace and apply + ``PhysxArticulationAPI`` only when the user authored that one field. + For PhysX-only articulation-root properties (self-collisions, TGS solver + iterations, sleep / stabilization thresholds), use + :class:`~isaaclab_physx.sim.schemas.PhysxArticulationRootPropertiesCfg`. See :meth:`modify_articulation_root_properties` for more information. @@ -21,23 +102,36 @@ class ArticulationRootPropertiesCfg: the properties and leave the rest as-is. """ - articulation_enabled: bool | None = None - """Whether to enable or disable articulation.""" - - enabled_self_collisions: bool | None = None - """Whether to enable or disable self-collisions.""" + # -- Class metadata (not dataclass fields) -- + # No base-native namespace today: every field is either solver-common (typed + # UsdPhysics API) or routed through ``_usd_field_exceptions``. + _usd_namespace: ClassVar[str | None] = None + _usd_applied_schema: ClassVar[str | None] = None + # Per-field exceptions: applied_schema -> (namespace, [cfg_field, ...]). The USD + # attribute name is the auto snake -> camelCase of the cfg field name (project + # convention). When any listed field is non-None at write time, the writer applies + # the schema and writes the attribute under the exception namespace. + _usd_field_exceptions: ClassVar[dict] = { + "PhysxArticulationAPI": ("physxArticulation", ["articulation_enabled"]), + } - solver_position_iteration_count: int | None = None - """Solver position iteration counts for the body.""" + articulation_enabled: bool | None = None + """Whether to enable or disable the articulation. - solver_velocity_iteration_count: int | None = None - """Solver velocity iteration counts for the body.""" + PhysX honors this per-articulation at sim time via + ``physxArticulation:articulationEnabled``: setting False makes PhysX skip + the articulation in its solver passes. - sleep_threshold: float | None = None - """Mass-normalized kinetic energy threshold below which an actor may go to sleep.""" + On Newton, the field is read by the IsaacLab Newton wrapper at spawn time + (``isaaclab_newton/assets/rigid_object/rigid_object.py:1035``) as a guard + against accidentally spawning a ``RigidObject`` over a prim that still has + ``ArticulationRootAPI`` applied; setting False suppresses the guard error. + The Newton solver itself does not consult the flag at sim time. - stabilization_threshold: float | None = None - """The mass-normalized kinetic energy threshold below which an articulation may participate in stabilization.""" + Placed on the solver-common class because the user-facing intent is + universal and both PhysX (sim-time) and the IL Newton wrapper (spawn-time) + honor it. + """ fix_root_link: bool | None = None """Whether to fix the root link of the articulation. @@ -54,16 +148,38 @@ class ArticulationRootPropertiesCfg: @configclass -class RigidBodyPropertiesCfg: - """Properties to apply to a rigid body. +class RigidBodyBaseCfg: + """Solver-common properties to apply to a rigid body. + + Contains properties from the `UsdPhysics.RigidBodyAPI`_ that are common across all + simulation backends, plus :attr:`disable_gravity` whose USD attribute today is + PhysX-namespaced but whose semantics (per-body gravity exclusion) are universal: + PhysX honors it per-body; Newton's importer consumes it at the scene level + (partial honor, documented on the field). For PhysX-only rigid-body properties, + use :class:`PhysxRigidBodyPropertiesCfg`. See :meth:`modify_rigid_body_properties` for more information. .. note:: If the values are None, they are not modified. This is useful when you want to set only a subset of the properties and leave the rest as-is. + + .. _UsdPhysics.RigidBodyAPI: https://openusd.org/dev/api/class_usd_physics_rigid_body_a_p_i.html """ + # -- Class metadata (not dataclass fields) -- + # ``rigid_body_enabled`` and ``kinematic_enabled`` write to ``physics:*`` (UsdPhysics + # standard attributes). The helper's per-declaring-class routing keeps these under + # the base namespace even when the cfg is a PhysX subclass instance. The + # ``UsdPhysics.RigidBodyAPI`` schema is applied upstream by ``define_rigid_body_properties`` + # so ``_usd_applied_schema`` here stays None. ``disable_gravity`` is routed via + # ``_usd_field_exceptions`` to ``physxRigidBody:disableGravity``. + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = { + "PhysxRigidBodyAPI": ("physxRigidBody", ["disable_gravity"]), + } + rigid_body_enabled: bool | None = None """Whether to enable or disable the rigid body.""" @@ -77,85 +193,88 @@ class RigidBodyPropertiesCfg: """ disable_gravity: bool | None = None - """Disable gravity for the actor.""" - - linear_damping: float | None = None - """Linear damping for the body.""" - - angular_damping: float | None = None - """Angular damping for the body.""" + """Disable gravity for the body. - max_linear_velocity: float | None = None - """Maximum linear velocity for rigid bodies (in m/s).""" + PhysX honors this per-body via ``physxRigidBody:disableGravity``: setting True + excludes the body from world gravity integration. - max_angular_velocity: float | None = None - """Maximum angular velocity for rigid bodies (in deg/s).""" + Newton currently consumes the same USD attribute at the **scene level** -- + Newton's importer reads ``physxRigidBody:disableGravity`` on the scene prim + and uses it to drive the scene-wide ``builder.gravity`` flag (``import_usd.py:1212``). + Per-body intent is therefore partially honored on Newton: whichever rigid body + has the attribute authored ends up controlling scene-wide gravity, and other + bodies cannot be selectively excluded. - max_depenetration_velocity: float | None = None - """Maximum depenetration velocity permitted to be introduced by the solver (in m/s).""" - - max_contact_impulse: float | None = None - """The limit on the impulse that may be applied at a contact.""" - - enable_gyroscopic_forces: bool | None = None - """Enables computation of gyroscopic forces on the rigid body.""" - - retain_accelerations: bool | None = None - """Carries over forces/accelerations over sub-steps.""" - - solver_position_iteration_count: int | None = None - """Solver position iteration counts for the body.""" - - solver_velocity_iteration_count: int | None = None - """Solver position iteration counts for the body.""" - - sleep_threshold: float | None = None - """Mass-normalized kinetic energy threshold below which an actor may go to sleep.""" - - stabilization_threshold: float | None = None - """The mass-normalized kinetic energy threshold below which an actor may participate in stabilization.""" + The field is placed on the base because the user-facing intent (per-body + gravity exclusion for markers, sensors, kinematic targets) is universal physics + and PhysX honors it fully. Closing the Newton gap is a kernel-level fix + (introduce ``Model.body_disable_gravity`` boolean array consumed by the + integrator) that does not require a cfg-API change. + """ @configclass -class CollisionPropertiesCfg: - """Properties to apply to colliders in a rigid body. +class CollisionBaseCfg: + """Solver-common properties to apply to colliders. + + Contains :attr:`collision_enabled` from the `UsdPhysics.CollisionAPI`_ and the + :attr:`contact_offset` / :attr:`rest_offset` knobs whose USD attributes today are + PhysX-namespaced (``physxCollision:contactOffset``, ``physxCollision:restOffset``) + but whose semantics (collision-pair generation distance, rest separation gap) are + universal physics: PhysX consumes them natively, Newton's importer consumes them + via the PhysX bridge resolver and populates ``Model.shape_collision_radius`` / + ``Model.shape_collision_thickness`` from the ``gap`` and ``margin`` keys (see + ``import_usd.py:2104, 2111``). For PhysX-only collision properties (e.g. torsional + patch friction), use :class:`~isaaclab_physx.sim.schemas.PhysxCollisionPropertiesCfg`. See :meth:`modify_collision_properties` for more information. .. note:: If the values are None, they are not modified. This is useful when you want to set only a subset of the properties and leave the rest as-is. + + .. _UsdPhysics.CollisionAPI: https://openusd.org/dev/api/class_usd_physics_collision_a_p_i.html """ + # -- Class metadata (not dataclass fields) -- + # ``collision_enabled`` writes to ``physics:collisionEnabled`` (UsdPhysics standard). + # The helper's per-declaring-class routing keeps it under ``physics:*`` even when + # the cfg is a PhysX subclass instance. ``contact_offset`` / ``rest_offset`` are + # routed via ``_usd_field_exceptions`` to ``physxCollision:*``. + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = { + "PhysxCollisionAPI": ("physxCollision", ["contact_offset", "rest_offset"]), + } + collision_enabled: bool | None = None - """Whether to enable or disable collisions.""" + """Whether to enable or disable collisions. + + Writes ``physics:collisionEnabled`` via :class:`UsdPhysics.CollisionAPI`. + """ contact_offset: float | None = None - """Contact offset for the collision shape (in m). + """Contact offset for the collision shape [m]. The collision detector generates contact points as soon as two shapes get closer than the sum of their contact offsets. This quantity should be non-negative which means that contact generation can potentially start before the shapes actually penetrate. + + Writes ``physxCollision:contactOffset``. Newton's USD importer consumes the same + attribute via its PhysX-bridge resolver. """ rest_offset: float | None = None - """Rest offset for the collision shape (in m). + """Rest offset for the collision shape [m]. The rest offset quantifies how close a shape gets to others at rest, At rest, the distance between two vertically stacked objects is the sum of their rest offsets. If a pair of shapes have a positive rest offset, the shapes will be separated at rest by an air gap. - """ - - torsional_patch_radius: float | None = None - """Radius of the contact patch for applying torsional friction (in m). - It is used to approximate rotational friction introduced by the compression of contacting surfaces. - If the radius is zero, no torsional friction is applied. + Writes ``physxCollision:restOffset``. Newton's USD importer consumes the same + attribute via its PhysX-bridge resolver. """ - min_torsional_patch_radius: float | None = None - """Minimum radius of the contact patch for applying torsional friction (in m).""" - @configclass class MassPropertiesCfg: @@ -168,6 +287,13 @@ class MassPropertiesCfg: the properties and leave the rest as-is. """ + # -- Class metadata (not dataclass fields) -- + # ``mass`` / ``density`` write to ``physics:*`` (UsdPhysics standard attributes). + # The ``UsdPhysics.MassAPI`` schema is applied upstream by ``define_mass_properties``. + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = {} + mass: float | None = None """The mass of the rigid body (in kg). @@ -184,16 +310,43 @@ class MassPropertiesCfg: @configclass -class JointDrivePropertiesCfg: - """Properties to define the drive mechanism of a joint. +class JointDriveBaseCfg: + """Solver-common properties to define the drive mechanism of a joint. + + Contains properties from the `UsdPhysics.DriveAPI`_ that are common across all + simulation backends, plus :attr:`max_joint_velocity` whose USD attribute today is + PhysX-namespaced but whose semantics (per-DOF velocity limit) are universal: + Newton's importer consumes ``physxJoint:maxJointVelocity`` and populates + ``Model.joint_velocity_limit``; PhysX consumes it natively. For PhysX-only + drive properties, use :class:`PhysxJointDrivePropertiesCfg`. See :meth:`modify_joint_drive_properties` for more information. .. note:: If the values are None, they are not modified. This is useful when you want to set only a subset of the properties and leave the rest as-is. + + .. _UsdPhysics.DriveAPI: https://openusd.org/dev/api/class_usd_physics_drive_a_p_i.html """ + # -- Class metadata (not dataclass fields) -- + # No base-native namespace today: drive-type / max-effort / stiffness / damping are + # written via the typed ``UsdPhysics.DriveAPI``; ``max_joint_velocity`` is routed + # through ``_usd_field_exceptions`` to ``physxJoint:maxJointVelocity`` (the only + # USD path to ``Model.joint_velocity_limit`` today). + _usd_namespace: ClassVar[str | None] = None + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = { + "PhysxJointAPI": ("physxJoint", ["max_joint_velocity"]), + } + + def __post_init__(self): + # Deprecation aliases: project convention is that python ``snake_case`` cfg field + # names map identity-style to USD ``camelCase`` attrs. Legacy short names that + # diverged are forwarded here. + _deprecate_field_alias(self, "max_velocity", "max_joint_velocity") + _deprecate_field_alias(self, "max_effort", "max_force") + drive_type: Literal["force", "acceleration"] | None = None """Joint drive type to apply. @@ -201,16 +354,20 @@ class JointDrivePropertiesCfg: then the joint is driven by an acceleration (usually used for kinematic joints). """ - max_effort: float | None = None - """Maximum effort that can be applied to the joint (in kg-m^2/s^2).""" + max_force: float | None = None + """Maximum force/torque that can be applied to the joint [N for linear joints, N-m for angular joints]. - max_velocity: float | None = None - """Maximum velocity of the joint. + Writes ``drive::physics:maxForce`` via :class:`UsdPhysics.DriveAPI`. + """ - The unit depends on the joint model: + max_effort: float | None = None + """Deprecated alias for :attr:`max_force`. - * For linear joints, the unit is m/s. - * For angular joints, the unit is rad/s. + .. deprecated:: 4.6.25 + Use :attr:`max_force` instead. The cfg field is renamed so its + snake_case name maps identity-style to the USD camelCase attribute + (``maxForce`` on ``UsdPhysics.DriveAPI``). The alias is forwarded to + :attr:`max_force` in :meth:`__post_init__` and will be removed in 5.0. """ stiffness: float | None = None @@ -243,307 +400,122 @@ class JointDrivePropertiesCfg: overridden later by the actuator model. """ + max_joint_velocity: float | None = None + """Maximum velocity of the joint [m/s for linear joints, rad/s for angular joints]. -@configclass -class FixedTendonPropertiesCfg: - """Properties to define fixed tendons of an articulation. - - See :meth:`modify_fixed_tendon_properties` for more information. - - .. note:: - If the values are None, they are not modified. This is useful when you want to set only a subset of - the properties and leave the rest as-is. + Notes: + Today this writes ``physxJoint:maxJointVelocity`` (a PhysX add-on schema attribute). + Newton's USD importer consumes the same attribute via its PhysX-bridge resolver and + populates ``Model.joint_velocity_limit``; the PhysX engine consumes it natively. The + Kamino solver honors the limit at the simulation step. The XPBD, Featherstone, and + Semi-implicit Newton solvers import the value but do not consume it in their kernels; + the MuJoCo (MJC) solver explicitly drops it. When Newton ships ``newton:maxJointVelocity`` + as a registered applied API, the writer namespace will switch transparently and this + docstring caveat will be removed. """ - tendon_enabled: bool | None = None - """Whether to enable or disable the tendon.""" - - stiffness: float | None = None - """Spring stiffness term acting on the tendon's length.""" - - damping: float | None = None - """The damping term acting on both the tendon length and the tendon-length limits.""" - - limit_stiffness: float | None = None - """Limit stiffness term acting on the tendon's length limits.""" - - offset: float | None = None - """Length offset term for the tendon. + max_velocity: float | None = None + """Deprecated alias for :attr:`max_joint_velocity`. - It defines an amount to be added to the accumulated length computed for the tendon. This allows the application - to actuate the tendon by shortening or lengthening it. + .. deprecated:: 4.6.25 + Use :attr:`max_joint_velocity` instead. The cfg field is renamed so its + snake_case name maps identity-style to the USD camelCase attribute + (``physxJoint:maxJointVelocity``). The alias is forwarded to + :attr:`max_joint_velocity` in :meth:`__post_init__` and will be removed in 5.0. """ - rest_length: float | None = None - """Spring rest length of the tendon.""" - @configclass -class SpatialTendonPropertiesCfg: - """Properties to define spatial tendons of an articulation. +class MeshCollisionBaseCfg: + """Solver-common properties to apply to a mesh in regards to collision. - See :meth:`modify_spatial_tendon_properties` for more information. - - .. note:: - If the values are None, they are not modified. This is useful when you want to set only a subset of - the properties and leave the rest as-is. - """ - - tendon_enabled: bool | None = None - """Whether to enable or disable the tendon.""" - - stiffness: float | None = None - """Spring stiffness term acting on the tendon's length.""" - - damping: float | None = None - """The damping term acting on both the tendon length and the tendon-length limits.""" + Carries only the standard ``UsdPhysics:MeshCollisionAPI`` token + (:attr:`mesh_approximation_name` -> ``physics:approximation``). For PhysX-cooking + tunables (convex hull / decomposition / triangle mesh / SDF), use the + ``Physx*PropertiesCfg`` subclasses in :mod:`isaaclab_physx.sim.schemas`. - limit_stiffness: float | None = None - """Limit stiffness term acting on the tendon's length limits.""" - - offset: float | None = None - """Length offset term for the tendon. - - It defines an amount to be added to the accumulated length computed for the tendon. This allows the application - to actuate the tendon by shortening or lengthening it. - """ - - -@configclass -class MeshCollisionPropertiesCfg: - """Properties to apply to a mesh in regards to collision. - See :meth:`set_mesh_collision_properties` for more information. + See :meth:`modify_mesh_collision_properties` for more information. .. note:: - If the values are None, they are not modified. This is useful when you want to set only a subset of - the properties and leave the rest as-is. + If the values are None, they are not modified. This is useful when you want to + set only a subset of the properties and leave the rest as-is. """ - usd_api: str | None = None - """USD API name for mesh collision (e.g. 'MeshCollisionAPI').""" - - physx_api: str | None = None - """PhysX schema name for mesh collision (e.g. 'PhysxConvexDecompositionCollisionAPI').""" + # -- Class metadata (not dataclass fields) -- + # The standard ``UsdPhysics.MeshCollisionAPI`` is always applied by the writer when a + # mesh-collision cfg is supplied; ``_usd_applied_schema`` here records the standard + # API name so subclasses that author no PhysX namespace can rely on the writer's + # standard-vs-PhysX gating logic. PhysX-cooking subclasses override this. + _usd_applied_schema: ClassVar[str | None] = "MeshCollisionAPI" + # Base class authors no PhysX-namespaced fields, so no namespace is defined. + _usd_namespace: ClassVar[str | None] = None + _usd_attr_name_map: ClassVar[dict] = {} + _usd_field_exceptions: ClassVar[dict] = {} mesh_approximation_name: str = "none" """Name of mesh collision approximation method. Default: "none". - Refer to :const:`schemas.MESH_APPROXIMATION_TOKENS` for available options. - """ - -@configclass -class BoundingCubePropertiesCfg(MeshCollisionPropertiesCfg): - usd_api: str = "MeshCollisionAPI" - """Original USD Documentation: - https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_usd_physics_mesh_collision_a_p_i.html - """ - - mesh_approximation_name: str = "boundingCube" - """Name of mesh collision approximation method. Default: "boundingCube". + Writes ``physics:approximation`` via :class:`UsdPhysics.MeshCollisionAPI`. Refer to :const:`schemas.MESH_APPROXIMATION_TOKENS` for available options. """ - -@configclass -class BoundingSpherePropertiesCfg(MeshCollisionPropertiesCfg): - usd_api: str = "MeshCollisionAPI" - """Original USD Documentation: - https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_usd_physics_mesh_collision_a_p_i.html - """ - - mesh_approximation_name: str = "boundingSphere" - """Name of mesh collision approximation method. Default: "boundingSphere". - Refer to :const:`schemas.MESH_APPROXIMATION_TOKENS` for available options. - """ + def __getattr__(self, name: str): + """Deprecated read-only access to the legacy ``usd_api`` / ``physx_api`` instance attrs. + + Falls back here only when the attribute is not found on the dataclass instance. + Returns the legacy-mapped string value derived from the class-level + ``_usd_applied_schema`` metadata and emits a ``DeprecationWarning``. + """ + if name == "usd_api": + warnings.warn( + "'usd_api' attribute is deprecated and will be removed in 5.0. Use class-level" + " metadata via getattr(cfg, '_usd_applied_schema').", + DeprecationWarning, + stacklevel=2, + ) + schema = self.__dict__.get("_usd_applied_schema", None) + # Every PhysX cooking subclass legacy-mapped to ``"MeshCollisionAPI"``; the base + # class also wrote that token. Return ``None`` only when no schema is declared. + return "MeshCollisionAPI" if schema is not None else None + if name == "physx_api": + warnings.warn( + "'physx_api' attribute is deprecated and will be removed in 5.0. Use class-level" + " metadata via getattr(cfg, '_usd_applied_schema').", + DeprecationWarning, + stacklevel=2, + ) + schema = self.__dict__.get("_usd_applied_schema", None) + if schema and schema.startswith("Physx"): + return schema + return None + raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}") @configclass -class ConvexDecompositionPropertiesCfg(MeshCollisionPropertiesCfg): - usd_api: str = "MeshCollisionAPI" - """Original USD Documentation: - https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_usd_physics_mesh_collision_a_p_i.html - """ - - physx_api: str = "PhysxConvexDecompositionCollisionAPI" - """Original PhysX Documentation: - https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_convex_decomposition_collision_a_p_i.html - """ - - mesh_approximation_name: str = "convexDecomposition" - """Name of mesh collision approximation method. Default: "convexDecomposition". - Refer to :const:`schemas.MESH_APPROXIMATION_TOKENS` for available options. - """ - - hull_vertex_limit: int | None = None - """Convex hull vertex limit used for convex hull cooking. - - Defaults to 64. - """ - max_convex_hulls: int | None = None - """Maximum of convex hulls created during convex decomposition. - Default value is 32. - """ - min_thickness: float | None = None - """Convex hull min thickness. - - Range: [0, inf). Units are distance. Default value is 0.001. - """ - voxel_resolution: int | None = None - """Voxel resolution used for convex decomposition. - - Defaults to 500,000 voxels. - """ - error_percentage: float | None = None - """Convex decomposition error percentage parameter. - - Defaults to 10 percent. Units are percent. - """ - shrink_wrap: bool | None = None - """Attempts to adjust the convex hull points so that they are projected onto the surface of the original graphics - mesh. - - Defaults to False. - """ +class BoundingCubePropertiesCfg(MeshCollisionBaseCfg): + """Bounding-cube mesh collision approximation. USD-only; authors no PhysX schema. + Writes the ``boundingCube`` token to ``physics:approximation`` via + :class:`UsdPhysics.MeshCollisionAPI`. -@configclass -class ConvexHullPropertiesCfg(MeshCollisionPropertiesCfg): - usd_api: str = "MeshCollisionAPI" - """Original USD Documentation: + Original USD Documentation: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_usd_physics_mesh_collision_a_p_i.html """ - physx_api: str = "PhysxConvexHullCollisionAPI" - """Original PhysX Documentation: - https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_convex_hull_collision_a_p_i.html - """ - - mesh_approximation_name: str = "convexHull" - """Name of mesh collision approximation method. Default: "convexHull". - Refer to :const:`schemas.MESH_APPROXIMATION_TOKENS` for available options. - """ - - hull_vertex_limit: int | None = None - """Convex hull vertex limit used for convex hull cooking. - - Defaults to 64. - """ - min_thickness: float | None = None - """Convex hull min thickness. - - Range: [0, inf). Units are distance. Default value is 0.001. - """ + mesh_approximation_name: str = "boundingCube" + """Name of mesh collision approximation method. Default: "boundingCube".""" @configclass -class TriangleMeshPropertiesCfg(MeshCollisionPropertiesCfg): - physx_api: str = "PhysxTriangleMeshCollisionAPI" - """Triangle mesh is only supported by PhysX API. +class BoundingSpherePropertiesCfg(MeshCollisionBaseCfg): + """Bounding-sphere mesh collision approximation. USD-only; authors no PhysX schema. - Original PhysX Documentation: - https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_triangle_mesh_collision_a_p_i.html - """ + Writes the ``boundingSphere`` token to ``physics:approximation`` via + :class:`UsdPhysics.MeshCollisionAPI`. - mesh_approximation_name: str = "none" - """Name of mesh collision approximation method. Default: "none" (uses triangle mesh). - Refer to :const:`schemas.MESH_APPROXIMATION_TOKENS` for available options. - """ - - weld_tolerance: float | None = None - """Mesh weld tolerance, controls the distance at which vertices are welded. - - Default -inf will autocompute the welding tolerance based on the mesh size. Zero value will disable welding. - Range: [0, inf) Units: distance - """ - - -@configclass -class TriangleMeshSimplificationPropertiesCfg(MeshCollisionPropertiesCfg): - usd_api: str = "MeshCollisionAPI" - """Original USD Documentation: + Original USD Documentation: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_usd_physics_mesh_collision_a_p_i.html """ - physx_api: str = "PhysxTriangleMeshSimplificationCollisionAPI" - """Original PhysX Documentation: - https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_triangle_mesh_simplification_collision_a_p_i.html - """ - - mesh_approximation_name: str = "meshSimplification" - """Name of mesh collision approximation method. Default: "meshSimplification". - Refer to :const:`schemas.MESH_APPROXIMATION_TOKENS` for available options. - """ - - simplification_metric: float | None = None - """Mesh simplification accuracy. - - Defaults to 0.55. - """ - weld_tolerance: float | None = None - """Mesh weld tolerance, controls the distance at which vertices are welded. - - Default -inf will autocompute the welding tolerance based on the mesh size. Zero value will disable welding. - Range: [0, inf) Units: distance - """ - - -@configclass -class SDFMeshPropertiesCfg(MeshCollisionPropertiesCfg): - physx_api: str = "PhysxSDFMeshCollisionAPI" - """SDF mesh is only supported by PhysX API. - - Original PhysX documentation: - https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_s_d_f_mesh_collision_a_p_i.html - - More details and steps for optimizing SDF results can be found here: - https://nvidia-omniverse.github.io/PhysX/physx/5.2.1/docs/RigidBodyCollision.html#dynamic-triangle-meshes-with-sdfs - """ - - mesh_approximation_name: str = "sdf" - """Name of mesh collision approximation method. Default: "sdf". - Refer to :const:`schemas.MESH_APPROXIMATION_TOKENS` for available options. - """ - - sdf_margin: float | None = None - """Margin to increase the size of the SDF relative to the bounding box diagonal length of the mesh. - - - A sdf margin value of 0.01 means the sdf boundary will be enlarged in any direction by 1% of the mesh's bounding - box diagonal length. Representing the margin relative to the bounding box diagonal length ensures that it is scale - independent. Margins allow for precise distance queries in a region slightly outside of the mesh's bounding box. - - Default value is 0.01. - Range: [0, inf) Units: dimensionless - """ - sdf_narrow_band_thickness: float | None = None - """Size of the narrow band around the mesh surface where high resolution SDF samples are available. - - Outside of the narrow band, only low resolution samples are stored. Representing the narrow band thickness as a - fraction of the mesh's bounding box diagonal length ensures that it is scale independent. A value of 0.01 is - usually large enough. The smaller the narrow band thickness, the smaller the memory consumption of the sparse SDF. - - Default value is 0.01. - Range: [0, 1] Units: dimensionless - """ - sdf_resolution: int | None = None - """The spacing of the uniformly sampled SDF is equal to the largest AABB extent of the mesh, - divided by the resolution. - - Choose the lowest possible resolution that provides acceptable performance; very high resolution results in large - memory consumption, and slower cooking and simulation performance. - - Default value is 256. - Range: (1, inf) - """ - sdf_subgrid_resolution: int | None = None - """A positive subgrid resolution enables sparsity on signed-distance-fields (SDF) while a value of 0 leads to the - usage of a dense SDF. - - A value in the range of 4 to 8 is a reasonable compromise between block size and the overhead introduced by block - addressing. The smaller a block, the more memory is spent on the address table. The bigger a block, the less - precisely the sparse SDF can adapt to the mesh's surface. In most cases sparsity reduces the memory consumption of - a SDF significantly. - - Default value is 6. - Range: [0, inf) - """ + mesh_approximation_name: str = "boundingSphere" + """Name of mesh collision approximation method. Default: "boundingSphere".""" diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py index 78aaf1b15c94..87fc48de4d30 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py @@ -46,7 +46,7 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): spatial_tendons_props: schemas.SpatialTendonPropertiesCfg | None = None """Properties to apply to the spatial tendons (if any).""" - joint_drive_props: schemas.JointDrivePropertiesCfg | None = None + joint_drive_props: schemas.JointDriveBaseCfg | None = None """Properties to apply to a joint. .. note:: diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py index b86e98599fc0..ae4049c25d21 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py @@ -54,4 +54,30 @@ from isaaclab.utils.module import lazy_export -lazy_export() +_stub_getattr, _stub_dir, __all__ = lazy_export() + +# Names that moved out of this module into ``isaaclab_physx.sim.spawners.materials``. +# Resolved lazily on first access so importing ``isaaclab.sim.spawners.materials`` does +# not require ``isaaclab_physx`` to be installed. +_PHYSX_FORWARDS = frozenset({ + "RigidBodyMaterialCfg", + "PhysxRigidBodyMaterialCfg", +}) + + +def __getattr__(name): + if name in _PHYSX_FORWARDS: + try: + from isaaclab_physx.sim.spawners.materials import physics_materials_cfg as _physx_cfg + except ImportError as e: + raise ImportError( + f"'isaaclab.sim.spawners.materials.{name}' has moved to" + " 'isaaclab_physx.sim.spawners.materials'. Install the isaaclab_physx extension" + " or update your import. This forwarding shim is scheduled for removal in 5.0." + ) from e + return getattr(_physx_cfg, name) + return _stub_getattr(name) + + +def __dir__(): + return sorted(set(_stub_dir()) | _PHYSX_FORWARDS) diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi index 93142ddab389..0dd023c2998f 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi @@ -6,7 +6,7 @@ __all__ = [ "spawn_rigid_body_material", "PhysicsMaterialCfg", - "RigidBodyMaterialCfg", + "RigidBodyMaterialBaseCfg", "spawn_from_mdl_file", "spawn_preview_surface", "GlassMdlCfg", @@ -18,7 +18,7 @@ __all__ = [ from .physics_materials import spawn_rigid_body_material from .physics_materials_cfg import ( PhysicsMaterialCfg, - RigidBodyMaterialCfg, + RigidBodyMaterialBaseCfg, ) from .visual_materials import spawn_from_mdl_file, spawn_preview_surface from .visual_materials_cfg import GlassMdlCfg, MdlFileCfg, PreviewSurfaceCfg, VisualMaterialCfg diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py index 240a0ff00746..b76077b38bed 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py @@ -5,25 +5,33 @@ from __future__ import annotations +import dataclasses from typing import TYPE_CHECKING from pxr import Usd, UsdPhysics, UsdShade -from isaaclab.sim.utils import clone, safe_set_attribute_on_usd_prim, safe_set_attribute_on_usd_schema +from isaaclab.sim.schemas.schemas import _apply_namespaced_schemas +from isaaclab.sim.utils import clone from isaaclab.sim.utils.stage import get_current_stage -from isaaclab.utils.string import to_camel_case if TYPE_CHECKING: from . import physics_materials_cfg @clone -def spawn_rigid_body_material(prim_path: str, cfg: physics_materials_cfg.RigidBodyMaterialCfg) -> Usd.Prim: +def spawn_rigid_body_material(prim_path: str, cfg: physics_materials_cfg.RigidBodyMaterialBaseCfg) -> Usd.Prim: """Create material with rigid-body physics properties. Rigid body materials are used to define the physical properties to meshes of a rigid body. These - include the friction, restitution, and their respective combination modes. For more information on - rigid body material, please refer to the `documentation on PxMaterial `_. + include the friction, restitution, and (PhysX-only) compliant-contact spring and combine-mode + tokens. For more information on rigid body material, please refer to the `documentation on + PxMaterial `_. + + The writer is metadata-driven: it always applies the standard ``UsdPhysics.MaterialAPI`` and + writes the friction/restitution fields, then reads ``_usd_applied_schema``, ``_usd_namespace``, + and ``_usd_attr_name_map`` from the cfg to author solver-specific attributes. The applied + schema (e.g. ``PhysxMaterialAPI``) is added only when at least one solver-specific field has a + non-``None`` value at the instance level. .. note:: This function is decorated with :func:`clone` that resolves prim path into list of paths @@ -39,7 +47,8 @@ def spawn_rigid_body_material(prim_path: str, cfg: physics_materials_cfg.RigidBo The spawned rigid body material prim. Raises: - ValueError: When a prim already exists at the specified prim path and is not a material. + ValueError: When a prim already exists at the specified prim path and is not a material. + ValueError: When the cfg defines solver-specific fields but does not define ``_usd_namespace``. """ # get stage handle stage = get_current_stage() @@ -53,24 +62,17 @@ def spawn_rigid_body_material(prim_path: str, cfg: physics_materials_cfg.RigidBo # check if prim is a material if not prim.IsA(UsdShade.Material): raise ValueError(f"A prim already exists at path: '{prim_path}' but is not a material.") - # retrieve the USD rigid-body api - usd_physics_material_api = UsdPhysics.MaterialAPI(prim) - if not usd_physics_material_api: - usd_physics_material_api = UsdPhysics.MaterialAPI.Apply(prim) - # ensure PhysX material API is applied - applied = prim.GetAppliedSchemas() - if "PhysxMaterialAPI" not in applied: - prim.AddAppliedSchema("PhysxMaterialAPI") - - # convert to dict - cfg = cfg.to_dict() - del cfg["func"] - # set into USD API - for attr_name in ["static_friction", "dynamic_friction", "restitution"]: - value = cfg.pop(attr_name, None) - safe_set_attribute_on_usd_schema(usd_physics_material_api, attr_name, value, camel_case=True) - # set into PhysX API (prim attributes: physxMaterial:*) - for attr_name, value in cfg.items(): - safe_set_attribute_on_usd_prim(prim, f"physxMaterial:{to_camel_case(attr_name, 'cC')}", value, camel_case=False) + + # apply the standard UsdPhysics MaterialAPI (always) + if not UsdPhysics.MaterialAPI(prim): + UsdPhysics.MaterialAPI.Apply(prim) + + # build cfg dict, dropping underscore-prefixed metadata keys and the spawner ``func`` field + cfg_dict = {f.name: getattr(cfg, f.name) for f in dataclasses.fields(cfg) if f.name != "func"} + + # All fields routed by the helper: base friction/restitution under ``physics:*``, + # PhysX-subclass fields (compliant-contact, combine modes) under ``physxMaterial:*``. + _apply_namespaced_schemas(prim, cfg, cfg_dict) + # return the prim return prim diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py index dde9aec6d905..86437285ee9e 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py @@ -7,10 +7,31 @@ from collections.abc import Callable from dataclasses import MISSING -from typing import Literal +from typing import ClassVar from isaaclab.utils import configclass +# Names that moved out of this submodule into ``isaaclab_physx.sim.spawners.materials.physics_materials_cfg``. +# Resolved lazily so callers using ``from isaaclab.sim.spawners.materials.physics_materials_cfg +# import RigidBodyMaterialCfg`` continue to work without importing ``isaaclab_physx`` at module +# load time. +_PHYSX_FORWARDS = frozenset({"RigidBodyMaterialCfg", "PhysxRigidBodyMaterialCfg"}) + + +def __getattr__(name): + if name in _PHYSX_FORWARDS: + try: + from isaaclab_physx.sim.spawners.materials import physics_materials_cfg as _physx_mat_cfg + except ImportError as e: + raise ImportError( + f"'isaaclab.sim.spawners.materials.physics_materials_cfg.{name}' has moved to" + " 'isaaclab_physx.sim.spawners.materials.physics_materials_cfg'. Install the" + " isaaclab_physx extension or update your import. This forwarding shim is scheduled" + " for removal in 5.0." + ) from e + return getattr(_physx_mat_cfg, name) + raise AttributeError(f"module 'isaaclab.sim.spawners.materials.physics_materials_cfg' has no attribute {name!r}") + @configclass class PhysicsMaterialCfg: @@ -27,12 +48,26 @@ class PhysicsMaterialCfg: @configclass -class RigidBodyMaterialCfg(PhysicsMaterialCfg): - """Physics material parameters for rigid bodies. +class RigidBodyMaterialBaseCfg(PhysicsMaterialCfg): + """Solver-common physics-material parameters for rigid bodies. + + Contains the friction and restitution fields from the `UsdPhysics.MaterialAPI`_ that are common + across all simulation backends. For PhysX-only material properties (compliant-contact spring, + combine modes), use :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. See :meth:`spawn_rigid_body_material` for more information. + + .. _UsdPhysics.MaterialAPI: https://openusd.org/dev/api/class_usd_physics_material_a_p_i.html """ + # -- Class metadata (not dataclass fields) -- + # ``static_friction`` / ``dynamic_friction`` / ``restitution`` write to ``physics:*`` + # (UsdPhysics standard attributes). The helper's per-declaring-class routing keeps + # them under the base namespace even when the cfg is a PhysX subclass instance. + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = {} + func: Callable | str = "{DIR}.physics_materials:spawn_rigid_body_material" static_friction: float = 0.5 @@ -43,37 +78,3 @@ class RigidBodyMaterialCfg(PhysicsMaterialCfg): restitution: float = 0.0 """The restitution coefficient. Defaults to 0.0.""" - - friction_combine_mode: Literal["average", "min", "multiply", "max"] = "average" - """Determines the way friction will be combined during collisions. Defaults to `"average"`. - - .. attention:: - - When two physics materials with different combine modes collide, the combine mode with the higher - priority will be used. The priority order is provided `here - `__. - """ - - restitution_combine_mode: Literal["average", "min", "multiply", "max"] = "average" - """Determines the way restitution coefficient will be combined during collisions. Defaults to `"average"`. - - .. attention:: - - When two physics materials with different combine modes collide, the combine mode with the higher - priority will be used. The priority order is provided `here - `__. - """ - - compliant_contact_stiffness: float = 0.0 - """Spring stiffness for a compliant contact model using implicit springs. Defaults to 0.0. - - A higher stiffness results in behavior closer to a rigid contact. The compliant contact model is only enabled - if the stiffness is larger than 0. - """ - - compliant_contact_damping: float = 0.0 - """Damping coefficient for a compliant contact model using implicit springs. Defaults to 0.0. - - Irrelevant if compliant contacts are disabled when :obj:`compliant_contact_stiffness` is set to zero and - rigid contacts are active. - """ diff --git a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py index 7a803ad0e0dd..0adf9215f81e 100644 --- a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py @@ -85,10 +85,10 @@ class RigidObjectSpawnerCfg(SpawnerCfg): mass_props: schemas.MassPropertiesCfg | None = None """Mass properties.""" - rigid_props: schemas.RigidBodyPropertiesCfg | None = None + rigid_props: schemas.RigidBodyBaseCfg | None = None """Rigid body properties. - For making a rigid object static, set the :attr:`schemas.RigidBodyPropertiesCfg.kinematic_enabled` + For making a rigid object static, set the :attr:`schemas.RigidBodyBaseCfg.kinematic_enabled` as True. This will make the object static and will not be affected by gravity or other forces. """ diff --git a/source/isaaclab/isaaclab/utils/configclass.py b/source/isaaclab/isaaclab/utils/configclass.py index 59605f2797cc..f44f1288e017 100644 --- a/source/isaaclab/isaaclab/utils/configclass.py +++ b/source/isaaclab/isaaclab/utils/configclass.py @@ -452,9 +452,13 @@ class State: for key in ann: # find matching field in class value = class_members.get(key, MISSING) - # check if key belongs to ClassVar - # in that case, we cannot use default_factory! - origin = getattr(ann[key], "__origin__", None) + # check if key belongs to ClassVar -- in that case, we cannot use default_factory! + # ``from __future__ import annotations`` turns annotations into strings, so we + # also detect the string form (``"ClassVar[...]"``) for files using PEP 563. + ann_value = ann[key] + if isinstance(ann_value, str) and ann_value.startswith(("ClassVar", "typing.ClassVar")): + continue + origin = getattr(ann_value, "__origin__", None) if origin is ClassVar: continue # check if f is MISSING diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 1acda3b149c6..1fb03ede1433 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -13,14 +13,32 @@ """Rest everything follows.""" import math +import warnings import pytest +from isaaclab_physx.sim.schemas import ( + ArticulationRootPropertiesCfg as ArticulationRootDeprecatedAliasCfg, +) +from isaaclab_physx.sim.schemas import ( + CollisionPropertiesCfg as PhysxCollisionPropertiesCfgAlias, +) +from isaaclab_physx.sim.schemas import ( + PhysxArticulationRootPropertiesCfg, + PhysxCollisionPropertiesCfg, + PhysxJointDrivePropertiesCfg, + PhysxRigidBodyPropertiesCfg, +) +from isaaclab_physx.sim.schemas import ( + PhysXCollisionPropertiesCfg as PhysxDeformableCollisionAliasCfg, +) +from isaaclab_physx.sim.spawners.materials import PhysxRigidBodyMaterialCfg, RigidBodyMaterialCfg from pxr import UsdPhysics import isaaclab.sim as sim_utils import isaaclab.sim.schemas as schemas from isaaclab.sim import SimulationCfg, SimulationContext +from isaaclab.sim.spawners.materials import RigidBodyMaterialBaseCfg, spawn_rigid_body_material from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR from isaaclab.utils.string import to_camel_case @@ -44,7 +62,7 @@ def setup_simulation(): stabilization_threshold=5.0, fix_root_link=False, ) - rigid_cfg = schemas.RigidBodyPropertiesCfg( + rigid_cfg = PhysxRigidBodyPropertiesCfg( rigid_body_enabled=True, kinematic_enabled=False, disable_gravity=False, @@ -69,8 +87,8 @@ def setup_simulation(): torsional_patch_radius=1.0, ) mass_cfg = schemas.MassPropertiesCfg(mass=1.0, density=100.0) - joint_cfg = schemas.JointDrivePropertiesCfg( - drive_type="acceleration", max_effort=80.0, max_velocity=10.0, stiffness=10.0, damping=0.1 + joint_cfg = PhysxJointDrivePropertiesCfg( + drive_type="acceleration", max_force=80.0, max_joint_velocity=10.0, stiffness=10.0, damping=0.1 ) yield sim, arti_cfg, rigid_cfg, collision_cfg, mass_cfg, joint_cfg # Teardown @@ -86,12 +104,602 @@ def test_valid_properties_cfg(setup_simulation): This is to ensure that we check that all the properties of the schema are set. """ sim, arti_cfg, rigid_cfg, collision_cfg, mass_cfg, joint_cfg = setup_simulation + # deprecation aliases are nulled by __post_init__ after forwarding to the canonical + # field; exclude them from the all-non-None check. + deprecation_aliases = {"max_velocity", "max_effort"} for cfg in [arti_cfg, rigid_cfg, collision_cfg, mass_cfg, joint_cfg]: - # check nothing is none for k, v in cfg.__dict__.items(): + # skip class-metadata keys (``_usd_*``) and deprecation aliases nulled in __post_init__ + if k.startswith("_") or k in deprecation_aliases: + continue assert v is not None, f"{cfg.__class__.__name__}:{k} is None. Please make sure schemas are valid." +@pytest.mark.isaacsim_ci +def test_max_joint_velocity_on_base_cfg(setup_simulation): + """Setting ``max_joint_velocity`` on the base ``JointDriveBaseCfg`` must author + ``physxJoint:maxJointVelocity`` on the prim, identical to setting it on + the deprecated PhysX subclass. + + Regression test for the Path 2 placement rule: ``max_joint_velocity`` is the + only USD path to ``Model.joint_velocity_limit`` and lives on the base. + """ + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + base_cfg = schemas.JointDriveBaseCfg( + drive_type="acceleration", + max_force=80.0, + max_joint_velocity=10.0, + stiffness=10.0, + damping=0.1, + ) + + # spawn a minimal articulation with a revolute joint, then write properties. + sim_utils.create_prim("/World/Articulation", prim_type="Xform") + sim_utils.create_prim("/World/Articulation/body0", prim_type="Cube") + sim_utils.create_prim("/World/Articulation/body1", prim_type="Cube") + UsdPhysics.RevoluteJoint.Define(stage, "/World/Articulation/joint_0") + + prim_path = "/World/Articulation/joint_0" + # use unwrapped function (no parent traversal) so this returns the inner bool + schemas.modify_joint_drive_properties.__wrapped__(prim_path, base_cfg) + + # Revolute drives convert rad/s -> deg/s; check the authored value. + attr = stage.GetPrimAtPath(prim_path).GetAttribute("physxJoint:maxJointVelocity") + assert attr.IsValid(), "physxJoint:maxJointVelocity was not authored on the prim" + expected_deg_per_sec = 10.0 * 180.0 / math.pi + assert attr.Get() == pytest.approx(expected_deg_per_sec, rel=1e-6) + + +@pytest.mark.isaacsim_ci +def test_max_velocity_deprecation_alias(setup_simulation): + """Legacy ``max_velocity`` kwarg must forward to ``max_joint_velocity`` and emit + a ``DeprecationWarning``. Behavior must match setting ``max_joint_velocity`` directly. + """ + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + with pytest.warns(DeprecationWarning, match="max_velocity"): + base_cfg = schemas.JointDriveBaseCfg( + drive_type="acceleration", + max_force=80.0, + max_velocity=10.0, + stiffness=10.0, + damping=0.1, + ) + + assert base_cfg.max_joint_velocity == 10.0 + assert base_cfg.max_velocity is None + + sim_utils.create_prim("/World/Articulation_dep", prim_type="Xform") + sim_utils.create_prim("/World/Articulation_dep/body0", prim_type="Cube") + sim_utils.create_prim("/World/Articulation_dep/body1", prim_type="Cube") + UsdPhysics.RevoluteJoint.Define(stage, "/World/Articulation_dep/joint_0") + prim_path = "/World/Articulation_dep/joint_0" + schemas.modify_joint_drive_properties.__wrapped__(prim_path, base_cfg) + + attr = stage.GetPrimAtPath(prim_path).GetAttribute("physxJoint:maxJointVelocity") + assert attr.IsValid() + assert attr.Get() == pytest.approx(10.0 * 180.0 / math.pi, rel=1e-6) + + +@pytest.mark.isaacsim_ci +def test_max_effort_deprecation_alias(setup_simulation): + """Legacy ``max_effort`` kwarg must forward to ``max_force`` and emit + a ``DeprecationWarning``. Behavior must match setting ``max_force`` directly. + """ + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + with pytest.warns(DeprecationWarning, match="max_effort"): + base_cfg = schemas.JointDriveBaseCfg( + drive_type="acceleration", + max_effort=42.0, + stiffness=10.0, + damping=0.1, + ) + + assert base_cfg.max_force == 42.0 + assert base_cfg.max_effort is None + + sim_utils.create_prim("/World/Articulation_eff", prim_type="Xform") + sim_utils.create_prim("/World/Articulation_eff/body0", prim_type="Cube") + sim_utils.create_prim("/World/Articulation_eff/body1", prim_type="Cube") + UsdPhysics.PrismaticJoint.Define(stage, "/World/Articulation_eff/joint_0") + prim_path = "/World/Articulation_eff/joint_0" + schemas.modify_joint_drive_properties.__wrapped__(prim_path, base_cfg) + + attr = stage.GetPrimAtPath(prim_path).GetAttribute("drive:linear:physics:maxForce") + assert attr.IsValid() + assert attr.Get() == pytest.approx(42.0, rel=1e-6) + + +@pytest.mark.isaacsim_ci +def test_joint_drive_base_no_physx_schema_when_max_joint_velocity_unset(setup_simulation): + """Regression: setting only UsdPhysics drive fields on JointDriveBaseCfg + must NOT cause PhysxJointAPI to be applied to the prim. Without this, + Newton-targeted users get PhysX schemas stamped on every joint.""" + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + base_cfg = schemas.JointDriveBaseCfg( + drive_type="acceleration", + max_force=80.0, + stiffness=10.0, + damping=0.1, + # max_joint_velocity intentionally left None + ) + sim_utils.create_prim("/World/Articulation", prim_type="Xform") + sim_utils.create_prim("/World/Articulation/body0", prim_type="Cube") + sim_utils.create_prim("/World/Articulation/body1", prim_type="Cube") + UsdPhysics.RevoluteJoint.Define(stage, "/World/Articulation/joint_0") + + prim_path = "/World/Articulation/joint_0" + schemas.modify_joint_drive_properties.__wrapped__(prim_path, base_cfg) + + applied = stage.GetPrimAtPath(prim_path).GetAppliedSchemas() + assert "PhysxJointAPI" not in applied, ( + f"PhysxJointAPI should not be applied when max_velocity is None; got {list(applied)}" + ) + + +@pytest.mark.isaacsim_ci +def test_disable_gravity_on_base_cfg(setup_simulation): + """Setting disable_gravity on the base RigidBodyBaseCfg must author + physxRigidBody:disableGravity on the prim. PhysX honors per-body; + Newton currently honors at scene level (partial), documented in field + docstring. Regression test for the consumption-gated placement rule.""" + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + base_cfg = schemas.RigidBodyBaseCfg( + rigid_body_enabled=True, + kinematic_enabled=False, + disable_gravity=True, + ) + sim_utils.create_prim("/World/cube_dg", prim_type="Cube", translation=(0.0, 0.0, 0.62)) + schemas.define_rigid_body_properties("/World/cube_dg", base_cfg) + + prim_path = "/World/cube_dg" + attr = stage.GetPrimAtPath(prim_path).GetAttribute("physxRigidBody:disableGravity") + assert attr.IsValid(), "physxRigidBody:disableGravity was not authored on the prim" + assert attr.Get() is True + applied = stage.GetPrimAtPath(prim_path).GetAppliedSchemas() + assert "PhysxRigidBodyAPI" in applied, ( + f"PhysxRigidBodyAPI must be applied when disable_gravity is set; got {list(applied)}" + ) + + +@pytest.mark.isaacsim_ci +def test_physx_rigid_body_no_physx_schema_when_all_physx_fields_none(setup_simulation): + """Regression: PhysxRigidBodyPropertiesCfg with all PhysX-specific fields + left as None must NOT cause PhysxRigidBodyAPI to be applied to the prim. + The user only authored UsdPhysics-standard fields; the PhysX schema + should not be stamped onto a Newton-targeted asset.""" + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + cfg = PhysxRigidBodyPropertiesCfg( + rigid_body_enabled=True, + kinematic_enabled=False, + # every PhysX field intentionally left None + ) + sim_utils.create_prim("/World/cube_no_physx", prim_type="Cube", translation=(0.0, 0.0, 0.62)) + schemas.define_rigid_body_properties("/World/cube_no_physx", cfg) + + prim_path = "/World/cube_no_physx" + applied = stage.GetPrimAtPath(prim_path).GetAppliedSchemas() + assert "PhysxRigidBodyAPI" not in applied, ( + f"PhysxRigidBodyAPI should not be applied when no PhysX fields are set; got {list(applied)}" + ) + + +@pytest.mark.isaacsim_ci +def test_rigid_body_material_base_cfg(setup_simulation): + """Setting only UsdPhysics fields on RigidBodyMaterialBaseCfg must author the + three friction/restitution attrs and must NOT apply PhysxMaterialAPI.""" + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + cfg = RigidBodyMaterialBaseCfg(static_friction=0.7, dynamic_friction=0.6, restitution=0.1) + prim_path = "/World/Looks/BaseMaterial" + spawn_rigid_body_material.__wrapped__(prim_path, cfg) + + prim = stage.GetPrimAtPath(prim_path) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.7) + assert prim.GetAttribute("physics:dynamicFriction").Get() == pytest.approx(0.6) + assert prim.GetAttribute("physics:restitution").Get() == pytest.approx(0.1) + applied = prim.GetAppliedSchemas() + assert "PhysxMaterialAPI" not in applied, ( + f"PhysxMaterialAPI must not be applied for the base cfg; got {list(applied)}" + ) + + +@pytest.mark.isaacsim_ci +def test_physx_rigid_body_material_cfg(setup_simulation): + """Setting a PhysX-namespaced field on PhysxRigidBodyMaterialCfg must author the + namespaced attribute AND apply PhysxMaterialAPI.""" + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + cfg = PhysxRigidBodyMaterialCfg(static_friction=0.7, compliant_contact_stiffness=100.0) + prim_path = "/World/Looks/PhysxMaterial" + spawn_rigid_body_material.__wrapped__(prim_path, cfg) + + prim = stage.GetPrimAtPath(prim_path) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.7) + assert prim.GetAttribute("physxMaterial:compliantContactStiffness").Get() == pytest.approx(100.0) + applied = prim.GetAppliedSchemas() + assert "PhysxMaterialAPI" in applied, ( + f"PhysxMaterialAPI must be applied when a PhysX field is set; got {list(applied)}" + ) + + +@pytest.mark.isaacsim_ci +def test_rigid_body_material_deprecation_alias(setup_simulation): + """Instantiating the legacy ``RigidBodyMaterialCfg`` name emits exactly one + ``DeprecationWarning`` whose message references the 5.0 removal target.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + RigidBodyMaterialCfg() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1, f"expected exactly one DeprecationWarning, got {len(deprecations)}" + assert "5.0" in str(deprecations[0].message) + + +@pytest.mark.isaacsim_ci +def test_collision_base_cfg_writes_physx_namespaced_attrs(setup_simulation): + """Setting ``contact_offset`` / ``rest_offset`` on the base ``CollisionBaseCfg`` must + author the ``physxCollision:*`` attributes AND apply ``PhysxCollisionAPI``. Newton's + importer consumes them via the PhysX bridge resolver.""" + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + base_cfg = schemas.CollisionBaseCfg(collision_enabled=True, contact_offset=0.05, rest_offset=0.001) + sim_utils.create_prim("/World/cube_co", prim_type="Cube", translation=(0.0, 0.0, 0.62)) + schemas.define_collision_properties("/World/cube_co", base_cfg) + + prim = stage.GetPrimAtPath("/World/cube_co") + assert prim.GetAttribute("physxCollision:contactOffset").Get() == pytest.approx(0.05) + assert prim.GetAttribute("physxCollision:restOffset").Get() == pytest.approx(0.001) + applied = prim.GetAppliedSchemas() + assert "PhysxCollisionAPI" in applied, ( + f"PhysxCollisionAPI must be applied when contact_offset/rest_offset are set; got {list(applied)}" + ) + + +@pytest.mark.isaacsim_ci +def test_collision_base_cfg_no_physx_schema_when_only_usd_field_set(setup_simulation): + """Regression: setting only ``collision_enabled`` on ``CollisionBaseCfg`` must NOT + cause ``PhysxCollisionAPI`` to be applied. The user only authored a UsdPhysics-standard + field; the PhysX schema should not be stamped onto a Newton-targeted prim.""" + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + base_cfg = schemas.CollisionBaseCfg(collision_enabled=True) + sim_utils.create_prim("/World/cube_co_only", prim_type="Cube", translation=(0.0, 0.0, 0.62)) + schemas.define_collision_properties("/World/cube_co_only", base_cfg) + + prim = stage.GetPrimAtPath("/World/cube_co_only") + assert prim.GetAttribute("physics:collisionEnabled").Get() is True + applied = prim.GetAppliedSchemas() + assert "PhysxCollisionAPI" not in applied, ( + f"PhysxCollisionAPI should not be applied when only collision_enabled is set; got {list(applied)}" + ) + + +@pytest.mark.isaacsim_ci +def test_physx_collision_cfg_writes_torsional_patch(setup_simulation): + """Setting ``torsional_patch_radius`` on ``PhysxCollisionPropertiesCfg`` must author + the ``physxCollision:torsionalPatchRadius`` attribute AND apply ``PhysxCollisionAPI``.""" + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + cfg = PhysxCollisionPropertiesCfg(torsional_patch_radius=1.0) + sim_utils.create_prim("/World/cube_tpr", prim_type="Cube", translation=(0.0, 0.0, 0.62)) + schemas.define_collision_properties("/World/cube_tpr", cfg) + + prim = stage.GetPrimAtPath("/World/cube_tpr") + assert prim.GetAttribute("physxCollision:torsionalPatchRadius").Get() == pytest.approx(1.0) + applied = prim.GetAppliedSchemas() + assert "PhysxCollisionAPI" in applied + + +@pytest.mark.isaacsim_ci +def test_collision_deprecation_alias(setup_simulation): + """Instantiating the legacy ``CollisionPropertiesCfg`` name emits exactly one + ``DeprecationWarning`` whose message references the 5.0 removal target.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + PhysxCollisionPropertiesCfgAlias() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1, f"expected exactly one DeprecationWarning, got {len(deprecations)}" + assert "5.0" in str(deprecations[0].message) + + +@pytest.mark.isaacsim_ci +def test_physx_capitalx_collision_deprecation_alias(setup_simulation): + """Instantiating the legacy ``PhysXCollisionPropertiesCfg`` (capital X, deformable) + name emits exactly one ``DeprecationWarning`` pointing to + ``PhysxDeformableCollisionPropertiesCfg``.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + PhysxDeformableCollisionAliasCfg() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1, f"expected exactly one DeprecationWarning, got {len(deprecations)}" + assert "PhysxDeformableCollisionPropertiesCfg" in str(deprecations[0].message) + + +@pytest.mark.isaacsim_ci +def test_articulation_root_base_cfg_writes_articulation_enabled(setup_simulation): + """Setting ``articulation_enabled`` on the base ``ArticulationRootBaseCfg`` must author + ``physxArticulation:articulationEnabled`` AND apply ``PhysxArticulationAPI``. The + PhysX namespace is honored at sim time by PhysX and as a spawn-time guard by the IL + Newton wrapper.""" + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + base_cfg = schemas.ArticulationRootBaseCfg(articulation_enabled=False) + sim_utils.create_prim("/World/arti_ae", prim_type="Xform") + schemas.define_articulation_root_properties("/World/arti_ae", base_cfg) + + prim = stage.GetPrimAtPath("/World/arti_ae") + assert prim.GetAttribute("physxArticulation:articulationEnabled").Get() is False + applied = prim.GetAppliedSchemas() + assert "PhysxArticulationAPI" in applied, ( + f"PhysxArticulationAPI must be applied when articulation_enabled is set; got {list(applied)}" + ) + + +@pytest.mark.isaacsim_ci +def test_articulation_root_base_no_physx_schema_when_only_fix_root_link_set(setup_simulation): + """Regression: setting only ``fix_root_link`` on ``ArticulationRootBaseCfg`` must NOT + cause ``PhysxArticulationAPI`` to be applied. ``fix_root_link`` is a writer-side flag + materializing ``UsdPhysics.FixedJoint``; it does not author any PhysX-namespaced + attribute. Newton-targeted prims that only set ``fix_root_link`` should not receive + ``PhysxArticulationAPI`` stamping.""" + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + base_cfg = schemas.ArticulationRootBaseCfg(fix_root_link=False) + sim_utils.create_prim("/World/arti_frl", prim_type="Xform") + schemas.define_articulation_root_properties("/World/arti_frl", base_cfg) + + prim = stage.GetPrimAtPath("/World/arti_frl") + applied = prim.GetAppliedSchemas() + assert "PhysxArticulationAPI" not in applied, ( + f"PhysxArticulationAPI should not be applied when only fix_root_link is set; got {list(applied)}" + ) + + +@pytest.mark.isaacsim_ci +def test_physx_articulation_root_writes_self_collisions(setup_simulation): + """Setting ``enabled_self_collisions`` on ``PhysxArticulationRootPropertiesCfg`` must + author ``physxArticulation:enabledSelfCollisions`` AND apply ``PhysxArticulationAPI``.""" + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + cfg = PhysxArticulationRootPropertiesCfg(enabled_self_collisions=True) + sim_utils.create_prim("/World/arti_sc", prim_type="Xform") + schemas.define_articulation_root_properties("/World/arti_sc", cfg) + + prim = stage.GetPrimAtPath("/World/arti_sc") + assert prim.GetAttribute("physxArticulation:enabledSelfCollisions").Get() is True + applied = prim.GetAppliedSchemas() + assert "PhysxArticulationAPI" in applied + + +@pytest.mark.isaacsim_ci +def test_articulation_root_deprecation_alias(setup_simulation): + """Instantiating the legacy ``ArticulationRootPropertiesCfg`` name emits exactly one + ``DeprecationWarning`` whose message references the 5.0 removal target.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + ArticulationRootDeprecatedAliasCfg() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1, f"expected exactly one DeprecationWarning, got {len(deprecations)}" + assert "5.0" in str(deprecations[0].message) + + +@pytest.mark.isaacsim_ci +def test_mesh_collision_base_cfg_writes_approximation_token(setup_simulation): + """``MeshCollisionBaseCfg(mesh_approximation_name="boundingCube")`` authors + ``physics:approximation`` via ``UsdPhysics.MeshCollisionAPI``. No PhysX cooking schema is + applied because the base class declares no PhysX namespace.""" + from pxr import UsdGeom + + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + UsdGeom.Mesh.Define(stage, "/World/mesh_base") + cfg = schemas.MeshCollisionBaseCfg(mesh_approximation_name="boundingCube") + schemas.define_mesh_collision_properties("/World/mesh_base", cfg) + + prim = stage.GetPrimAtPath("/World/mesh_base") + assert prim.GetAttribute("physics:approximation").Get() == "boundingCube" + applied = prim.GetAppliedSchemas() + # The standard UsdPhysics.MeshCollisionAPI is registered under + # ``PhysicsMeshCollisionAPI`` in the prim's applied-schema list. + assert any("MeshCollisionAPI" in s for s in applied), ( + f"a MeshCollisionAPI schema must be applied; got {list(applied)}" + ) + # no PhysX cooking schema applied for the base class + assert not any(s.startswith("Physx") and "Mesh" in s for s in applied), ( + f"no PhysX mesh schema should be applied for the base class; got {list(applied)}" + ) + + +@pytest.mark.isaacsim_ci +def test_physx_convex_hull_writes_tuning_attrs(setup_simulation): + """Setting tuning fields on ``PhysxConvexHullPropertiesCfg`` authors the + ``physxConvexHullCollision:*`` namespaced attributes AND applies + ``PhysxConvexHullCollisionAPI``.""" + from isaaclab_physx.sim.schemas import PhysxConvexHullPropertiesCfg + + from pxr import UsdGeom + + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + UsdGeom.Mesh.Define(stage, "/World/mesh_ch") + cfg = PhysxConvexHullPropertiesCfg(hull_vertex_limit=64, min_thickness=0.001) + schemas.define_mesh_collision_properties("/World/mesh_ch", cfg) + + prim = stage.GetPrimAtPath("/World/mesh_ch") + assert prim.GetAttribute("physics:approximation").Get() == "convexHull" + assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 64 + assert prim.GetAttribute("physxConvexHullCollision:minThickness").Get() == pytest.approx(0.001) + applied = prim.GetAppliedSchemas() + assert "PhysxConvexHullCollisionAPI" in applied + + +@pytest.mark.isaacsim_ci +def test_physx_convex_hull_no_physx_schema_when_no_tuning_fields_set(setup_simulation): + """Regression: ``PhysxConvexHullPropertiesCfg()`` with all tuning fields None must NOT + apply ``PhysxConvexHullCollisionAPI``. The approximation token is still authored on the + standard ``UsdPhysics.MeshCollisionAPI``.""" + from isaaclab_physx.sim.schemas import PhysxConvexHullPropertiesCfg + + from pxr import UsdGeom + + sim, _, _, _, _, _ = setup_simulation + stage = sim_utils.get_current_stage() + + UsdGeom.Mesh.Define(stage, "/World/mesh_ch_default") + cfg = PhysxConvexHullPropertiesCfg() + schemas.define_mesh_collision_properties("/World/mesh_ch_default", cfg) + + prim = stage.GetPrimAtPath("/World/mesh_ch_default") + assert prim.GetAttribute("physics:approximation").Get() == "convexHull" + applied = prim.GetAppliedSchemas() + assert "PhysxConvexHullCollisionAPI" not in applied, ( + f"PhysxConvexHullCollisionAPI should not be applied without tuning fields; got {list(applied)}" + ) + + +@pytest.mark.isaacsim_ci +def test_bounding_cube_default_token(setup_simulation): + """``BoundingCubePropertiesCfg()`` defaults to the ``boundingCube`` token.""" + cfg = schemas.BoundingCubePropertiesCfg() + assert cfg.mesh_approximation_name == "boundingCube" + + +@pytest.mark.isaacsim_ci +@pytest.mark.parametrize( + "name", + [ + "MeshCollisionPropertiesCfg", + "ConvexHullPropertiesCfg", + "ConvexDecompositionPropertiesCfg", + "TriangleMeshPropertiesCfg", + "TriangleMeshSimplificationPropertiesCfg", + "SDFMeshPropertiesCfg", + ], +) +def test_mesh_collision_deprecation_aliases(setup_simulation, name): + """Each legacy mesh-collision class name emits exactly one DeprecationWarning on + instantiation and the warning message references the 5.0 removal target.""" + from isaaclab_physx.sim.schemas import schemas_cfg as physx_cfg + + cls = getattr(physx_cfg, name) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + cls() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1, f"{name}: expected one DeprecationWarning, got {len(deprecations)}" + assert "5.0" in str(deprecations[0].message) + + +@pytest.mark.isaacsim_ci +def test_physx_fixed_tendon_relocation(setup_simulation): + """``PhysxFixedTendonPropertiesCfg`` is importable from + :mod:`isaaclab_physx.sim.schemas` and round-trips its fields.""" + from isaaclab_physx.sim.schemas import PhysxFixedTendonPropertiesCfg + + cfg = PhysxFixedTendonPropertiesCfg( + tendon_enabled=True, + stiffness=10.0, + damping=0.5, + limit_stiffness=1.0, + offset=0.1, + rest_length=0.2, + ) + assert cfg.tendon_enabled is True + assert cfg.stiffness == 10.0 + assert cfg.damping == 0.5 + assert cfg.limit_stiffness == 1.0 + assert cfg.offset == 0.1 + assert cfg.rest_length == 0.2 + + +@pytest.mark.isaacsim_ci +def test_fixed_tendon_deprecation_alias(setup_simulation): + """Instantiating the legacy ``FixedTendonPropertiesCfg`` (via the shim) emits exactly + one ``DeprecationWarning`` whose message references the 5.0 removal target.""" + cls = schemas.FixedTendonPropertiesCfg + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + cls() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1, f"expected one DeprecationWarning, got {len(deprecations)}" + assert "5.0" in str(deprecations[0].message) + + +@pytest.mark.isaacsim_ci +def test_physx_spatial_tendon_relocation(setup_simulation): + """``PhysxSpatialTendonPropertiesCfg`` is importable from + :mod:`isaaclab_physx.sim.schemas` and round-trips its fields.""" + from isaaclab_physx.sim.schemas import PhysxSpatialTendonPropertiesCfg + + cfg = PhysxSpatialTendonPropertiesCfg( + tendon_enabled=True, + stiffness=20.0, + damping=0.25, + limit_stiffness=2.0, + offset=0.05, + ) + assert cfg.tendon_enabled is True + assert cfg.stiffness == 20.0 + assert cfg.damping == 0.25 + assert cfg.limit_stiffness == 2.0 + assert cfg.offset == 0.05 + + +@pytest.mark.isaacsim_ci +def test_spatial_tendon_deprecation_alias(setup_simulation): + """Instantiating the legacy ``SpatialTendonPropertiesCfg`` (via the shim) emits exactly + one ``DeprecationWarning`` whose message references the 5.0 removal target.""" + cls = schemas.SpatialTendonPropertiesCfg + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + cls() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1, f"expected one DeprecationWarning, got {len(deprecations)}" + assert "5.0" in str(deprecations[0].message) + + +@pytest.mark.isaacsim_ci +def test_usd_api_physx_api_attrs_deprecated(setup_simulation): + """Reading ``cfg.usd_api`` and ``cfg.physx_api`` on the new mesh cfgs emits a + DeprecationWarning and returns the legacy-mapped string value.""" + from isaaclab_physx.sim.schemas import PhysxConvexHullPropertiesCfg + + cfg = PhysxConvexHullPropertiesCfg() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + usd_api_value = cfg.usd_api + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + assert usd_api_value == "MeshCollisionAPI" + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + physx_api_value = cfg.physx_api + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + assert physx_api_value == "PhysxConvexHullCollisionAPI" + + @pytest.mark.isaacsim_ci def test_modify_properties_on_invalid_prim(setup_simulation): """Test modifying properties on a prim that does not exist.""" @@ -290,8 +898,8 @@ def _validate_articulation_properties_on_prim( root_prim = stage.GetPrimAtPath(prim_path) # check articulation properties are set correctly for attr_name, attr_value in arti_cfg.__dict__.items(): - # skip names we know are not present - if attr_name == "func": + # skip class metadata and names we know are not present + if attr_name.startswith("_") or attr_name == "func": continue # handle fixed root link if attr_name == "fix_root_link" and attr_value is not None: @@ -334,8 +942,12 @@ def _validate_rigid_body_properties_on_prim(prim_path: str, rigid_cfg, verbose: for link_prim in root_prim.GetChildren(): if UsdPhysics.RigidBodyAPI(link_prim): for attr_name, attr_value in rigid_cfg.__dict__.items(): - # skip names we know are not present - if attr_name in ["func", "rigid_body_enabled", "kinematic_enabled"]: + # skip class metadata and names we know are not present + if attr_name.startswith("_") or attr_name in [ + "func", + "rigid_body_enabled", + "kinematic_enabled", + ]: continue # convert attribute name in prim to cfg name prim_prop_name = f"physxRigidBody:{to_camel_case(attr_name, to='cC')}" @@ -363,8 +975,8 @@ def _validate_collision_properties_on_prim(prim_path: str, collision_cfg, verbos for mesh_prim in link_prim.GetChildren(): if UsdPhysics.CollisionAPI(mesh_prim): for attr_name, attr_value in collision_cfg.__dict__.items(): - # skip names we know are not present - if attr_name in ["func", "collision_enabled"]: + # skip names we know are not present and class-metadata keys + if attr_name.startswith("_") or attr_name in ["func", "collision_enabled"]: continue # convert attribute name in prim to cfg name prim_prop_name = f"physxCollision:{to_camel_case(attr_name, to='cC')}" @@ -391,8 +1003,8 @@ def _validate_mass_properties_on_prim(prim_path: str, mass_cfg, verbose: bool = for link_prim in root_prim.GetChildren(): if UsdPhysics.MassAPI(link_prim): for attr_name, attr_value in mass_cfg.__dict__.items(): - # skip names we know are not present - if attr_name in ["func"]: + # skip names we know are not present and class-metadata keys + if attr_name in ["func"] or attr_name.startswith("_"): continue # print(link_prim.GetProperties()) prim_prop_name = f"physics:{to_camel_case(attr_name, to='cC')}" @@ -423,8 +1035,8 @@ def _validate_joint_drive_properties_on_prim(prim_path: str, joint_cfg, verbose: assert joint_prim.HasAPI(UsdPhysics.DriveAPI) # iterate over the joint properties for attr_name, attr_value in joint_cfg.__dict__.items(): - # skip names we know are not present - if attr_name in ["func", "ensure_drives_exist"]: + # skip class metadata and names we know are not present on the USD prim + if attr_name.startswith("_") or attr_name in ["func", "ensure_drives_exist"]: continue # resolve the drive (linear or angular) drive_model = "linear" if joint_prim.IsA(UsdPhysics.PrismaticJoint) else "angular" @@ -437,10 +1049,8 @@ def _validate_joint_drive_properties_on_prim(prim_path: str, joint_cfg, verbose: continue # non-string attributes - if attr_name == "max_velocity": + if attr_name == "max_joint_velocity": prim_attr_name = "physxJoint:maxJointVelocity" - elif attr_name == "max_effort": - prim_attr_name = f"drive:{drive_model}:physics:maxForce" else: prim_attr_name = f"drive:{drive_model}:physics:{to_camel_case(attr_name, to='cC')}" @@ -450,7 +1060,7 @@ def _validate_joint_drive_properties_on_prim(prim_path: str, joint_cfg, verbose: # for angular drives, we expect user to set in radians # the values reported by USD are in degrees if drive_model == "angular": - if attr_name == "max_velocity": + if attr_name == "max_joint_velocity": # deg / s --> rad / s prim_attr_value = prim_attr_value * math.pi / 180.0 elif attr_name in ["stiffness", "damping"]: diff --git a/source/isaaclab/test/sim/test_schemas_shim.py b/source/isaaclab/test/sim/test_schemas_shim.py new file mode 100644 index 000000000000..11e6e5d1ba24 --- /dev/null +++ b/source/isaaclab/test/sim/test_schemas_shim.py @@ -0,0 +1,172 @@ +# 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 + +"""Tests that the forwarding shims resolve the PhysX cfgs that were relocated to +:mod:`isaaclab_physx`. Covers both the schema cfgs (in :mod:`isaaclab.sim.schemas`) and the +material cfgs (in :mod:`isaaclab.sim.spawners.materials`). + +These tests do not require Isaac Sim — only Python import semantics. +""" + +import warnings + +import pytest +from isaaclab_physx.sim.schemas import schemas_cfg as physx_cfg +from isaaclab_physx.sim.spawners.materials import physics_materials_cfg as physx_mat_cfg + +import isaaclab.sim as sim_utils +import isaaclab.sim.schemas as schemas +import isaaclab.sim.schemas.schemas_cfg as schemas_cfg_submodule +import isaaclab.sim.spawners.materials as materials +import isaaclab.sim.spawners.materials.physics_materials_cfg as materials_cfg_submodule + +FORWARDED_NAMES = [ + "RigidBodyPropertiesCfg", + "JointDrivePropertiesCfg", + "PhysxRigidBodyPropertiesCfg", + "PhysxJointDrivePropertiesCfg", + "CollisionPropertiesCfg", + "PhysxCollisionPropertiesCfg", + "PhysxDeformableCollisionPropertiesCfg", + "PhysXCollisionPropertiesCfg", + "ArticulationRootPropertiesCfg", + "PhysxArticulationRootPropertiesCfg", + "MeshCollisionPropertiesCfg", + "ConvexHullPropertiesCfg", + "ConvexDecompositionPropertiesCfg", + "TriangleMeshPropertiesCfg", + "TriangleMeshSimplificationPropertiesCfg", + "SDFMeshPropertiesCfg", + "PhysxConvexHullPropertiesCfg", + "PhysxConvexDecompositionPropertiesCfg", + "PhysxTriangleMeshPropertiesCfg", + "PhysxTriangleMeshSimplificationPropertiesCfg", + "PhysxSDFMeshPropertiesCfg", + "FixedTendonPropertiesCfg", + "SpatialTendonPropertiesCfg", + "PhysxFixedTendonPropertiesCfg", + "PhysxSpatialTendonPropertiesCfg", +] + +DEPRECATED_FORWARDED_NAMES = [ + "RigidBodyPropertiesCfg", + "JointDrivePropertiesCfg", + "CollisionPropertiesCfg", + "PhysXCollisionPropertiesCfg", + "ArticulationRootPropertiesCfg", + "MeshCollisionPropertiesCfg", + "ConvexHullPropertiesCfg", + "ConvexDecompositionPropertiesCfg", + "TriangleMeshPropertiesCfg", + "TriangleMeshSimplificationPropertiesCfg", + "SDFMeshPropertiesCfg", + "FixedTendonPropertiesCfg", + "SpatialTendonPropertiesCfg", +] + +FORWARDED_MATERIAL_NAMES = [ + "RigidBodyMaterialCfg", + "PhysxRigidBodyMaterialCfg", +] + + +@pytest.mark.parametrize("name", FORWARDED_NAMES) +def test_schemas_shim_resolves_to_physx_class(name): + """``isaaclab.sim.schemas.`` resolves to the same class object as the one in + ``isaaclab_physx.sim.schemas.schemas_cfg``.""" + assert getattr(schemas, name) is getattr(physx_cfg, name) + + +@pytest.mark.parametrize("name", FORWARDED_NAMES) +def test_sim_namespace_shim_resolves_to_physx_class(name): + """``isaaclab.sim.`` (i.e. ``sim_utils.``) resolves to the same class object.""" + assert getattr(sim_utils, name) is getattr(physx_cfg, name) + + +@pytest.mark.parametrize("name", FORWARDED_NAMES) +def test_schemas_cfg_submodule_shim_resolves_to_physx_class(name): + """``from isaaclab.sim.schemas.schemas_cfg import `` (direct submodule import path) + resolves to the same class object as the relocated definition.""" + assert getattr(schemas_cfg_submodule, name) is getattr(physx_cfg, name) + + +@pytest.mark.parametrize("name", FORWARDED_MATERIAL_NAMES) +def test_materials_shim_resolves_to_physx_class(name): + """``isaaclab.sim.spawners.materials.`` resolves to the same class object as the + one in ``isaaclab_physx.sim.spawners.materials.physics_materials_cfg``.""" + assert getattr(materials, name) is getattr(physx_mat_cfg, name) + + +@pytest.mark.parametrize("name", FORWARDED_MATERIAL_NAMES) +def test_materials_cfg_submodule_shim_resolves_to_physx_class(name): + """``from isaaclab.sim.spawners.materials.physics_materials_cfg import `` (direct + submodule import path) resolves to the same class object as the relocated definition.""" + assert getattr(materials_cfg_submodule, name) is getattr(physx_mat_cfg, name) + + +@pytest.mark.parametrize("name", FORWARDED_MATERIAL_NAMES) +def test_sim_namespace_material_shim_resolves_to_physx_class(name): + """``isaaclab.sim.`` (i.e. ``sim_utils.``) resolves to the relocated material class.""" + assert getattr(sim_utils, name) is getattr(physx_mat_cfg, name) + + +def test_deprecated_alias_emits_deprecation_warning(): + """Instantiating ``RigidBodyPropertiesCfg`` via the shim still emits ``DeprecationWarning``.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + schemas.RigidBodyPropertiesCfg() + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + +@pytest.mark.parametrize("name", DEPRECATED_FORWARDED_NAMES) +def test_deprecated_aliases_emit_deprecation_warning(name): + """Instantiating each deprecated forwarded alias via the shim emits exactly one + ``DeprecationWarning``.""" + cls = getattr(schemas, name) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + cls() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1, f"{name}: expected one DeprecationWarning, got {len(deprecations)}" + + +def test_deprecated_material_alias_emits_deprecation_warning(): + """Instantiating ``RigidBodyMaterialCfg`` via the shim still emits ``DeprecationWarning``.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + materials.RigidBodyMaterialCfg() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecations) == 1 + assert "5.0" in str(deprecations[0].message) + + +def test_new_class_does_not_emit_deprecation_warning(): + """Instantiating ``PhysxRigidBodyPropertiesCfg`` directly does NOT emit ``DeprecationWarning``.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + schemas.PhysxRigidBodyPropertiesCfg() + assert not any(issubclass(w.category, DeprecationWarning) for w in caught) + + +def test_new_material_class_does_not_emit_deprecation_warning(): + """Instantiating ``PhysxRigidBodyMaterialCfg`` directly does NOT emit ``DeprecationWarning``.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + materials.PhysxRigidBodyMaterialCfg() + assert not any(issubclass(w.category, DeprecationWarning) for w in caught) + + +def test_dir_lists_forwarded_names(): + """``dir(isaaclab.sim.schemas)`` includes the forwarded names so IDE autocomplete works.""" + listing = dir(schemas) + for name in FORWARDED_NAMES: + assert name in listing + + +def test_dir_lists_forwarded_material_names(): + """``dir(isaaclab.sim.spawners.materials)`` includes the forwarded names.""" + listing = dir(materials) + for name in FORWARDED_MATERIAL_NAMES: + assert name in listing diff --git a/source/isaaclab_newton/changelog.d/vidur-feature-usd-proprties-refactor.skip b/source/isaaclab_newton/changelog.d/vidur-feature-usd-proprties-refactor.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index a5eacf95045e..a392e7773468 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -196,7 +196,7 @@ def generate_articulation_cfg( # we set 80.0 default for max force because default in USD is 10e10 which makes testing annoying. spawn=sim_utils.UsdFileCfg( usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/SimpleArticulation/revolute_articulation.usd", - joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_effort=80.0, max_velocity=5.0), + joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_force=80.0, max_joint_velocity=5.0), ), actuators={ "joint": ImplicitActuatorCfg( @@ -220,7 +220,7 @@ def generate_articulation_cfg( articulation_cfg = ArticulationCfg( spawn=sim_utils.UsdFileCfg( usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/SimpleArticulation/revolute_articulation.usd", - joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_effort=80.0, max_velocity=5.0), + joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_force=80.0, max_joint_velocity=5.0), ), actuators={ "joint": IdealPDActuatorCfg( @@ -1510,7 +1510,7 @@ def test_setting_velocity_limit_implicit( # Case 3: velocity limit sim is not set but velocity limit is set # For backwards compatibility, we do not set velocity limit to simulation # Thus, both default to USD default value. - limit = articulation_cfg.spawn.joint_drive_props.max_velocity + limit = articulation_cfg.spawn.joint_drive_props.max_joint_velocity else: # Case 4: only velocity limit sim is set # In this case, the velocity limit is set to the USD value @@ -1572,7 +1572,7 @@ def test_setting_velocity_limit_explicit(sim, num_articulations, device, vel_lim if vel_limit_sim is not None: limit = vel_limit_sim else: - limit = articulation_cfg.spawn.joint_drive_props.max_velocity + limit = articulation_cfg.spawn.joint_drive_props.max_joint_velocity # check physx is set to expected value expected_vel_limit = torch.full_like(newton_vel_limit, limit) torch.testing.assert_close(newton_vel_limit, expected_vel_limit) @@ -1625,7 +1625,7 @@ def test_setting_effort_limit_implicit( # decide the limit based on what is set if effort_limit_sim is None and effort_limit is None: - limit = articulation_cfg.spawn.joint_drive_props.max_effort + limit = articulation_cfg.spawn.joint_drive_props.max_force elif effort_limit_sim is not None and effort_limit is None: limit = effort_limit_sim elif effort_limit_sim is None and effort_limit is not None: diff --git a/source/isaaclab_physx/changelog.d/vidur-rebalance-cfg-placement.minor.rst b/source/isaaclab_physx/changelog.d/vidur-rebalance-cfg-placement.minor.rst new file mode 100644 index 000000000000..4233c5d1c720 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidur-rebalance-cfg-placement.minor.rst @@ -0,0 +1,77 @@ +Added +^^^^^ + +* Added :class:`PhysxRigidBodyMaterialCfg`, a subclass of + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` carrying the + ``PhysxMaterialAPI`` schema fields (``compliant_contact_stiffness``, + ``compliant_contact_damping``, ``friction_combine_mode``, ``restitution_combine_mode``). + Use this when authoring PhysX-specific material knobs; use the base class when only the + UsdPhysics-standard friction/restitution fields are needed. +* Added :class:`PhysxCollisionPropertiesCfg`, a subclass of + :class:`~isaaclab.sim.schemas.CollisionBaseCfg` carrying the PhysX-specific + ``torsional_patch_radius`` / ``min_torsional_patch_radius`` friction approximations. + These fields have no Newton equivalent. +* Added :class:`PhysxDeformableCollisionPropertiesCfg`, renaming the previous + ``PhysXCollisionPropertiesCfg`` (capital X) for clarity. Used internally by + :class:`DeformableBodyPropertiesCfg`. +* Added :class:`PhysxArticulationRootPropertiesCfg`, a subclass of + :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` carrying the PhysX-specific + ``enabled_self_collisions``, ``solver_position_iteration_count``, + ``solver_velocity_iteration_count``, ``sleep_threshold``, ``stabilization_threshold``. +* Added :class:`PhysxConvexHullPropertiesCfg`, :class:`PhysxConvexDecompositionPropertiesCfg`, + :class:`PhysxTriangleMeshPropertiesCfg`, + :class:`PhysxTriangleMeshSimplificationPropertiesCfg`, and + :class:`PhysxSDFMeshPropertiesCfg` -- the PhysX-cooking-specific mesh collision + subclasses. Each declares its own PhysxSchema cooking API via class-level + ``_usd_applied_schema`` metadata and inherits ``mesh_approximation_name`` from + :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg`. +* Added :class:`PhysxFixedTendonPropertiesCfg` and :class:`PhysxSpatialTendonPropertiesCfg`, + the relocated PhysX-only tendon cfg classes. Same fields as the legacy core-side classes; + no field-level split. + +Changed +^^^^^^^ + +* Removed the ``max_velocity`` field and USD metadata + (``_usd_applied_schema``, ``_usd_namespace``, ``_usd_attr_name_map``) from + :class:`PhysxJointDrivePropertiesCfg`. The field moved to + :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`; ``PhysxJointDrivePropertiesCfg`` + inherits it. Existing instantiations continue to work unchanged. +* Removed the ``disable_gravity`` field from :class:`PhysxRigidBodyPropertiesCfg`. + The field moved to :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`; + ``PhysxRigidBodyPropertiesCfg`` inherits it. Existing instantiations continue + to work unchanged. + +Deprecated +^^^^^^^^^^ + +* Deprecated :class:`RigidBodyMaterialCfg` in favor of + :class:`PhysxRigidBodyMaterialCfg` (PhysX-specific) or + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` (solver-common). + The legacy name remains as a concrete subclass of :class:`PhysxRigidBodyMaterialCfg` + that emits ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. +* Deprecated :class:`CollisionPropertiesCfg` in favor of + :class:`PhysxCollisionPropertiesCfg` (PhysX-specific) or + :class:`~isaaclab.sim.schemas.CollisionBaseCfg` (solver-common). The legacy name remains + as a concrete subclass of :class:`PhysxCollisionPropertiesCfg` that emits + ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. +* Deprecated :class:`PhysXCollisionPropertiesCfg` (capital X, deformable-body) in favor of + :class:`PhysxDeformableCollisionPropertiesCfg`. The capital-X name is preserved as a + deprecation alias (concrete subclass) and is scheduled for removal in 5.0. +* Deprecated :class:`ArticulationRootPropertiesCfg` in favor of + :class:`PhysxArticulationRootPropertiesCfg` (PhysX-specific) or + :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` (solver-common). The legacy name + remains as a concrete subclass of :class:`PhysxArticulationRootPropertiesCfg` that emits + ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. +* Deprecated :class:`MeshCollisionPropertiesCfg`, :class:`ConvexHullPropertiesCfg`, + :class:`ConvexDecompositionPropertiesCfg`, :class:`TriangleMeshPropertiesCfg`, + :class:`TriangleMeshSimplificationPropertiesCfg`, and :class:`SDFMeshPropertiesCfg` in + favor of :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg` or the new ``Physx*`` + subclasses. Legacy names remain as concrete subclasses that emit ``DeprecationWarning`` + on instantiation. Scheduled for removal in 5.0. +* Deprecated :class:`FixedTendonPropertiesCfg` in favor of + :class:`PhysxFixedTendonPropertiesCfg`. Legacy name remains as a concrete subclass that + emits ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. +* Deprecated :class:`SpatialTendonPropertiesCfg` in favor of + :class:`PhysxSpatialTendonPropertiesCfg`. Legacy name remains as a concrete subclass + that emits ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi index abc8d0087afd..9a522d96ff8f 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi @@ -7,6 +7,10 @@ __all__ = [ "define_deformable_body_properties", "modify_deformable_body_properties", "DeformableBodyPropertiesCfg", + "JointDrivePropertiesCfg", + "PhysxJointDrivePropertiesCfg", + "PhysxRigidBodyPropertiesCfg", + "RigidBodyPropertiesCfg", "DeformableObjectSpawnerCfg", "spawn_deformable_body_material", "DeformableBodyMaterialCfg", @@ -17,7 +21,11 @@ __all__ = [ from .schemas import ( define_deformable_body_properties, modify_deformable_body_properties, - DeformableBodyPropertiesCfg + DeformableBodyPropertiesCfg, + JointDrivePropertiesCfg, + PhysxJointDrivePropertiesCfg, + PhysxRigidBodyPropertiesCfg, + RigidBodyPropertiesCfg, ) from .spawners import ( DeformableObjectSpawnerCfg, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index bb0e51a19b4b..6d2a05bf803c 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -6,11 +6,63 @@ __all__ = [ "define_deformable_body_properties", "modify_deformable_body_properties", + "ArticulationRootPropertiesCfg", + "CollisionPropertiesCfg", + "ConvexDecompositionPropertiesCfg", + "ConvexHullPropertiesCfg", "DeformableBodyPropertiesCfg", + "FixedTendonPropertiesCfg", + "JointDrivePropertiesCfg", + "MeshCollisionPropertiesCfg", + "PhysxArticulationRootPropertiesCfg", + "PhysxCollisionPropertiesCfg", + "PhysXCollisionPropertiesCfg", + "PhysxConvexDecompositionPropertiesCfg", + "PhysxConvexHullPropertiesCfg", + "PhysxDeformableCollisionPropertiesCfg", + "PhysxFixedTendonPropertiesCfg", + "PhysxJointDrivePropertiesCfg", + "PhysxRigidBodyPropertiesCfg", + "PhysxSDFMeshPropertiesCfg", + "PhysxSpatialTendonPropertiesCfg", + "PhysxTriangleMeshPropertiesCfg", + "PhysxTriangleMeshSimplificationPropertiesCfg", + "RigidBodyPropertiesCfg", + "SDFMeshPropertiesCfg", + "SpatialTendonPropertiesCfg", + "TriangleMeshPropertiesCfg", + "TriangleMeshSimplificationPropertiesCfg", ] from .schemas import ( define_deformable_body_properties, modify_deformable_body_properties, ) -from .schemas_cfg import DeformableBodyPropertiesCfg +from .schemas_cfg import ( + ArticulationRootPropertiesCfg, + CollisionPropertiesCfg, + ConvexDecompositionPropertiesCfg, + ConvexHullPropertiesCfg, + DeformableBodyPropertiesCfg, + FixedTendonPropertiesCfg, + JointDrivePropertiesCfg, + MeshCollisionPropertiesCfg, + PhysxArticulationRootPropertiesCfg, + PhysxCollisionPropertiesCfg, + PhysXCollisionPropertiesCfg, + PhysxConvexDecompositionPropertiesCfg, + PhysxConvexHullPropertiesCfg, + PhysxDeformableCollisionPropertiesCfg, + PhysxFixedTendonPropertiesCfg, + PhysxJointDrivePropertiesCfg, + PhysxRigidBodyPropertiesCfg, + PhysxSDFMeshPropertiesCfg, + PhysxSpatialTendonPropertiesCfg, + PhysxTriangleMeshPropertiesCfg, + PhysxTriangleMeshSimplificationPropertiesCfg, + RigidBodyPropertiesCfg, + SDFMeshPropertiesCfg, + SpatialTendonPropertiesCfg, + TriangleMeshPropertiesCfg, + TriangleMeshSimplificationPropertiesCfg, +) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py index 6579fee6356a..e6bdea28d24e 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -6,7 +6,16 @@ from __future__ import annotations import dataclasses - +import warnings +from typing import ClassVar + +from isaaclab.sim.schemas.schemas_cfg import ( + ArticulationRootBaseCfg, + CollisionBaseCfg, + JointDriveBaseCfg, + MeshCollisionBaseCfg, + RigidBodyBaseCfg, +) from isaaclab.utils import configclass @@ -110,12 +119,19 @@ class PhysXDeformableBodyPropertiesCfg: @configclass -class PhysXCollisionPropertiesCfg: +class PhysxDeformableCollisionPropertiesCfg: """PhysX-specific collision properties for a deformable body. These properties are set with the prefix ``physxCollision:``. See the PhysX documentation for more information on the available properties. + + .. note:: + This class is distinct from + :class:`~isaaclab_physx.sim.schemas.PhysxCollisionPropertiesCfg` (lowercase x), + which is the rigid-body collision cfg layered on + :class:`~isaaclab.sim.schemas.CollisionBaseCfg`. This class is used internally + as a base of :class:`DeformableBodyPropertiesCfg`. """ contact_offset: float | None = None @@ -135,9 +151,30 @@ class PhysXCollisionPropertiesCfg: """ +@configclass +class PhysXCollisionPropertiesCfg(PhysxDeformableCollisionPropertiesCfg): + """Deprecated: use :class:`PhysxDeformableCollisionPropertiesCfg`. + + .. deprecated:: 4.6.23 + ``PhysXCollisionPropertiesCfg`` (capital X) was renamed to + :class:`PhysxDeformableCollisionPropertiesCfg` to clear the namespace for the + new rigid-body :class:`PhysxCollisionPropertiesCfg` (lowercase x). The capital-X + name is preserved as a deprecation alias and is scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'PhysXCollisionPropertiesCfg' (capital X) is deprecated and will be removed in 5.0." + " Use 'isaaclab_physx.sim.schemas.PhysxDeformableCollisionPropertiesCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + @configclass class DeformableBodyPropertiesCfg( - OmniPhysicsPropertiesCfg, PhysXDeformableBodyPropertiesCfg, PhysXCollisionPropertiesCfg + OmniPhysicsPropertiesCfg, PhysXDeformableBodyPropertiesCfg, PhysxDeformableCollisionPropertiesCfg ): """Properties to apply to a deformable body. @@ -158,6 +195,716 @@ class DeformableBodyPropertiesCfg( _property_prefix: dict[str, list[str]] = { "omniphysics": [field.name for field in dataclasses.fields(OmniPhysicsPropertiesCfg)], "physxDeformableBody": [field.name for field in dataclasses.fields(PhysXDeformableBodyPropertiesCfg)], - "physxCollision": [field.name for field in dataclasses.fields(PhysXCollisionPropertiesCfg)], + "physxCollision": [field.name for field in dataclasses.fields(PhysxDeformableCollisionPropertiesCfg)], } """Mapping between the property prefixes and the properties that fall under each prefix.""" + + +@configclass +class PhysxRigidBodyPropertiesCfg(RigidBodyBaseCfg): + """PhysX-specific rigid body properties. + + Extends :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg` with properties from the `PhysxRigidBodyAPI`_ schema. + + See :meth:`~isaaclab.sim.schemas.modify_rigid_body_properties` for more information. + + .. note:: + If the values are None, they are not modified. This is useful when you want to set only a subset of + the properties and leave the rest as-is. + + .. _PhysxRigidBodyAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_rigid_body_a_p_i.html + """ + + # PhysX-specific fields below all live under the ``PhysxRigidBodyAPI`` schema's + # ``physxRigidBody:*`` namespace. The ``disable_gravity`` field on the base remains + # routed via ``_usd_field_exceptions`` (inherited). + _usd_applied_schema: ClassVar[str | None] = "PhysxRigidBodyAPI" + _usd_namespace: ClassVar[str | None] = "physxRigidBody" + + linear_damping: float | None = None + """Linear damping for the body.""" + + angular_damping: float | None = None + """Angular damping for the body.""" + + max_linear_velocity: float | None = None + """Maximum linear velocity for rigid bodies (in m/s).""" + + max_angular_velocity: float | None = None + """Maximum angular velocity for rigid bodies (in deg/s).""" + + max_depenetration_velocity: float | None = None + """Maximum depenetration velocity permitted to be introduced by the solver (in m/s).""" + + max_contact_impulse: float | None = None + """The limit on the impulse that may be applied at a contact.""" + + enable_gyroscopic_forces: bool | None = None + """Enables computation of gyroscopic forces on the rigid body.""" + + retain_accelerations: bool | None = None + """Carries over forces/accelerations over sub-steps.""" + + solver_position_iteration_count: int | None = None + """Solver position iteration counts for the body.""" + + solver_velocity_iteration_count: int | None = None + """Solver velocity iteration counts for the body.""" + + sleep_threshold: float | None = None + """Mass-normalized kinetic energy threshold below which an actor may go to sleep.""" + + stabilization_threshold: float | None = None + """The mass-normalized kinetic energy threshold below which an actor may participate in stabilization.""" + + +@configclass +class RigidBodyPropertiesCfg(PhysxRigidBodyPropertiesCfg): + """Deprecated: use :class:`PhysxRigidBodyPropertiesCfg` or :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`. + + .. deprecated:: 4.6.22 + ``RigidBodyPropertiesCfg`` has been split into + :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg` (solver-common) and + :class:`PhysxRigidBodyPropertiesCfg` (PhysX-specific) and relocated to + :mod:`isaaclab_physx.sim.schemas`. This alias preserves backwards compatibility and is + scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'RigidBodyPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxRigidBodyPropertiesCfg' for PhysX properties, or" + " 'isaaclab.sim.schemas.RigidBodyBaseCfg' for solver-common properties only.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + +@configclass +class PhysxJointDrivePropertiesCfg(JointDriveBaseCfg): + """PhysX-specific joint drive properties. + + Currently empty after the consumption-gated split moved :attr:`max_joint_velocity` + to :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`. This class is retained + as the deprecation-alias target for the legacy :class:`JointDrivePropertiesCfg` + name and as the home for any future PhysX-only joint-drive fields (e.g. + PhysX-specific drive force-limit modes). + + Inherits all fields and USD metadata from + :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`. + + See :meth:`~isaaclab.sim.schemas.modify_joint_drive_properties` for more information. + + .. _PhysxJointAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_joint_a_p_i.html + """ + + # ``max_joint_velocity`` on the base remains routed via ``_usd_field_exceptions`` + # (inherited). Future PhysX-only joint-drive fields would be written under this + # namespace. + _usd_applied_schema: ClassVar[str | None] = "PhysxJointAPI" + _usd_namespace: ClassVar[str | None] = "physxJoint" + + +@configclass +class JointDrivePropertiesCfg(PhysxJointDrivePropertiesCfg): + """Deprecated: use :class:`PhysxJointDrivePropertiesCfg` or :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`. + + .. deprecated:: 4.6.22 + ``JointDrivePropertiesCfg`` has been split into + :class:`~isaaclab.sim.schemas.JointDriveBaseCfg` (solver-common) and + :class:`PhysxJointDrivePropertiesCfg` (PhysX-specific) and relocated to + :mod:`isaaclab_physx.sim.schemas`. This alias preserves backwards compatibility and is + scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'JointDrivePropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxJointDrivePropertiesCfg' for PhysX properties, or" + " 'isaaclab.sim.schemas.JointDriveBaseCfg' for solver-common properties only.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + +@configclass +class PhysxCollisionPropertiesCfg(CollisionBaseCfg): + """PhysX-specific rigid-body collision properties. + + Extends :class:`~isaaclab.sim.schemas.CollisionBaseCfg` with the PhysX-only torsional + patch friction approximations (:attr:`torsional_patch_radius`, + :attr:`min_torsional_patch_radius`). These fields have no Newton equivalent and are + consumed only by the PhysX solver. + + See :meth:`~isaaclab.sim.schemas.modify_collision_properties` for more information. + + .. note:: + If the values are None, they are not modified. This is useful when you want to set only a subset of + the properties and leave the rest as-is. + + .. _PhysxCollisionAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_collision_a_p_i.html + """ + + # PhysX torsional-friction fields below live under the ``PhysxCollisionAPI`` schema's + # ``physxCollision:*`` namespace. Base ``contact_offset`` / ``rest_offset`` remain + # routed via ``_usd_field_exceptions`` (inherited). + _usd_applied_schema: ClassVar[str | None] = "PhysxCollisionAPI" + _usd_namespace: ClassVar[str | None] = "physxCollision" + + torsional_patch_radius: float | None = None + """Radius of the contact patch for applying torsional friction [m]. + + It is used to approximate rotational friction introduced by the compression of contacting surfaces. + If the radius is zero, no torsional friction is applied. + """ + + min_torsional_patch_radius: float | None = None + """Minimum radius of the contact patch for applying torsional friction [m].""" + + +@configclass +class PhysxArticulationRootPropertiesCfg(ArticulationRootBaseCfg): + """PhysX-specific articulation-root properties. + + Extends :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` with the + `PhysxArticulationAPI`_ schema fields that are PhysX-only or dual-namespace + (Rule 2 — the conceptual quantity also has a ``newton:*`` attribute, and a + future ``NewtonArticulationRootPropertiesCfg`` would carry it on the Newton + side). Use this class when authoring PhysX-specific articulation knobs; + use :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` when only the + solver-common ``fix_root_link`` / ``articulation_enabled`` fields are needed. + + See :meth:`~isaaclab.sim.schemas.modify_articulation_root_properties` for more information. + + .. note:: + If the values are None, they are not modified. This is useful when you want to set only a subset of + the properties and leave the rest as-is. + + .. _PhysxArticulationAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_articulation_a_p_i.html + """ + + # PhysX articulation-root fields below live under the ``PhysxArticulationAPI`` schema's + # ``physxArticulation:*`` namespace. Base ``articulation_enabled`` remains routed via + # ``_usd_field_exceptions`` (inherited). + _usd_applied_schema: ClassVar[str | None] = "PhysxArticulationAPI" + _usd_namespace: ClassVar[str | None] = "physxArticulation" + + enabled_self_collisions: bool | None = None + """Whether self-collisions between bodies in the same articulation are enabled. + + The conceptual quantity exists in two USD namespaces simultaneously: + + * ``physxArticulation:enabledSelfCollisions`` (PhysX, ``PhysxArticulationAPI``) + * ``newton:selfCollisionEnabled`` (Newton-native, on a future ``NewtonArticulationRootAPI``) + + Newton's resolver checks the native ``newton:*`` attribute first and falls back + to the PhysX namespace. Both backends honor the field end-to-end. + + Because the conceptual quantity has a dedicated USD attribute in each backend's + namespace, this field is placed on the **PhysX subclass** (one cfg per namespace). + A future ``NewtonArticulationRootPropertiesCfg`` will carry the same field over the + ``newton:*`` namespace. + """ + + solver_position_iteration_count: int | None = None + """Solver position iteration counts for the body.""" + + solver_velocity_iteration_count: int | None = None + """Solver velocity iteration counts for the body.""" + + sleep_threshold: float | None = None + """Mass-normalized kinetic energy threshold below which an actor may go to sleep.""" + + stabilization_threshold: float | None = None + """The mass-normalized kinetic energy threshold below which an articulation may participate in stabilization.""" + + +@configclass +class ArticulationRootPropertiesCfg(PhysxArticulationRootPropertiesCfg): + """Deprecated: use :class:`PhysxArticulationRootPropertiesCfg` or the solver-common base class. + + Use :class:`PhysxArticulationRootPropertiesCfg` for PhysX-specific properties or + :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` for solver-common properties only. + + .. deprecated:: 4.6.24 + ``ArticulationRootPropertiesCfg`` has been split into + :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` (solver-common + ``fix_root_link`` and the PhysX-namespaced but IL-Newton-consumed + ``articulation_enabled``) and + :class:`PhysxArticulationRootPropertiesCfg` (PhysX-specific + self-collisions, TGS solver iter / sleep / stabilization thresholds) + and relocated to :mod:`isaaclab_physx.sim.schemas`. This alias preserves + backwards compatibility and is scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'ArticulationRootPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxArticulationRootPropertiesCfg' for PhysX properties, or" + " 'isaaclab.sim.schemas.ArticulationRootBaseCfg' for solver-common properties only.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + +@configclass +class CollisionPropertiesCfg(PhysxCollisionPropertiesCfg): + """Deprecated: use :class:`PhysxCollisionPropertiesCfg` or :class:`~isaaclab.sim.schemas.CollisionBaseCfg`. + + .. deprecated:: 4.6.23 + ``CollisionPropertiesCfg`` has been split into + :class:`~isaaclab.sim.schemas.CollisionBaseCfg` (solver-common) and + :class:`PhysxCollisionPropertiesCfg` (PhysX-specific) and relocated to + :mod:`isaaclab_physx.sim.schemas`. This alias preserves backwards compatibility and is + scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'CollisionPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxCollisionPropertiesCfg' for PhysX properties, or" + " 'isaaclab.sim.schemas.CollisionBaseCfg' for solver-common properties only.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + +@configclass +class PhysxConvexHullPropertiesCfg(MeshCollisionBaseCfg): + """PhysX convex-hull cooking properties for a mesh collider. + + Extends :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg` with the + ``PhysxConvexHullCollisionAPI`` schema's tuning fields. The ``convexHull`` token is + written to ``physics:approximation``; the cooking schema is applied only when at + least one tuning field is set (consistent with the other consumption-gated writers). + + See :meth:`~isaaclab.sim.schemas.modify_mesh_collision_properties` for more information. + + Original PhysX Documentation: + https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_convex_hull_collision_a_p_i.html + """ + + _usd_applied_schema: ClassVar[str | None] = "PhysxConvexHullCollisionAPI" + _usd_namespace: ClassVar[str | None] = "physxConvexHullCollision" + + mesh_approximation_name: str = "convexHull" + """Name of mesh collision approximation method. Default: "convexHull".""" + + hull_vertex_limit: int | None = None + """Convex hull vertex limit used for convex hull cooking. + + Defaults to 64. + """ + min_thickness: float | None = None + """Convex hull min thickness. + + Range: [0, inf). Units are distance. Default value is 0.001. + """ + + +@configclass +class PhysxConvexDecompositionPropertiesCfg(MeshCollisionBaseCfg): + """PhysX convex-decomposition cooking properties for a mesh collider. + + See :meth:`~isaaclab.sim.schemas.modify_mesh_collision_properties` for more information. + + Original PhysX Documentation: + https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_convex_decomposition_collision_a_p_i.html + """ + + _usd_applied_schema: ClassVar[str | None] = "PhysxConvexDecompositionCollisionAPI" + _usd_namespace: ClassVar[str | None] = "physxConvexDecompositionCollision" + + mesh_approximation_name: str = "convexDecomposition" + """Name of mesh collision approximation method. Default: "convexDecomposition".""" + + hull_vertex_limit: int | None = None + """Convex hull vertex limit used for convex hull cooking. + + Defaults to 64. + """ + max_convex_hulls: int | None = None + """Maximum of convex hulls created during convex decomposition. + Default value is 32. + """ + min_thickness: float | None = None + """Convex hull min thickness. + + Range: [0, inf). Units are distance. Default value is 0.001. + """ + voxel_resolution: int | None = None + """Voxel resolution used for convex decomposition. + + Defaults to 500,000 voxels. + """ + error_percentage: float | None = None + """Convex decomposition error percentage parameter. + + Defaults to 10 percent. Units are percent. + """ + shrink_wrap: bool | None = None + """Attempts to adjust the convex hull points so that they are projected onto the surface of the original graphics + mesh. + + Defaults to False. + """ + + +@configclass +class PhysxTriangleMeshPropertiesCfg(MeshCollisionBaseCfg): + """PhysX triangle-mesh cooking properties for a mesh collider. + + Triangle-mesh colliders are PhysX-only. + + See :meth:`~isaaclab.sim.schemas.modify_mesh_collision_properties` for more information. + + Original PhysX Documentation: + https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_triangle_mesh_collision_a_p_i.html + """ + + _usd_applied_schema: ClassVar[str | None] = "PhysxTriangleMeshCollisionAPI" + _usd_namespace: ClassVar[str | None] = "physxTriangleMeshCollision" + + mesh_approximation_name: str = "none" + """Name of mesh collision approximation method. Default: "none" (uses triangle mesh).""" + + weld_tolerance: float | None = None + """Mesh weld tolerance, controls the distance at which vertices are welded. + + Default -inf will autocompute the welding tolerance based on the mesh size. Zero value will disable welding. + Range: [0, inf) Units: distance + """ + + +@configclass +class PhysxTriangleMeshSimplificationPropertiesCfg(MeshCollisionBaseCfg): + """PhysX triangle-mesh-simplification cooking properties for a mesh collider. + + See :meth:`~isaaclab.sim.schemas.modify_mesh_collision_properties` for more information. + + Original PhysX Documentation: + https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_triangle_mesh_simplification_collision_a_p_i.html + """ + + _usd_applied_schema: ClassVar[str | None] = "PhysxTriangleMeshSimplificationCollisionAPI" + _usd_namespace: ClassVar[str | None] = "physxTriangleMeshSimplificationCollision" + + mesh_approximation_name: str = "meshSimplification" + """Name of mesh collision approximation method. Default: "meshSimplification".""" + + simplification_metric: float | None = None + """Mesh simplification accuracy. + + Defaults to 0.55. + """ + weld_tolerance: float | None = None + """Mesh weld tolerance, controls the distance at which vertices are welded. + + Default -inf will autocompute the welding tolerance based on the mesh size. Zero value will disable welding. + Range: [0, inf) Units: distance + """ + + +@configclass +class PhysxSDFMeshPropertiesCfg(MeshCollisionBaseCfg): + """PhysX SDF-mesh cooking properties for a mesh collider. + + SDF-mesh colliders are PhysX-only. + + See :meth:`~isaaclab.sim.schemas.modify_mesh_collision_properties` for more information. + + Original PhysX documentation: + https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_s_d_f_mesh_collision_a_p_i.html + + More details and steps for optimizing SDF results can be found here: + https://nvidia-omniverse.github.io/PhysX/physx/5.2.1/docs/RigidBodyCollision.html#dynamic-triangle-meshes-with-sdfs + """ + + _usd_applied_schema: ClassVar[str | None] = "PhysxSDFMeshCollisionAPI" + _usd_namespace: ClassVar[str | None] = "physxSDFMeshCollision" + + mesh_approximation_name: str = "sdf" + """Name of mesh collision approximation method. Default: "sdf".""" + + sdf_margin: float | None = None + """Margin to increase the size of the SDF relative to the bounding box diagonal length of the mesh. + + A sdf margin value of 0.01 means the sdf boundary will be enlarged in any direction by 1% of the mesh's bounding + box diagonal length. Representing the margin relative to the bounding box diagonal length ensures that it is scale + independent. Margins allow for precise distance queries in a region slightly outside of the mesh's bounding box. + + Default value is 0.01. + Range: [0, inf) Units: dimensionless + """ + sdf_narrow_band_thickness: float | None = None + """Size of the narrow band around the mesh surface where high resolution SDF samples are available. + + Outside of the narrow band, only low resolution samples are stored. Representing the narrow band thickness as a + fraction of the mesh's bounding box diagonal length ensures that it is scale independent. A value of 0.01 is + usually large enough. The smaller the narrow band thickness, the smaller the memory consumption of the sparse SDF. + + Default value is 0.01. + Range: [0, 1] Units: dimensionless + """ + sdf_resolution: int | None = None + """The spacing of the uniformly sampled SDF is equal to the largest AABB extent of the mesh, + divided by the resolution. + + Choose the lowest possible resolution that provides acceptable performance; very high resolution results in large + memory consumption, and slower cooking and simulation performance. + + Default value is 256. + Range: (1, inf) + """ + sdf_subgrid_resolution: int | None = None + """A positive subgrid resolution enables sparsity on signed-distance-fields (SDF) while a value of 0 leads to the + usage of a dense SDF. + + A value in the range of 4 to 8 is a reasonable compromise between block size and the overhead introduced by block + addressing. The smaller a block, the more memory is spent on the address table. The bigger a block, the less + precisely the sparse SDF can adapt to the mesh's surface. In most cases sparsity reduces the memory consumption of + a SDF significantly. + + Default value is 6. + Range: [0, inf) + """ + + +@configclass +class MeshCollisionPropertiesCfg(MeshCollisionBaseCfg): + """Deprecated: use :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg`. + + .. deprecated:: 4.6.25 + ``MeshCollisionPropertiesCfg`` was the flat (non-leaf) base of the legacy + mesh-collision cfg family. It has been renamed to + :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg` to match the rest of the + consumption-gated split. This alias preserves backwards compatibility and is + scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'MeshCollisionPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab.sim.schemas.MeshCollisionBaseCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + +@configclass +class ConvexHullPropertiesCfg(PhysxConvexHullPropertiesCfg): + """Deprecated: use :class:`PhysxConvexHullPropertiesCfg`. + + .. deprecated:: 4.6.25 + Renamed and relocated. This alias preserves backwards compatibility and is + scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'ConvexHullPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxConvexHullPropertiesCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + +@configclass +class ConvexDecompositionPropertiesCfg(PhysxConvexDecompositionPropertiesCfg): + """Deprecated: use :class:`PhysxConvexDecompositionPropertiesCfg`. + + .. deprecated:: 4.6.25 + Renamed and relocated. This alias preserves backwards compatibility and is + scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'ConvexDecompositionPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxConvexDecompositionPropertiesCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + +@configclass +class TriangleMeshPropertiesCfg(PhysxTriangleMeshPropertiesCfg): + """Deprecated: use :class:`PhysxTriangleMeshPropertiesCfg`. + + .. deprecated:: 4.6.25 + Renamed and relocated. This alias preserves backwards compatibility and is + scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'TriangleMeshPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxTriangleMeshPropertiesCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + +@configclass +class TriangleMeshSimplificationPropertiesCfg(PhysxTriangleMeshSimplificationPropertiesCfg): + """Deprecated: use :class:`PhysxTriangleMeshSimplificationPropertiesCfg`. + + .. deprecated:: 4.6.25 + Renamed and relocated. This alias preserves backwards compatibility and is + scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'TriangleMeshSimplificationPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxTriangleMeshSimplificationPropertiesCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + +@configclass +class SDFMeshPropertiesCfg(PhysxSDFMeshPropertiesCfg): + """Deprecated: use :class:`PhysxSDFMeshPropertiesCfg`. + + .. deprecated:: 4.6.25 + Renamed and relocated. This alias preserves backwards compatibility and is + scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'SDFMeshPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxSDFMeshPropertiesCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + +@configclass +class PhysxFixedTendonPropertiesCfg: + """PhysX fixed-tendon properties for an articulation. + + Tendons are a PhysX-only feature -- Newton has no tendon system -- so this class + is a pure data carrier that is consumed by the PhysX-specific writer + :func:`~isaaclab.sim.schemas.modify_fixed_tendon_properties`. The writer authors + the multi-instance ``PhysxTendonAxisRootAPI`` schema; this cfg class declares no + metadata-driven writer plumbing of its own. + + See :func:`~isaaclab.sim.schemas.modify_fixed_tendon_properties` for more information. + + .. note:: + If the values are None, they are not modified. This is useful when you want to set only a subset of + the properties and leave the rest as-is. + """ + + tendon_enabled: bool | None = None + """Whether to enable or disable the tendon.""" + + stiffness: float | None = None + """Spring stiffness term acting on the tendon's length.""" + + damping: float | None = None + """The damping term acting on both the tendon length and the tendon-length limits.""" + + limit_stiffness: float | None = None + """Limit stiffness term acting on the tendon's length limits.""" + + offset: float | None = None + """Length offset term for the tendon. + + It defines an amount to be added to the accumulated length computed for the tendon. This allows the application + to actuate the tendon by shortening or lengthening it. + """ + + rest_length: float | None = None + """Spring rest length of the tendon.""" + + +@configclass +class FixedTendonPropertiesCfg(PhysxFixedTendonPropertiesCfg): + """Deprecated: use :class:`PhysxFixedTendonPropertiesCfg`. + + .. deprecated:: 4.6.x + ``FixedTendonPropertiesCfg`` was relocated to + :mod:`isaaclab_physx.sim.schemas` and renamed to + :class:`PhysxFixedTendonPropertiesCfg`. The legacy name remains as a + deprecation alias and is scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'FixedTendonPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + + +@configclass +class PhysxSpatialTendonPropertiesCfg: + """PhysX spatial-tendon properties for an articulation. + + Tendons are a PhysX-only feature -- Newton has no tendon system -- so this class + is a pure data carrier that is consumed by the PhysX-specific writer + :func:`~isaaclab.sim.schemas.modify_spatial_tendon_properties`. The writer authors + the multi-instance ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` + schemas; this cfg class declares no metadata-driven writer plumbing of its own. + + See :func:`~isaaclab.sim.schemas.modify_spatial_tendon_properties` for more information. + + .. note:: + If the values are None, they are not modified. This is useful when you want to set only a subset of + the properties and leave the rest as-is. + """ + + tendon_enabled: bool | None = None + """Whether to enable or disable the tendon.""" + + stiffness: float | None = None + """Spring stiffness term acting on the tendon's length.""" + + damping: float | None = None + """The damping term acting on both the tendon length and the tendon-length limits.""" + + limit_stiffness: float | None = None + """Limit stiffness term acting on the tendon's length limits.""" + + offset: float | None = None + """Length offset term for the tendon. + + It defines an amount to be added to the accumulated length computed for the tendon. This allows the application + to actuate the tendon by shortening or lengthening it. + """ + + +@configclass +class SpatialTendonPropertiesCfg(PhysxSpatialTendonPropertiesCfg): + """Deprecated: use :class:`PhysxSpatialTendonPropertiesCfg`. + + .. deprecated:: 4.6.x + ``SpatialTendonPropertiesCfg`` was relocated to + :mod:`isaaclab_physx.sim.schemas` and renamed to + :class:`PhysxSpatialTendonPropertiesCfg`. The legacy name remains as a + deprecation alias and is scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'SpatialTendonPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxSpatialTendonPropertiesCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi index 6e8a48b2f118..1a3e833d61d9 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi @@ -6,11 +6,15 @@ __all__ = [ "spawn_deformable_body_material", "DeformableBodyMaterialCfg", + "PhysxRigidBodyMaterialCfg", + "RigidBodyMaterialCfg", "SurfaceDeformableBodyMaterialCfg", ] from .physics_materials import spawn_deformable_body_material from .physics_materials_cfg import ( DeformableBodyMaterialCfg, + PhysxRigidBodyMaterialCfg, + RigidBodyMaterialCfg, SurfaceDeformableBodyMaterialCfg, ) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py index 35b066fa8736..2c8121a5cbee 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py @@ -6,9 +6,12 @@ from __future__ import annotations import dataclasses +import warnings from collections.abc import Callable +from typing import ClassVar, Literal from isaaclab.sim.spawners.materials import PhysicsMaterialCfg +from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg from isaaclab.utils import configclass @@ -120,3 +123,83 @@ class SurfaceDeformableBodyMaterialCfg(DeformableBodyMaterialCfg, OmniPhysicsSur "physxDeformableBody": [field.name for field in dataclasses.fields(PhysXDeformableMaterialCfg)], } """Extend DeformableBodyMaterialCfg properties under each prefix.""" + + +@configclass +class PhysxRigidBodyMaterialCfg(RigidBodyMaterialBaseCfg): + """PhysX-specific physics-material parameters for rigid bodies. + + Extends :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` with the + `PhysxMaterialAPI`_ schema fields: compliant-contact spring (stiffness/damping) and the + friction/restitution combine-mode tokens. None of these fields have a Newton consumer + today; they are PhysX-engine-only knobs. + + See :meth:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material` for more information. + + .. _PhysxMaterialAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_material_a_p_i.html + """ + + # -- Class metadata (not dataclass fields) -- + # USD applied schema written when at least one PhysX-namespaced field is set. + _usd_applied_schema: ClassVar[str | None] = "PhysxMaterialAPI" + # Prim attribute namespace for PhysX-specific fields. + _usd_namespace: ClassVar[str | None] = "physxMaterial" + + compliant_contact_stiffness: float | None = None + """Spring stiffness for a compliant contact model using implicit springs. + + A higher stiffness results in behavior closer to a rigid contact. The compliant contact model + is only enabled if the stiffness is larger than 0. PhysX-only; not consumed by Newton. + """ + + compliant_contact_damping: float | None = None + """Damping coefficient for a compliant contact model using implicit springs. + + Irrelevant if compliant contacts are disabled when :attr:`compliant_contact_stiffness` is set + to zero and rigid contacts are active. PhysX-only; not consumed by Newton. + """ + + friction_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """Determines the way friction will be combined during collisions. + + .. attention:: + + When two physics materials with different combine modes collide, the combine mode with + the higher priority will be used. The priority order is provided `here + `__. + """ + + restitution_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """Determines the way restitution coefficient will be combined during collisions. + + .. attention:: + + When two physics materials with different combine modes collide, the combine mode with + the higher priority will be used. The priority order is provided `here + `__. + """ + + +@configclass +class RigidBodyMaterialCfg(PhysxRigidBodyMaterialCfg): + """Deprecated: use :class:`PhysxRigidBodyMaterialCfg` or + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`. + + .. deprecated:: 4.6.22 + ``RigidBodyMaterialCfg`` has been split into + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` (solver-common) and + :class:`PhysxRigidBodyMaterialCfg` (PhysX-specific) and relocated to + :mod:`isaaclab_physx.sim.spawners.materials`. This alias preserves backwards compatibility + and is scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'RigidBodyMaterialCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg' for PhysX" + " properties, or 'isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg' for" + " solver-common properties only.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index 3687dae5961d..508a10f27c27 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -92,7 +92,7 @@ def generate_articulation_cfg( # we set 80.0 default for max force because default in USD is 10e10 which makes testing annoying. spawn=sim_utils.UsdFileCfg( usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/SimpleArticulation/revolute_articulation.usd", - joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_effort=80.0, max_velocity=5.0), + joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_force=80.0, max_joint_velocity=5.0), ), actuators={ "joint": ImplicitActuatorCfg( @@ -116,7 +116,7 @@ def generate_articulation_cfg( articulation_cfg = ArticulationCfg( spawn=sim_utils.UsdFileCfg( usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/SimpleArticulation/revolute_articulation.usd", - joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_effort=80.0, max_velocity=5.0), + joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_force=80.0, max_joint_velocity=5.0), ), actuators={ "joint": IdealPDActuatorCfg( @@ -1359,7 +1359,7 @@ def test_setting_velocity_limit_implicit(sim, num_articulations, device, vel_lim # Case 3: velocity limit sim is not set but velocity limit is set # For backwards compatibility, we do not set velocity limit to simulation # Thus, both default to USD default value. - limit = articulation_cfg.spawn.joint_drive_props.max_velocity + limit = articulation_cfg.spawn.joint_drive_props.max_joint_velocity else: # Case 4: only velocity limit sim is set # In this case, the velocity limit is set to the USD value @@ -1418,7 +1418,7 @@ def test_setting_velocity_limit_explicit(sim, num_articulations, device, vel_lim if vel_limit_sim is not None: limit = vel_limit_sim else: - limit = articulation_cfg.spawn.joint_drive_props.max_velocity + limit = articulation_cfg.spawn.joint_drive_props.max_joint_velocity # check physx is set to expected value expected_vel_limit = torch.full_like(physx_vel_limit, limit) torch.testing.assert_close(physx_vel_limit, expected_vel_limit) @@ -1466,7 +1466,7 @@ def test_setting_effort_limit_implicit(sim, num_articulations, device, effort_li # decide the limit based on what is set if effort_limit_sim is None and effort_limit is None: - limit = articulation_cfg.spawn.joint_drive_props.max_effort + limit = articulation_cfg.spawn.joint_drive_props.max_force elif effort_limit_sim is not None and effort_limit is None: limit = effort_limit_sim elif effort_limit_sim is None and effort_limit is not None: From 238817880733b801c7666554bcba5e03da069b2a Mon Sep 17 00:00:00 2001 From: Yuchen Deng Date: Thu, 7 May 2026 13:45:44 -0700 Subject: [PATCH 009/133] Updates docs for using nurec background in locomanipulation sdg (#5301) Updates docs for using nurec background in locomanipulation sdg ## 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) - [x] 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: Kelly Guo --- .../humanoids_imitation.rst | 143 ++++++++++++++---- .../locomanipulation_sdg/generate_data.py | 4 +- .../locomanipulation_sdg/scene_utils.py | 4 +- 3 files changed, 116 insertions(+), 35 deletions(-) diff --git a/docs/source/overview/imitation-learning/humanoids_imitation.rst b/docs/source/overview/imitation-learning/humanoids_imitation.rst index ae9989ddd8de..68fc22e75f9b 100644 --- a/docs/source/overview/imitation-learning/humanoids_imitation.rst +++ b/docs/source/overview/imitation-learning/humanoids_imitation.rst @@ -107,7 +107,7 @@ You can replay the collected demonstrations by running the following command: --dataset_file ./datasets/dataset_gr1.hdf5 .. note:: - Non-determinism may be observed during replay as physics in IsaacLab are not determimnistically reproducible when using ``env.reset``. + Non-determinism may be observed during replay as physics in IsaacLab are not deterministically reproducible when using ``env.reset``. Annotate the demonstrations @@ -405,7 +405,7 @@ The robot picks up an object at the initial location (point A) and places it at AGILE is an officially supported humanoid control training pipeline that leverages the manager based environment in Isaac Lab. It will also be seamlessly integrated with other evaluation and deployment tools across Isaac products. This allows teams to rely on a single, maintained stack covering all necessary infrastructure and tooling for policy training, with easy export to real-world deployment. The AGILE repository contains - updated pre-trained policies with separate upper and lower body policies for flexibtility. They have been verified in the real world and can be + updated pre-trained policies with separate upper and lower body policies for flexibility. They have been verified in the real world and can be directly deployed. Users can also train their own locomotion or whole-body control policies using the AGILE framework. .. _generate-the-manipulation-dataset: @@ -531,6 +531,8 @@ Visualize the trained policy performance: * Behavior Cloning (BC) policy success is typically 75-85% (evaluated on 50 rollouts) when trained on 1000 generated demonstrations for 2000 epochs (default), depending on demonstration quality. Training takes approximately 40 minutes on a RTX ADA 6000. * **Recommendation:** Train for 2000 epochs with 1000 generated demonstrations, and **evaluate multiple checkpoints saved between the 1000th and 2000th epochs** to select the best-performing policy. Testing various epochs is essential for finding optimal performance. +.. _generate-the-dataset-with-manipulation-and-point-to-point-navigation: + Generate the dataset with manipulation and point-to-point navigation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -580,7 +582,7 @@ To generate the locomanipulation dataset, use the following command: The key parameters for locomanipulation dataset generation are: * ``--lift_step 60``: Number of steps for the lifting phase of the manipulation task. This should mark the point immediately after the robot has grasped the object. -* ``--navigate_step 130``: Number of steps for the navigation phase between locations. This should make the point where the robot has lifted the object and is ready to walk. +* ``--navigate_step 130``: Number of steps for the navigation phase between locations. This should mark the point where the robot has lifted the object and is ready to walk. * ``--output_file``: Name of the output dataset file .. note:: @@ -600,6 +602,8 @@ This process creates a dataset where the robot performs the manipulation task at The data generated from this locomanipulation pipeline can also be used to finetune an imitation learning policy using GR00T N1.5. The following steps describe how to install GR00T, convert the dataset to LeRobot format, finetune the policy, and run rollouts in Isaac Lab. +.. _finetune-groot-n15-for-locomanipulation: + Finetune GR00T N1.5 policy for locomanipulation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -699,37 +703,99 @@ Optional arguments include ``--randomize_placement`` and ``--policy_quat_format The policy shown above uses the camera image, hand poses, hand joint positions, object pose, and base goal pose as inputs. The output of the model is the target base velocity, hand poses, and hand joint positions for the next several timesteps. -Use NuRec Background in Locomanipulation SDG -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Integrating 3D Gaussian Splatting into SDG +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -**Prerequisites:** Generate a manipulation dataset or download a pre-recorded annotated dataset from :ref:`Generate the manipulation dataset `. +This section extends +:ref:`locomanipulation SDG ` +by replacing the synthetic background with a 3D Gaussian Splatting (NuRec) scene. As in the +base pipeline, the workflow takes a manipulation dataset as input and produces a combined +navigation and manipulation dataset as an HDF5 file — but here the robot navigates and +manipulates objects inside a neurally-rendered environment, and an ego-centric camera +captures the result, producing more realistic training data than a purely synthetic scene. +NVIDIA Isaac Sim renders 3DGS models stored as USD assets; see +`Neural Volume Rendering `__ +for details. -The `NuRec assets `__ -are neural volumes reconstructed from real-world captures. When integrated into the locomanipulation SDG workflow, these -assets allow you to generate synthetic data in photorealistic environments that mirror real-world. +.. note:: -Custom NuRec Asset Requirements -""""""""""""""""""""""""""""""" + This section focuses on data generation with a 3DGS background. To train a policy on the + generated data, see :ref:`Finetune GR00T N1.5 policy for locomanipulation `. -To load a custom USD asset, ensure it meets the following specifications: +.. note:: -- Neural Rendering: Include neural reconstruction for rendering. -- Navigation: Include a pre-computed occupancy map for path planning and navigation. You can use the `Occupancy Map Generator `_ to generate the occupancy map. -- Orientation: Transform the asset so that the ground aligns with the z=0 plane. -- Collision Mesh (optional): If a collision mesh is included, set it to invisible. + The locomanipulation SDG pipeline currently runs a single environment. Parallel environment + support is not yet available for this workflow. -Using Pre-constructed Assets -"""""""""""""""""""""""""""" +Setup: downloading example assets +""""""""""""""""""""""""""""""""" + +We provide a sample asset, ``hand_hold-voyager-babyboom``, on +`Hugging Face `__. -Pre-constructed assets are available via the `PhysicalAI Robotics NuRec `__ -dataset. Some of them are captured from a humanoid-viewpoint to match the camera view of the humanoid robot. +Log in to Hugging Face: -For example, when using the asset ``hand_hold-voyager-babyboom``, the relevant files are: +.. code:: bash -- `stage.usdz `__: a USDZ archive that bundles 3D Gaussian splatting (``volume.nurec``), a collision mesh (``mesh.usd``), etc. -- `occupancy_map.yaml `__ and `occupancy_map.png `__: occupancy map for path planning and navigation. + hf auth login --token -Download the files and place them under ````, then run the following command to generate a new dataset with background: +Download the required USDZ stage files and occupancy maps: + +.. code:: bash + + hf download nvidia/PhysicalAI-Robotics-NuRec \ + hand_hold-voyager-babyboom/stage_volume.usdz \ + hand_hold-voyager-babyboom/stage_particle.usdz \ + hand_hold-voyager-babyboom/occupancy_map.png \ + hand_hold-voyager-babyboom/occupancy_map.yaml \ + --repo-type dataset \ + --local-dir + +The sample includes both a volume-based USD (``stage_volume.usdz``) and a particle-field USD +(``stage_particle.usdz``). Either can be used as the background asset. + +Asset requirements +"""""""""""""""""" + +If you are using custom 3D Gaussian assets, ensure they meet these specifications to be +compatible with the SDG pipeline: + +- The scene has sufficient free space (e.g. 5m x 5m) for asset placement and robot navigation. +- The ground surface is aligned with the z=0 plane, as the pipeline assumes this elevation for + object placement. +- An occupancy map is required for path planning. + + - If your scene was reconstructed using the `Stereo Workflow `__, + the occupancy map is generated via ``nvblox``. + - If your background includes a mesh, use the `Occupancy Map Generator `__ + to create a map via physical simulation. + +Generating the dataset +"""""""""""""""""""""" + +Before proceeding, ensure you have generated a manipulation dataset or downloaded the sample +dataset provided in the +:ref:`Generate the manipulation dataset ` section. + +Once you have gathered: + +- A manipulation dataset +- A background USD asset +- A matched occupancy map + +you can run the generation command. At runtime, the script adds a ground plane at ``z=0`` to +the scene. It then proceeds through four stages: + +1. **Pick**: The robot picks up an object at the start location by replaying the manipulation + trajectory. ``--lift_step`` marks the end of this stage (immediately after grasp). +2. **Navigate**: The robot travels to the target location using occupancy-map path planning and + its locomotion policy. ``--navigate_step`` marks the end of this stage (when the robot is in + place to release the object). +3. **Place**: The robot places the object at the target location, completing the trajectory. +4. **Record**: Joint states, poses, and the ego-centric video are saved to the HDF5 file + specified by ``--output_file``. + +Run the generation command: .. code:: bash @@ -741,36 +807,49 @@ Download the files and place them under ````, then run the fo --num_runs 1 \ --lift_step 60 \ --navigate_step 130 \ - --output_file /generated_dataset_g1_locomanipulation_sdg_with_background.hdf5 \ + --output_file /generated_dataset_g1_locomanipulation_sdg_gaussian_background.hdf5 \ --enable_cameras \ --visualizer kit \ - --background_usd_path /stage.usdz \ + --background_usd_path /stage_particle.usdz \ --background_occupancy_yaml_file /occupancy_map.yaml \ --randomize_placement \ --high_res_video The key parameters are: -- ``--background_usd_path``: Path to the NuRec USD asset. +- ``--background_usd_path``: Path to the 3D Gaussian background USD asset. - ``--background_occupancy_yaml_file``: Path to the occupancy map file. -- ``--high_res_video``: Generate a higher resolution video (540x960) for the ego-centric camera view. -- ``--sensor_camera_view``: Optionally set the Sim GUI viewport to the ``robot_pov_cam`` sensor view. +- ``--high_res_video``: Capture the ego-centric camera at 960×540 instead of the default + 256×160. -On successful task completion, an HDF5 dataset is generated containing camera observations. You can convert -the ego-centric camera view to MP4. +When the run completes successfully, an HDF5 dataset is generated containing camera +observations. You can convert the ego-centric camera view to MP4: .. code:: bash ./isaaclab.sh -p scripts/tools/hdf5_to_mp4.py \ - --input_file /generated_dataset_g1_locomanipulation_sdg_with_background.hdf5 \ + --input_file /generated_dataset_g1_locomanipulation_sdg_gaussian_background.hdf5 \ --output_dir / \ --input_keys robot_pov_cam \ --video_width 960 \ --video_height 540 +Set ``--video_width`` and ``--video_height`` to match the resolution captured during +generation: 960×540 with ``--high_res_video``, or 256×160 without it. + To play the generated MP4 video on Ubuntu, install the following multimedia packages: .. code:: bash sudo apt update sudo apt install libavcodec-extra gstreamer1.0-libav gstreamer1.0-plugins-ugly + + +.. figure:: https://download.isaacsim.omniverse.nvidia.com/isaaclab/images/locomanipulation_sdg_gaussian_background_2x.webp + :width: 100% + :align: center + :alt: locomanipulation SDG with a 3D Gaussian background + :figclass: align-center + +The figure above shows recorded ego-centric camera views in the 3D Gaussian background +when the robot replays the pick and place trajectory and navigates to the target location. diff --git a/scripts/imitation_learning/locomanipulation_sdg/generate_data.py b/scripts/imitation_learning/locomanipulation_sdg/generate_data.py index bbae0689ff65..99014e75ff1d 100644 --- a/scripts/imitation_learning/locomanipulation_sdg/generate_data.py +++ b/scripts/imitation_learning/locomanipulation_sdg/generate_data.py @@ -331,8 +331,8 @@ def project_robot_state_into_env(env: LocomanipulationSDGEnv, input_episode_data object = env.scene["object"] current_object_pose = torch.cat( [ - torch.as_tensor(object.data.root_pos_w[0:1], device=env.device, dtype=torch.float32), - torch.as_tensor(object.data.root_quat_w[0:1], device=env.device, dtype=torch.float32), + torch.as_tensor(object.data.root_pos_w.torch[0:1], device=env.device, dtype=torch.float32), + torch.as_tensor(object.data.root_quat_w.torch[0:1], device=env.device, dtype=torch.float32), ], dim=-1, ) # (1, 7) 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 a0eb00fb4a58..4ba068fc8f56 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py +++ b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py @@ -106,7 +106,9 @@ def _get_xform_view(self) -> FrameView: 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 = FrameView(xform_prim._prim_path, device=xform_prim.device) + # FabricFrameView composes UsdFrameView; the template prim_path lives on the inner USD view. + inner = getattr(xform_prim, "_usd_view", xform_prim) + xform_prim = FrameView(inner._prim_path, device=xform_prim.device) self.scene.extras[self.entity_name] = xform_prim return xform_prim From a7514be502f1d7441db7fa3f5e4727998fa9e440 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 01:17:22 +0000 Subject: [PATCH 010/133] [CI][Auto Version Bump] Compile changelog fragments (workflow_dispatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 4.6.28 → 4.7.0 - isaaclab_newton: 0.5.26 → 0.6.0 - isaaclab_ov: 0.1.3 → 0.1.4 - isaaclab_ovphysx: 0.1.2 → 0.1.3 - isaaclab_physx: 0.5.29 → 0.6.0 - isaaclab_rl: 0.5.1 → 0.5.2 - isaaclab_tasks: 1.5.34 → 1.5.35 - isaaclab_teleop: 0.3.9 → 0.3.10 --- .../antoiner-rename-newton-presets.skip | 1 - .../clone-plan-visualizer-cleanup.minor.rst | 43 --- .../fix-fabric-prepare-for-reuse.rst | 8 - .../changelog.d/leapp_export_integration.rst | 15 -- .../mtrepte-expand_viz_markers-2.skip | 0 .../mtrepte-expand_viz_markers.minor.rst | 5 - .../changelog.d/omniverseclient-pin.rst | 4 - .../changelog.d/pr-5458-merge-develop.rst | 15 -- .../rschmitt_decouple_renderer_camera.rst | 33 --- .../rschmitt_default_cameracfg_renderer.rst | 11 - .../vidur-cfg-exception-table.minor.rst | 27 -- .../vidur-rebalance-cfg-placement.minor.rst | 122 --------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 245 ++++++++++++++++++ .../clone-plan-visualizer-cleanup.minor.rst | 9 - .../mtrepte-expand_viz_markers.skip | 1 - .../mym-newton-manager-abstraction.rst | 14 - .../changelog.d/pr-5458-merge-develop.rst | 13 - .../rschmitt_decouple_rednerer_camera.rst | 4 - .../vidur-feature-usd-proprties-refactor.skip | 0 source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 40 +++ .../changelog.d/pbarejko-open-usd.rst | 7 - .../rschmitt_decouple_renderer_camera.rst | 4 - source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 17 ++ .../changelog.d/pr-5458-merge-develop.rst | 7 - source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 12 + .../clone-plan-visualizer-cleanup.skip | 0 .../fix-fabric-prepare-for-reuse.rst | 12 - .../mtrepte-expand_viz_markers.skip | 2 - .../changelog.d/pr-5458-merge-develop.rst | 16 -- .../rschmitt_decouple_renderer_camera.rst | 4 - .../changelog.d/test-articulation-timeout.rst | 6 - .../vidur-rebalance-cfg-placement.minor.rst | 77 ------ source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 112 ++++++++ .../changelog.d/leapp_export_integration.rst | 5 - source/isaaclab_rl/config/extension.toml | 2 +- source/isaaclab_rl/docs/CHANGELOG.rst | 10 + .../antoiner-rename-newton-presets.rst | 25 -- .../changelog.d/g1-rough-terrain-wip.rst | 11 - .../changelog.d/huidongc-flaky-mark.skip | 0 .../changelog.d/leapp_export_integration.rst | 5 - .../mtrepte-expand_viz_markers.skip | 1 - .../changelog.d/pr-5458-merge-develop.rst | 10 - .../changelog.d/rendering-test-flakiness.skip | 0 .../rwiltz-restore-legacy-teleop.rst | 8 - source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 56 ++++ .../rwiltz-restore-legacy-teleop.rst | 11 - source/isaaclab_teleop/config/extension.toml | 2 +- source/isaaclab_teleop/docs/CHANGELOG.rst | 16 ++ 54 files changed, 516 insertions(+), 544 deletions(-) delete mode 100644 source/isaaclab/changelog.d/antoiner-rename-newton-presets.skip delete mode 100644 source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst delete mode 100644 source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst delete mode 100644 source/isaaclab/changelog.d/leapp_export_integration.rst delete mode 100644 source/isaaclab/changelog.d/mtrepte-expand_viz_markers-2.skip delete mode 100644 source/isaaclab/changelog.d/mtrepte-expand_viz_markers.minor.rst delete mode 100644 source/isaaclab/changelog.d/omniverseclient-pin.rst delete mode 100644 source/isaaclab/changelog.d/pr-5458-merge-develop.rst delete mode 100644 source/isaaclab/changelog.d/rschmitt_decouple_renderer_camera.rst delete mode 100644 source/isaaclab/changelog.d/rschmitt_default_cameracfg_renderer.rst delete mode 100644 source/isaaclab/changelog.d/vidur-cfg-exception-table.minor.rst delete mode 100644 source/isaaclab/changelog.d/vidur-rebalance-cfg-placement.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/clone-plan-visualizer-cleanup.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/mtrepte-expand_viz_markers.skip delete mode 100644 source/isaaclab_newton/changelog.d/mym-newton-manager-abstraction.rst delete mode 100644 source/isaaclab_newton/changelog.d/pr-5458-merge-develop.rst delete mode 100644 source/isaaclab_newton/changelog.d/rschmitt_decouple_rednerer_camera.rst delete mode 100644 source/isaaclab_newton/changelog.d/vidur-feature-usd-proprties-refactor.skip delete mode 100644 source/isaaclab_ov/changelog.d/pbarejko-open-usd.rst delete mode 100644 source/isaaclab_ov/changelog.d/rschmitt_decouple_renderer_camera.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/pr-5458-merge-develop.rst delete mode 100644 source/isaaclab_physx/changelog.d/clone-plan-visualizer-cleanup.skip delete mode 100644 source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst delete mode 100644 source/isaaclab_physx/changelog.d/mtrepte-expand_viz_markers.skip delete mode 100644 source/isaaclab_physx/changelog.d/pr-5458-merge-develop.rst delete mode 100644 source/isaaclab_physx/changelog.d/rschmitt_decouple_renderer_camera.rst delete mode 100644 source/isaaclab_physx/changelog.d/test-articulation-timeout.rst delete mode 100644 source/isaaclab_physx/changelog.d/vidur-rebalance-cfg-placement.minor.rst delete mode 100644 source/isaaclab_rl/changelog.d/leapp_export_integration.rst delete mode 100644 source/isaaclab_tasks/changelog.d/antoiner-rename-newton-presets.rst delete mode 100644 source/isaaclab_tasks/changelog.d/g1-rough-terrain-wip.rst delete mode 100644 source/isaaclab_tasks/changelog.d/huidongc-flaky-mark.skip delete mode 100644 source/isaaclab_tasks/changelog.d/leapp_export_integration.rst delete mode 100644 source/isaaclab_tasks/changelog.d/mtrepte-expand_viz_markers.skip delete mode 100644 source/isaaclab_tasks/changelog.d/pr-5458-merge-develop.rst delete mode 100644 source/isaaclab_tasks/changelog.d/rendering-test-flakiness.skip delete mode 100644 source/isaaclab_tasks/changelog.d/rwiltz-restore-legacy-teleop.rst delete mode 100644 source/isaaclab_teleop/changelog.d/rwiltz-restore-legacy-teleop.rst diff --git a/source/isaaclab/changelog.d/antoiner-rename-newton-presets.skip b/source/isaaclab/changelog.d/antoiner-rename-newton-presets.skip deleted file mode 100644 index e32e76dd5f0e..000000000000 --- a/source/isaaclab/changelog.d/antoiner-rename-newton-presets.skip +++ /dev/null @@ -1 +0,0 @@ -skip diff --git a/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst b/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst deleted file mode 100644 index 8a8a74cb6267..000000000000 --- a/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst +++ /dev/null @@ -1,43 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab.cloner.ClonePlan` frozen dataclass capturing per-group - prototype-to-environment mappings (``dest_template``, ``prototype_paths``, - ``clone_mask``). Lets downstream consumers (scene data providers, mesh samplers) - read prototype geometry once and scatter to environments via the per-group mask - instead of walking per-env USD paths. -* Added :meth:`~isaaclab.sim.SimulationContext.get_clone_plans` and - :meth:`~isaaclab.sim.SimulationContext.set_clone_plans` for publishing and - consuming the cloner's per-group plan map. -* Added :attr:`~isaaclab.scene.InteractiveScene.clone_plans` property (forwards to - :meth:`~isaaclab.sim.SimulationContext.get_clone_plans`) so consumers holding a - scene reference can read the published plans without going through the sim - context. - -Changed -^^^^^^^ - -* **Breaking:** :func:`~isaaclab.cloner.clone_from_template` now returns - ``dict[str, ClonePlan]`` instead of ``None``. Bind the result and publish it - through :meth:`~isaaclab.sim.SimulationContext.set_clone_plans` if downstream - consumers (e.g. the PhysX scene data provider's Newton-visualizer build path) - need to read the plan. - -Removed -^^^^^^^ - -* **Breaking:** Removed - :attr:`~isaaclab.cloner.TemplateCloneCfg.visualizer_clone_fn`, - :func:`~isaaclab.cloner.resolve_visualizer_clone_fn`, and - :class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts`. - Scene data providers now build backend models from the - :class:`~isaaclab.cloner.ClonePlan` map via - :meth:`~isaaclab.sim.SimulationContext.get_clone_plans` instead of receiving a - prebuilt artifact through a clone-time callback. -* **Breaking:** Removed - :meth:`~isaaclab.sim.SimulationContext.get_scene_data_visualizer_prebuilt_artifact`, - :meth:`~isaaclab.sim.SimulationContext.set_scene_data_visualizer_prebuilt_artifact`, - and - :meth:`~isaaclab.sim.SimulationContext.clear_scene_data_visualizer_prebuilt_artifact`. - Use :meth:`~isaaclab.sim.SimulationContext.get_clone_plans` / - :meth:`~isaaclab.sim.SimulationContext.set_clone_plans` instead. diff --git a/source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst b/source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst deleted file mode 100644 index 20a6d385c094..000000000000 --- a/source/isaaclab/changelog.d/fix-fabric-prepare-for-reuse.rst +++ /dev/null @@ -1,8 +0,0 @@ -Changed -^^^^^^^ - -* Updated :class:`~isaaclab.sensors.camera.Camera` to construct its internal - :class:`~isaaclab.sim.views.FrameView` without the now-removed - ``sync_usd_on_fabric_write`` kwarg. USD attributes on camera prims are - no longer kept in sync with Fabric writes; read poses through the view's - getters instead. diff --git a/source/isaaclab/changelog.d/leapp_export_integration.rst b/source/isaaclab/changelog.d/leapp_export_integration.rst deleted file mode 100644 index ea2de5e5d029..000000000000 --- a/source/isaaclab/changelog.d/leapp_export_integration.rst +++ /dev/null @@ -1,15 +0,0 @@ -Added -^^^^^ - -* Added LEAPP export support for manager-based RSL-RL policies, including - export-time observation/action annotation, recurrent actor-state handling, and - deployment through :mod:`scripts.reinforcement_learning.leapp.deploy`. -* Added a Direct workflow LEAPP export tutorial and annotated ANYmal-C example - script showing how to mark policy inputs, outputs, and persistent state with - LEAPP annotations. Direct workflow policies can be exported with - :mod:`scripts.reinforcement_learning.leapp.rsl_rl.export`, but are not yet - supported by :mod:`scripts.reinforcement_learning.leapp.deploy`. -* Added LEAPP deployment documentation describing the exported-policy validation - flow and linking the manager-based and Direct workflow export paths. -* Added LEAPP export annotations, proxy utilities, and deployment environment - support for Isaac Lab assets, sensors, commands, and manager-based environments. diff --git a/source/isaaclab/changelog.d/mtrepte-expand_viz_markers-2.skip b/source/isaaclab/changelog.d/mtrepte-expand_viz_markers-2.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab/changelog.d/mtrepte-expand_viz_markers.minor.rst b/source/isaaclab/changelog.d/mtrepte-expand_viz_markers.minor.rst deleted file mode 100644 index 8975a9178b83..000000000000 --- a/source/isaaclab/changelog.d/mtrepte-expand_viz_markers.minor.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added backend-agnostic :class:`~isaaclab.markers.VisualizationMarkers` support for - marker-capable Kit, Newton, Rerun, and Viser visualizers. diff --git a/source/isaaclab/changelog.d/omniverseclient-pin.rst b/source/isaaclab/changelog.d/omniverseclient-pin.rst deleted file mode 100644 index 832820b64a47..000000000000 --- a/source/isaaclab/changelog.d/omniverseclient-pin.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Pinned ``omniverseclient`` to ``2.71.1.7015``. diff --git a/source/isaaclab/changelog.d/pr-5458-merge-develop.rst b/source/isaaclab/changelog.d/pr-5458-merge-develop.rst deleted file mode 100644 index c94e6b8921a6..000000000000 --- a/source/isaaclab/changelog.d/pr-5458-merge-develop.rst +++ /dev/null @@ -1,15 +0,0 @@ -Changed -^^^^^^^ - -* Changed :func:`~isaaclab.envs.mdp.body_incoming_wrench` to read from - :class:`~isaaclab.sensors.JointWrenchSensor`. Pass - ``sensor_cfg=SceneEntityCfg("joint_wrench", body_names=...)`` instead of an - articulation asset config. - -Removed -^^^^^^^ - -* Removed ``BaseArticulationData.body_incoming_joint_wrench_b``. Add - :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene and read - :attr:`~isaaclab.sensors.JointWrenchSensorData.force` and - :attr:`~isaaclab.sensors.JointWrenchSensorData.torque` instead. diff --git a/source/isaaclab/changelog.d/rschmitt_decouple_renderer_camera.rst b/source/isaaclab/changelog.d/rschmitt_decouple_renderer_camera.rst deleted file mode 100644 index 337b7f55b6a3..000000000000 --- a/source/isaaclab/changelog.d/rschmitt_decouple_renderer_camera.rst +++ /dev/null @@ -1,33 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab.renderers.camera_render_spec.CameraRenderSpec` so render backends - take explicit camera inputs (USD paths, :class:`~isaaclab.sensors.camera.CameraCfg`, device, - counts) instead of the :class:`~isaaclab.sensors.camera.Camera` instance. -* Added :class:`~isaaclab.renderers.render_context.RenderContext` (accessed as - :attr:`~isaaclab.sim.simulation_context.SimulationContext.render_context`) to own one or - more :class:`~isaaclab.renderers.base_renderer.BaseRenderer` instances: configurations that - compare equal under ``==`` and share the same concrete - :class:`~isaaclab.renderers.renderer_cfg.RendererCfg` class reuse a backend; distinct - types (e.g. Isaac RTX and Newton) register separate backends, each with - :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.prepare_stage` the first time a camera - with that configuration initializes. -* Added :meth:`~isaaclab.renderers.render_context.RenderContext.render_into_camera` to run - :meth:`~isaaclab.renderers.render_context.RenderContext.update_transforms` (at most once - per physics step), then :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.render` and - :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.read_output`. -* Added :meth:`~isaaclab.sim.simulation_context.SimulationContext.get_physics_step_count`. - -Changed -^^^^^^^ - -* :class:`~isaaclab.sensors.camera.Camera` obtains a backend via - :meth:`~isaaclab.renderers.render_context.RenderContext.get_renderer` and calls - :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.create_render_data` with - a :class:`~isaaclab.renderers.camera_render_spec.CameraRenderSpec` (no - :class:`~isaaclab.sensors.sensor_base.SensorBase` reference on the public API). -* :class:`~isaaclab.scene.interactive_scene.InteractiveScene` calls - :meth:`~isaaclab.renderers.render_context.RenderContext.update_transforms` once at the start - of :meth:`~isaaclab.scene.interactive_scene.InteractiveScene.update` when - ``lazy_sensor_update`` is false; fetches that render still dedupe the same way via - ``physics_step_count`` in :class:`~isaaclab.renderers.render_context.RenderContext`. diff --git a/source/isaaclab/changelog.d/rschmitt_default_cameracfg_renderer.rst b/source/isaaclab/changelog.d/rschmitt_default_cameracfg_renderer.rst deleted file mode 100644 index e11891e307b5..000000000000 --- a/source/isaaclab/changelog.d/rschmitt_default_cameracfg_renderer.rst +++ /dev/null @@ -1,11 +0,0 @@ -Added -^^^^^ - -* Added :meth:`~isaaclab.utils.backend_utils.get_default_renderer_cfg`. to lazy load the IsaacRtxRendererCfg - -Changed -^^^^^^^ - -* :class:`~isaaclab.sensors.camera.CameraCfg` now defaults its render_cfg to :class:`~isaaclab.renderers.RenderCfg` - :meth:`~isaaclab.utils.backend_utils.get_default_renderer_cfg` is called during __post_init__ to replace - the generic RenderCfg with the default config :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg` diff --git a/source/isaaclab/changelog.d/vidur-cfg-exception-table.minor.rst b/source/isaaclab/changelog.d/vidur-cfg-exception-table.minor.rst deleted file mode 100644 index de2d19065b61..000000000000 --- a/source/isaaclab/changelog.d/vidur-cfg-exception-table.minor.rst +++ /dev/null @@ -1,27 +0,0 @@ -Changed -^^^^^^^ - -* Cleaned up the schema-cfg base classes to no longer carry PhysX namespace metadata. - :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`, - :class:`~isaaclab.sim.schemas.CollisionBaseCfg`, - :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg`, and - :class:`~isaaclab.sim.schemas.JointDriveBaseCfg` now declare ``_usd_namespace = None`` and - ``_usd_applied_schema = None``. Per-field PhysX overrides for fields whose only USD path - today is the ``physx*:*`` namespace (``disable_gravity``, ``contact_offset``, - ``rest_offset``, ``articulation_enabled``, ``max_velocity``) are declared via a new - ``_usd_field_exceptions`` mapping ``applied_schema -> (namespace, {cfg_field: usd_attr})``. - When any listed field is non-None at write time, the writer applies that schema and writes - the attribute under the exception namespace; otherwise the schema is not stamped onto the - prim. PhysX subclasses (:class:`PhysxRigidBodyPropertiesCfg`, - :class:`PhysxCollisionPropertiesCfg`, :class:`PhysxArticulationRootPropertiesCfg`, - :class:`PhysxJointDrivePropertiesCfg`) now self-declare ``_usd_namespace`` and - ``_usd_applied_schema`` for their own fields. Observable behavior on standard inputs is - unchanged. -* Consolidated the per-writer schema-application loop in - :mod:`isaaclab.sim.schemas` into a single shared helper ``_apply_namespaced_schemas``. - ``modify_articulation_root_properties``, ``modify_rigid_body_properties``, - ``modify_collision_properties``, ``modify_joint_drive_properties``, - ``modify_mesh_collision_properties``, and ``spawn_rigid_body_material`` all delegate to the - helper after writing their typed-API ``UsdPhysics`` fields. The canonical exception-table - + main-namespace gating logic now lives in one place instead of being duplicated across - six call sites. diff --git a/source/isaaclab/changelog.d/vidur-rebalance-cfg-placement.minor.rst b/source/isaaclab/changelog.d/vidur-rebalance-cfg-placement.minor.rst deleted file mode 100644 index 72be33772d9d..000000000000 --- a/source/isaaclab/changelog.d/vidur-rebalance-cfg-placement.minor.rst +++ /dev/null @@ -1,122 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`, the solver-common - base class for rigid-body physics materials. Carries the ``UsdPhysics.MaterialAPI`` standard - fields (``static_friction``, ``dynamic_friction``, ``restitution``). The PhysX-specific - compliant-contact and combine-mode fields moved to - :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. -* Added :class:`~isaaclab.sim.schemas.CollisionBaseCfg`, the solver-common base class for - collision properties. Carries :attr:`collision_enabled` (``UsdPhysics.CollisionAPI``) plus - :attr:`contact_offset` and :attr:`rest_offset` whose USD attributes are PhysX-namespaced - but are consumed by Newton's importer via the PhysX bridge resolver - (``import_usd.py:2104, 2111``). -* Added :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg`, the solver-common base class - for articulation root properties (``fix_root_link``, ``articulation_enabled``). -* Added :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg`, the solver-common base class for - mesh collision properties carrying ``mesh_approximation_name`` (writes - ``physics:approximation`` via :class:`UsdPhysics.MeshCollisionAPI`). The class-level - ``_usd_applied_schema`` metadata replaces the deprecated ``usd_api`` / ``physx_api`` - instance-field dispatch. - -Changed -^^^^^^^ - -* Moved the ``max_velocity`` field from :class:`~isaaclab_physx.sim.schemas.PhysxJointDrivePropertiesCfg` - to :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`. The field is the only USD path to set - Newton's ``Model.joint_velocity_limit`` and is consumed by Newton's importer. The USD - attribute written is unchanged (``physxJoint:maxJointVelocity``); existing code using - ``PhysxJointDrivePropertiesCfg(max_velocity=...)`` continues to work because the field - is inherited. -* Moved the ``disable_gravity`` field from :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyPropertiesCfg` - to :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`. PhysX honors per-body via - ``physxRigidBody:disableGravity``; Newton currently honors at scene level (partial), - documented in the field docstring. Existing code using - ``PhysxRigidBodyPropertiesCfg(disable_gravity=...)`` continues to work via inheritance. -* Documented :attr:`~isaaclab.sim.schemas.ArticulationRootPropertiesCfg.articulation_enabled` - and :attr:`~isaaclab.sim.schemas.ArticulationRootPropertiesCfg.enabled_self_collisions` - to lock their placement for the future :class:`ArticulationRootBaseCfg` / - ``PhysxArticulationRootPropertiesCfg`` split: ``articulation_enabled`` stays on the base - (single-namespace USD with verified Newton consumer); ``enabled_self_collisions`` moves - to the PhysX subclass (dual-namespace USD, with a future Newton sibling cfg owning the - ``newton:*`` namespace). -* Changed the defaults of :attr:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg.compliant_contact_stiffness`, - :attr:`compliant_contact_damping`, :attr:`friction_combine_mode`, and - :attr:`restitution_combine_mode` from concrete values (``0.0``, ``0.0``, ``"average"``, - ``"average"``) to ``None``. PhysX engine defaults match the previous concrete values, so - user-observable simulation behavior is unchanged; the difference is that these attributes - are now authored on the prim only when the user explicitly sets them (consistent with the - rest of the consumption-gated cfg layer). -* Relocated :class:`RigidBodyMaterialCfg` to :mod:`isaaclab_physx.sim.spawners.materials` and - split its fields between the new :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` - (UsdPhysics-standard friction/restitution) and - :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` - (PhysX-specific compliant-contact and combine-mode fields). A forwarding shim on - :mod:`isaaclab.sim.spawners.materials` and :mod:`isaaclab.sim` preserves existing imports. -* Refactored :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material` to be - metadata-driven: it reads ``_usd_applied_schema``, ``_usd_namespace``, and - ``_usd_attr_name_map`` from the cfg class and gates ``PhysxMaterialAPI`` application on - whether the user authored at least one PhysX-namespaced field with a non-``None`` value. - Previously, the writer applied ``PhysxMaterialAPI`` unconditionally on every material spawn. -* Relocated :class:`CollisionPropertiesCfg` to :mod:`isaaclab_physx.sim.schemas` and split - its fields between the new :class:`~isaaclab.sim.schemas.CollisionBaseCfg` (solver-common - ``collision_enabled`` plus the PhysX-namespaced but Newton-consumed - ``contact_offset`` / ``rest_offset``) and - :class:`~isaaclab_physx.sim.schemas.PhysxCollisionPropertiesCfg` (PhysX-only - ``torsional_patch_radius`` / ``min_torsional_patch_radius``). A forwarding shim on - :mod:`isaaclab.sim.schemas`, :mod:`isaaclab.sim.schemas.schemas_cfg`, and - :mod:`isaaclab.sim` preserves existing imports. -* Refactored :func:`~isaaclab.sim.schemas.modify_collision_properties` to be metadata-driven - and to gate ``PhysxCollisionAPI`` application on whether the user authored at least one - PhysX-namespaced field with a non-``None`` value. Previously, the writer applied - ``PhysxCollisionAPI`` unconditionally on every collision prim, stamping the schema onto - Newton-targeted prims that only set ``collision_enabled``. -* Relocated :class:`ArticulationRootPropertiesCfg` to :mod:`isaaclab_physx.sim.schemas` and - split its fields between the new :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` - (solver-common ``fix_root_link`` plus the PhysX-namespaced ``articulation_enabled`` which - is consumed by the IL Newton wrapper as a spawn-time guard) and - :class:`~isaaclab_physx.sim.schemas.PhysxArticulationRootPropertiesCfg` - (``enabled_self_collisions`` and PhysX TGS solver iter / sleep / stabilization thresholds). - A forwarding shim on :mod:`isaaclab.sim.schemas`, - :mod:`isaaclab.sim.schemas.schemas_cfg`, and :mod:`isaaclab.sim` preserves existing imports. -* Refactored :func:`~isaaclab.sim.schemas.modify_articulation_root_properties` to be - metadata-driven and to gate ``PhysxArticulationAPI`` application on whether the user - authored at least one PhysX-namespaced field with a non-``None`` value. Previously, the - writer applied ``PhysxArticulationAPI`` unconditionally on every articulation root, - stamping the schema onto Newton-targeted prims that only set ``fix_root_link``. -* Relocated :class:`MeshCollisionPropertiesCfg`, :class:`ConvexHullPropertiesCfg`, - :class:`ConvexDecompositionPropertiesCfg`, :class:`TriangleMeshPropertiesCfg`, - :class:`TriangleMeshSimplificationPropertiesCfg`, and :class:`SDFMeshPropertiesCfg` to - :mod:`isaaclab_physx.sim.schemas`. :class:`BoundingCubePropertiesCfg` and - :class:`BoundingSpherePropertiesCfg` stay in core because they author no PhysX schema. - A forwarding shim preserves existing imports. -* Refactored :func:`~isaaclab.sim.schemas.modify_mesh_collision_properties` to be - metadata-driven. The writer now reads ``_usd_applied_schema`` and ``_usd_namespace`` from - the cfg class instead of consulting instance-level ``usd_api`` / ``physx_api`` fields. - The standard :class:`UsdPhysics.MeshCollisionAPI` is always applied; PhysX cooking - schemas (``PhysxConvexHullCollisionAPI`` etc.) are gated on at least one - PhysX-namespaced tuning field being set. -* Relocated :class:`FixedTendonPropertiesCfg` and :class:`SpatialTendonPropertiesCfg` to - :mod:`isaaclab_physx.sim.schemas` as :class:`PhysxFixedTendonPropertiesCfg` and - :class:`PhysxSpatialTendonPropertiesCfg`. Tendons are a PhysX-only feature; no Newton - equivalent exists. A forwarding shim on :mod:`isaaclab.sim.schemas`, - :mod:`isaaclab.sim.schemas.schemas_cfg`, and :mod:`isaaclab.sim` preserves existing - imports. - -Deprecated -^^^^^^^^^^ - -* Deprecated the ``usd_api`` and ``physx_api`` instance attributes on the mesh-collision - cfg classes in favor of class-level ``_usd_applied_schema`` metadata. Reading these - attributes still works through one minor version but emits a ``DeprecationWarning``. - Scheduled for removal in 5.0. - -Fixed -^^^^^ - -* Fixed :meth:`~isaaclab.sim.schemas.modify_joint_drive_properties` and - :meth:`~isaaclab.sim.schemas.modify_rigid_body_properties` so that ``PhysxJointAPI`` and - ``PhysxRigidBodyAPI`` are applied only when the user authored at least one PhysX-namespaced - field with a non-``None`` value. Previously, schema application was gated on class-level - metadata being defined, which caused Newton-targeted prims to receive PhysX schemas even - when the user only set base ``UsdPhysics``-standard fields. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 95993d71590f..e1620d629745 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.28" +version = "4.7.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 701f53744f84..1bb6a29060fa 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,251 @@ Changelog --------- +4.7.0 (2026-05-08) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added LEAPP export support for manager-based RSL-RL policies, including + export-time observation/action annotation, recurrent actor-state handling, and + deployment through :mod:`scripts.reinforcement_learning.leapp.deploy`. +* Added a Direct workflow LEAPP export tutorial and annotated ANYmal-C example + script showing how to mark policy inputs, outputs, and persistent state with + LEAPP annotations. Direct workflow policies can be exported with + :mod:`scripts.reinforcement_learning.leapp.rsl_rl.export`, but are not yet + supported by :mod:`scripts.reinforcement_learning.leapp.deploy`. +* Added LEAPP deployment documentation describing the exported-policy validation + flow and linking the manager-based and Direct workflow export paths. +* Added LEAPP export annotations, proxy utilities, and deployment environment + support for Isaac Lab assets, sensors, commands, and manager-based environments. +* Added :class:`~isaaclab.renderers.camera_render_spec.CameraRenderSpec` so render backends + take explicit camera inputs (USD paths, :class:`~isaaclab.sensors.camera.CameraCfg`, device, + counts) instead of the :class:`~isaaclab.sensors.camera.Camera` instance. +* Added :class:`~isaaclab.renderers.render_context.RenderContext` (accessed as + :attr:`~isaaclab.sim.simulation_context.SimulationContext.render_context`) to own one or + more :class:`~isaaclab.renderers.base_renderer.BaseRenderer` instances: configurations that + compare equal under ``==`` and share the same concrete + :class:`~isaaclab.renderers.renderer_cfg.RendererCfg` class reuse a backend; distinct + types (e.g. Isaac RTX and Newton) register separate backends, each with + :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.prepare_stage` the first time a camera + with that configuration initializes. +* Added :meth:`~isaaclab.renderers.render_context.RenderContext.render_into_camera` to run + :meth:`~isaaclab.renderers.render_context.RenderContext.update_transforms` (at most once + per physics step), then :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.render` and + :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.read_output`. +* Added :meth:`~isaaclab.sim.simulation_context.SimulationContext.get_physics_step_count`. +* Added :class:`~isaaclab.cloner.ClonePlan` frozen dataclass capturing per-group + prototype-to-environment mappings (``dest_template``, ``prototype_paths``, + ``clone_mask``). Lets downstream consumers (scene data providers, mesh samplers) + read prototype geometry once and scatter to environments via the per-group mask + instead of walking per-env USD paths. +* Added :meth:`~isaaclab.sim.SimulationContext.get_clone_plans` and + :meth:`~isaaclab.sim.SimulationContext.set_clone_plans` for publishing and + consuming the cloner's per-group plan map. +* Added :attr:`~isaaclab.scene.InteractiveScene.clone_plans` property (forwards to + :meth:`~isaaclab.sim.SimulationContext.get_clone_plans`) so consumers holding a + scene reference can read the published plans without going through the sim + context. +* Added backend-agnostic :class:`~isaaclab.markers.VisualizationMarkers` support for + marker-capable Kit, Newton, Rerun, and Viser visualizers. +* Added :meth:`~isaaclab.utils.backend_utils.get_default_renderer_cfg`. to lazy load the IsaacRtxRendererCfg +* Added :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`, the solver-common + base class for rigid-body physics materials. Carries the ``UsdPhysics.MaterialAPI`` standard + fields (``static_friction``, ``dynamic_friction``, ``restitution``). The PhysX-specific + compliant-contact and combine-mode fields moved to + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. +* Added :class:`~isaaclab.sim.schemas.CollisionBaseCfg`, the solver-common base class for + collision properties. Carries :attr:`collision_enabled` (``UsdPhysics.CollisionAPI``) plus + :attr:`contact_offset` and :attr:`rest_offset` whose USD attributes are PhysX-namespaced + but are consumed by Newton's importer via the PhysX bridge resolver + (``import_usd.py:2104, 2111``). +* Added :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg`, the solver-common base class + for articulation root properties (``fix_root_link``, ``articulation_enabled``). +* Added :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg`, the solver-common base class for + mesh collision properties carrying ``mesh_approximation_name`` (writes + ``physics:approximation`` via :class:`UsdPhysics.MeshCollisionAPI`). The class-level + ``_usd_applied_schema`` metadata replaces the deprecated ``usd_api`` / ``physx_api`` + instance-field dispatch. + +Changed +^^^^^^^ + +* :class:`~isaaclab.sensors.camera.Camera` obtains a backend via + :meth:`~isaaclab.renderers.render_context.RenderContext.get_renderer` and calls + :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.create_render_data` with + a :class:`~isaaclab.renderers.camera_render_spec.CameraRenderSpec` (no + :class:`~isaaclab.sensors.sensor_base.SensorBase` reference on the public API). +* :class:`~isaaclab.scene.interactive_scene.InteractiveScene` calls + :meth:`~isaaclab.renderers.render_context.RenderContext.update_transforms` once at the start + of :meth:`~isaaclab.scene.interactive_scene.InteractiveScene.update` when + ``lazy_sensor_update`` is false; fetches that render still dedupe the same way via + ``physics_step_count`` in :class:`~isaaclab.renderers.render_context.RenderContext`. +* **Breaking:** :func:`~isaaclab.cloner.clone_from_template` now returns + ``dict[str, ClonePlan]`` instead of ``None``. Bind the result and publish it + through :meth:`~isaaclab.sim.SimulationContext.set_clone_plans` if downstream + consumers (e.g. the PhysX scene data provider's Newton-visualizer build path) + need to read the plan. +* Changed :func:`~isaaclab.envs.mdp.body_incoming_wrench` to read from + :class:`~isaaclab.sensors.JointWrenchSensor`. Pass + ``sensor_cfg=SceneEntityCfg("joint_wrench", body_names=...)`` instead of an + articulation asset config. +* Updated :class:`~isaaclab.sensors.camera.Camera` to construct its internal + :class:`~isaaclab.sim.views.FrameView` without the now-removed + ``sync_usd_on_fabric_write`` kwarg. USD attributes on camera prims are + no longer kept in sync with Fabric writes; read poses through the view's + getters instead. +* :class:`~isaaclab.sensors.camera.CameraCfg` now defaults its render_cfg to :class:`~isaaclab.renderers.RenderCfg` + :meth:`~isaaclab.utils.backend_utils.get_default_renderer_cfg` is called during __post_init__ to replace + the generic RenderCfg with the default config :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg` +* Cleaned up the schema-cfg base classes to no longer carry PhysX namespace metadata. + :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`, + :class:`~isaaclab.sim.schemas.CollisionBaseCfg`, + :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg`, and + :class:`~isaaclab.sim.schemas.JointDriveBaseCfg` now declare ``_usd_namespace = None`` and + ``_usd_applied_schema = None``. Per-field PhysX overrides for fields whose only USD path + today is the ``physx*:*`` namespace (``disable_gravity``, ``contact_offset``, + ``rest_offset``, ``articulation_enabled``, ``max_velocity``) are declared via a new + ``_usd_field_exceptions`` mapping ``applied_schema -> (namespace, {cfg_field: usd_attr})``. + When any listed field is non-None at write time, the writer applies that schema and writes + the attribute under the exception namespace; otherwise the schema is not stamped onto the + prim. PhysX subclasses (:class:`PhysxRigidBodyPropertiesCfg`, + :class:`PhysxCollisionPropertiesCfg`, :class:`PhysxArticulationRootPropertiesCfg`, + :class:`PhysxJointDrivePropertiesCfg`) now self-declare ``_usd_namespace`` and + ``_usd_applied_schema`` for their own fields. Observable behavior on standard inputs is + unchanged. +* Consolidated the per-writer schema-application loop in + :mod:`isaaclab.sim.schemas` into a single shared helper ``_apply_namespaced_schemas``. + ``modify_articulation_root_properties``, ``modify_rigid_body_properties``, + ``modify_collision_properties``, ``modify_joint_drive_properties``, + ``modify_mesh_collision_properties``, and ``spawn_rigid_body_material`` all delegate to the + helper after writing their typed-API ``UsdPhysics`` fields. The canonical exception-table + + main-namespace gating logic now lives in one place instead of being duplicated across + six call sites. +* Moved the ``max_velocity`` field from :class:`~isaaclab_physx.sim.schemas.PhysxJointDrivePropertiesCfg` + to :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`. The field is the only USD path to set + Newton's ``Model.joint_velocity_limit`` and is consumed by Newton's importer. The USD + attribute written is unchanged (``physxJoint:maxJointVelocity``); existing code using + ``PhysxJointDrivePropertiesCfg(max_velocity=...)`` continues to work because the field + is inherited. +* Moved the ``disable_gravity`` field from :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyPropertiesCfg` + to :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`. PhysX honors per-body via + ``physxRigidBody:disableGravity``; Newton currently honors at scene level (partial), + documented in the field docstring. Existing code using + ``PhysxRigidBodyPropertiesCfg(disable_gravity=...)`` continues to work via inheritance. +* Documented :attr:`~isaaclab.sim.schemas.ArticulationRootPropertiesCfg.articulation_enabled` + and :attr:`~isaaclab.sim.schemas.ArticulationRootPropertiesCfg.enabled_self_collisions` + to lock their placement for the future :class:`ArticulationRootBaseCfg` / + ``PhysxArticulationRootPropertiesCfg`` split: ``articulation_enabled`` stays on the base + (single-namespace USD with verified Newton consumer); ``enabled_self_collisions`` moves + to the PhysX subclass (dual-namespace USD, with a future Newton sibling cfg owning the + ``newton:*`` namespace). +* Changed the defaults of :attr:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg.compliant_contact_stiffness`, + :attr:`compliant_contact_damping`, :attr:`friction_combine_mode`, and + :attr:`restitution_combine_mode` from concrete values (``0.0``, ``0.0``, ``"average"``, + ``"average"``) to ``None``. PhysX engine defaults match the previous concrete values, so + user-observable simulation behavior is unchanged; the difference is that these attributes + are now authored on the prim only when the user explicitly sets them (consistent with the + rest of the consumption-gated cfg layer). +* Relocated :class:`RigidBodyMaterialCfg` to :mod:`isaaclab_physx.sim.spawners.materials` and + split its fields between the new :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` + (UsdPhysics-standard friction/restitution) and + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` + (PhysX-specific compliant-contact and combine-mode fields). A forwarding shim on + :mod:`isaaclab.sim.spawners.materials` and :mod:`isaaclab.sim` preserves existing imports. +* Refactored :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material` to be + metadata-driven: it reads ``_usd_applied_schema``, ``_usd_namespace``, and + ``_usd_attr_name_map`` from the cfg class and gates ``PhysxMaterialAPI`` application on + whether the user authored at least one PhysX-namespaced field with a non-``None`` value. + Previously, the writer applied ``PhysxMaterialAPI`` unconditionally on every material spawn. +* Relocated :class:`CollisionPropertiesCfg` to :mod:`isaaclab_physx.sim.schemas` and split + its fields between the new :class:`~isaaclab.sim.schemas.CollisionBaseCfg` (solver-common + ``collision_enabled`` plus the PhysX-namespaced but Newton-consumed + ``contact_offset`` / ``rest_offset``) and + :class:`~isaaclab_physx.sim.schemas.PhysxCollisionPropertiesCfg` (PhysX-only + ``torsional_patch_radius`` / ``min_torsional_patch_radius``). A forwarding shim on + :mod:`isaaclab.sim.schemas`, :mod:`isaaclab.sim.schemas.schemas_cfg`, and + :mod:`isaaclab.sim` preserves existing imports. +* Refactored :func:`~isaaclab.sim.schemas.modify_collision_properties` to be metadata-driven + and to gate ``PhysxCollisionAPI`` application on whether the user authored at least one + PhysX-namespaced field with a non-``None`` value. Previously, the writer applied + ``PhysxCollisionAPI`` unconditionally on every collision prim, stamping the schema onto + Newton-targeted prims that only set ``collision_enabled``. +* Relocated :class:`ArticulationRootPropertiesCfg` to :mod:`isaaclab_physx.sim.schemas` and + split its fields between the new :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` + (solver-common ``fix_root_link`` plus the PhysX-namespaced ``articulation_enabled`` which + is consumed by the IL Newton wrapper as a spawn-time guard) and + :class:`~isaaclab_physx.sim.schemas.PhysxArticulationRootPropertiesCfg` + (``enabled_self_collisions`` and PhysX TGS solver iter / sleep / stabilization thresholds). + A forwarding shim on :mod:`isaaclab.sim.schemas`, + :mod:`isaaclab.sim.schemas.schemas_cfg`, and :mod:`isaaclab.sim` preserves existing imports. +* Refactored :func:`~isaaclab.sim.schemas.modify_articulation_root_properties` to be + metadata-driven and to gate ``PhysxArticulationAPI`` application on whether the user + authored at least one PhysX-namespaced field with a non-``None`` value. Previously, the + writer applied ``PhysxArticulationAPI`` unconditionally on every articulation root, + stamping the schema onto Newton-targeted prims that only set ``fix_root_link``. +* Relocated :class:`MeshCollisionPropertiesCfg`, :class:`ConvexHullPropertiesCfg`, + :class:`ConvexDecompositionPropertiesCfg`, :class:`TriangleMeshPropertiesCfg`, + :class:`TriangleMeshSimplificationPropertiesCfg`, and :class:`SDFMeshPropertiesCfg` to + :mod:`isaaclab_physx.sim.schemas`. :class:`BoundingCubePropertiesCfg` and + :class:`BoundingSpherePropertiesCfg` stay in core because they author no PhysX schema. + A forwarding shim preserves existing imports. +* Refactored :func:`~isaaclab.sim.schemas.modify_mesh_collision_properties` to be + metadata-driven. The writer now reads ``_usd_applied_schema`` and ``_usd_namespace`` from + the cfg class instead of consulting instance-level ``usd_api`` / ``physx_api`` fields. + The standard :class:`UsdPhysics.MeshCollisionAPI` is always applied; PhysX cooking + schemas (``PhysxConvexHullCollisionAPI`` etc.) are gated on at least one + PhysX-namespaced tuning field being set. +* Relocated :class:`FixedTendonPropertiesCfg` and :class:`SpatialTendonPropertiesCfg` to + :mod:`isaaclab_physx.sim.schemas` as :class:`PhysxFixedTendonPropertiesCfg` and + :class:`PhysxSpatialTendonPropertiesCfg`. Tendons are a PhysX-only feature; no Newton + equivalent exists. A forwarding shim on :mod:`isaaclab.sim.schemas`, + :mod:`isaaclab.sim.schemas.schemas_cfg`, and :mod:`isaaclab.sim` preserves existing + imports. + +Deprecated +^^^^^^^^^^ + +* Deprecated the ``usd_api`` and ``physx_api`` instance attributes on the mesh-collision + cfg classes in favor of class-level ``_usd_applied_schema`` metadata. Reading these + attributes still works through one minor version but emits a ``DeprecationWarning``. + Scheduled for removal in 5.0. + +Removed +^^^^^^^ + +* **Breaking:** Removed + :attr:`~isaaclab.cloner.TemplateCloneCfg.visualizer_clone_fn`, + :func:`~isaaclab.cloner.resolve_visualizer_clone_fn`, and + :class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts`. + Scene data providers now build backend models from the + :class:`~isaaclab.cloner.ClonePlan` map via + :meth:`~isaaclab.sim.SimulationContext.get_clone_plans` instead of receiving a + prebuilt artifact through a clone-time callback. +* **Breaking:** Removed + :meth:`~isaaclab.sim.SimulationContext.get_scene_data_visualizer_prebuilt_artifact`, + :meth:`~isaaclab.sim.SimulationContext.set_scene_data_visualizer_prebuilt_artifact`, + and + :meth:`~isaaclab.sim.SimulationContext.clear_scene_data_visualizer_prebuilt_artifact`. + Use :meth:`~isaaclab.sim.SimulationContext.get_clone_plans` / + :meth:`~isaaclab.sim.SimulationContext.set_clone_plans` instead. +* Removed ``BaseArticulationData.body_incoming_joint_wrench_b``. Add + :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene and read + :attr:`~isaaclab.sensors.JointWrenchSensorData.force` and + :attr:`~isaaclab.sensors.JointWrenchSensorData.torque` instead. + +Fixed +^^^^^ + +* Pinned ``omniverseclient`` to ``2.71.1.7015``. +* Fixed :meth:`~isaaclab.sim.schemas.modify_joint_drive_properties` and + :meth:`~isaaclab.sim.schemas.modify_rigid_body_properties` so that ``PhysxJointAPI`` and + ``PhysxRigidBodyAPI`` are applied only when the user authored at least one PhysX-namespaced + field with a non-``None`` value. Previously, schema application was gated on class-level + metadata being defined, which caused Newton-targeted prims to receive PhysX schemas even + when the user only set base ``UsdPhysics``-standard fields. + + 4.6.27 (2026-05-01) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/changelog.d/clone-plan-visualizer-cleanup.minor.rst b/source/isaaclab_newton/changelog.d/clone-plan-visualizer-cleanup.minor.rst deleted file mode 100644 index 6fed4677a471..000000000000 --- a/source/isaaclab_newton/changelog.d/clone-plan-visualizer-cleanup.minor.rst +++ /dev/null @@ -1,9 +0,0 @@ -Removed -^^^^^^^ - -* **Breaking:** Removed - ``isaaclab_newton.cloner.newton_replicate.create_newton_visualizer_prebuild_clone_fn``. - Callers that need a Newton model for visualization should call - :func:`~isaaclab_newton.cloner.newton_replicate.newton_visualizer_prebuild` - directly with the ``(sources, destinations, env_ids, mask, positions)`` bundle - derived from :meth:`~isaaclab.sim.SimulationContext.get_clone_plans`. diff --git a/source/isaaclab_newton/changelog.d/mtrepte-expand_viz_markers.skip b/source/isaaclab_newton/changelog.d/mtrepte-expand_viz_markers.skip deleted file mode 100644 index a23b7c7322b3..000000000000 --- a/source/isaaclab_newton/changelog.d/mtrepte-expand_viz_markers.skip +++ /dev/null @@ -1 +0,0 @@ -Marker visualization changes are covered by the isaaclab fragment. diff --git a/source/isaaclab_newton/changelog.d/mym-newton-manager-abstraction.rst b/source/isaaclab_newton/changelog.d/mym-newton-manager-abstraction.rst deleted file mode 100644 index 1ad8a9265830..000000000000 --- a/source/isaaclab_newton/changelog.d/mym-newton-manager-abstraction.rst +++ /dev/null @@ -1,14 +0,0 @@ -Changed -^^^^^^^ - -* Changed :class:`~isaaclab_newton.physics.NewtonManager` to dispatch through - solver-specific manager subclasses while preserving the existing - ``NewtonCfg(solver_cfg=...)`` configuration pattern. - -Deprecated -^^^^^^^^^^ - -* Deprecated :attr:`~isaaclab_newton.physics.NewtonSolverCfg.solver_type` for - manager dispatch in favor of - :attr:`~isaaclab_newton.physics.NewtonSolverCfg.class_type`. Existing configs - remain valid, but new code should rely on ``class_type``. diff --git a/source/isaaclab_newton/changelog.d/pr-5458-merge-develop.rst b/source/isaaclab_newton/changelog.d/pr-5458-merge-develop.rst deleted file mode 100644 index 30eb959531c2..000000000000 --- a/source/isaaclab_newton/changelog.d/pr-5458-merge-develop.rst +++ /dev/null @@ -1,13 +0,0 @@ -Removed -^^^^^^^ - -* Removed the unimplemented ``ArticulationData.body_incoming_joint_wrench_b`` - accessor. Add :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene - and read :attr:`~isaaclab.sensors.JointWrenchSensorData.force` and - :attr:`~isaaclab.sensors.JointWrenchSensorData.torque` instead. - -Fixed -^^^^^ - -* Fixed :class:`~isaaclab_newton.sensors.JointWrenchSensor` initialization for - USD assets whose articulation root is nested below the configured asset prim. diff --git a/source/isaaclab_newton/changelog.d/rschmitt_decouple_rednerer_camera.rst b/source/isaaclab_newton/changelog.d/rschmitt_decouple_rednerer_camera.rst deleted file mode 100644 index 1c3efcb5c33e..000000000000 --- a/source/isaaclab_newton/changelog.d/rschmitt_decouple_rednerer_camera.rst +++ /dev/null @@ -1,4 +0,0 @@ -Changed -^^^^^^^^ - -* Modified the newton renderer to use the new patterns from renderer/camera decoupling. diff --git a/source/isaaclab_newton/changelog.d/vidur-feature-usd-proprties-refactor.skip b/source/isaaclab_newton/changelog.d/vidur-feature-usd-proprties-refactor.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 0a8eed8000c2..4837c3f7ae03 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.26" +version = "0.6.0" # 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 d9626a890476..0f81a9effc43 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,46 @@ Changelog --------- +0.6.0 (2026-05-08) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Modified the newton renderer to use the new patterns from renderer/camera decoupling. +* Changed :class:`~isaaclab_newton.physics.NewtonManager` to dispatch through + solver-specific manager subclasses while preserving the existing + ``NewtonCfg(solver_cfg=...)`` configuration pattern. + +Deprecated +^^^^^^^^^^ + +* Deprecated :attr:`~isaaclab_newton.physics.NewtonSolverCfg.solver_type` for + manager dispatch in favor of + :attr:`~isaaclab_newton.physics.NewtonSolverCfg.class_type`. Existing configs + remain valid, but new code should rely on ``class_type``. + +Removed +^^^^^^^ + +* **Breaking:** Removed + ``isaaclab_newton.cloner.newton_replicate.create_newton_visualizer_prebuild_clone_fn``. + Callers that need a Newton model for visualization should call + :func:`~isaaclab_newton.cloner.newton_replicate.newton_visualizer_prebuild` + directly with the ``(sources, destinations, env_ids, mask, positions)`` bundle + derived from :meth:`~isaaclab.sim.SimulationContext.get_clone_plans`. +* Removed the unimplemented ``ArticulationData.body_incoming_joint_wrench_b`` + accessor. Add :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene + and read :attr:`~isaaclab.sensors.JointWrenchSensorData.force` and + :attr:`~isaaclab.sensors.JointWrenchSensorData.torque` instead. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_newton.sensors.JointWrenchSensor` initialization for + USD assets whose articulation root is nested below the configured asset prim. + + 0.5.26 (2026-04-30) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/changelog.d/pbarejko-open-usd.rst b/source/isaaclab_ov/changelog.d/pbarejko-open-usd.rst deleted file mode 100644 index 455768ad5a5c..000000000000 --- a/source/isaaclab_ov/changelog.d/pbarejko-open-usd.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed ``AttributeError: 'Renderer' object has no attribute 'add_usd'`` in - :class:`~isaaclab_ov.renderers.OVRTXRenderer` when using ``ovrtx`` 0.3.0 or - newer. The renderer now calls :meth:`ovrtx.Renderer.open_usd` on 0.3.0+ and - falls back to ``Renderer.add_usd`` on older versions. diff --git a/source/isaaclab_ov/changelog.d/rschmitt_decouple_renderer_camera.rst b/source/isaaclab_ov/changelog.d/rschmitt_decouple_renderer_camera.rst deleted file mode 100644 index 1de2259dc2c3..000000000000 --- a/source/isaaclab_ov/changelog.d/rschmitt_decouple_renderer_camera.rst +++ /dev/null @@ -1,4 +0,0 @@ -Changed -^^^^^^^^ - -* Modified the OVRTX renderer to use the new patterns from renderer/camera decoupling. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index ba8f5046c4eb..88756787ecc1 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.1.3" +version = "0.1.4" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index e8afeda30bda..9d558d295876 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +0.1.4 (2026-05-08) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Modified the OVRTX renderer to use the new patterns from renderer/camera decoupling. + +Fixed +^^^^^ + +* Fixed ``AttributeError: 'Renderer' object has no attribute 'add_usd'`` in + :class:`~isaaclab_ov.renderers.OVRTXRenderer` when using ``ovrtx`` 0.3.0 or + newer. The renderer now calls :meth:`ovrtx.Renderer.open_usd` on 0.3.0+ and + falls back to ``Renderer.add_usd`` on older versions. + + 0.1.3 (2026-04-30) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/changelog.d/pr-5458-merge-develop.rst b/source/isaaclab_ovphysx/changelog.d/pr-5458-merge-develop.rst deleted file mode 100644 index 546cb8acc1c7..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/pr-5458-merge-develop.rst +++ /dev/null @@ -1,7 +0,0 @@ -Removed -^^^^^^^ - -* Removed ``ArticulationData.body_incoming_joint_wrench_b`` to match the - shared articulation data API. Code that needs incoming joint reaction - wrenches should use a backend joint-wrench sensor instead of the articulation - data object. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 8648b7fa9587..1e541402d828 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.2" +version = "0.1.3" # 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 b2eb969d7845..8eef22620a69 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.1.3 (2026-05-08) +~~~~~~~~~~~~~~~~~~ + +Removed +^^^^^^^ + +* Removed ``ArticulationData.body_incoming_joint_wrench_b`` to match the + shared articulation data API. Code that needs incoming joint reaction + wrenches should use a backend joint-wrench sensor instead of the articulation + data object. + + 0.1.2 (2026-04-23) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/changelog.d/clone-plan-visualizer-cleanup.skip b/source/isaaclab_physx/changelog.d/clone-plan-visualizer-cleanup.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst b/source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst deleted file mode 100644 index e7d842da72bd..000000000000 --- a/source/isaaclab_physx/changelog.d/fix-fabric-prepare-for-reuse.rst +++ /dev/null @@ -1,12 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** Removed the ``sync_usd_on_fabric_write`` keyword argument from - :class:`~isaaclab_physx.sim.views.FabricFrameView`. Fabric writes - (``set_world_poses``, ``set_scales``) now notify the renderer via - ``PrepareForReuse()`` on the underlying ``PrimSelection`` instead of writing - back to USD, which is ~200x faster and avoids the stale USD shadow state the - old path produced. Callers passing ``sync_usd_on_fabric_write=True`` should - remove the argument; if they relied on USD reflecting Fabric writes, they - should now read Fabric poses directly via the view's getters or refresh USD - explicitly. diff --git a/source/isaaclab_physx/changelog.d/mtrepte-expand_viz_markers.skip b/source/isaaclab_physx/changelog.d/mtrepte-expand_viz_markers.skip deleted file mode 100644 index 4f6915f6b47b..000000000000 --- a/source/isaaclab_physx/changelog.d/mtrepte-expand_viz_markers.skip +++ /dev/null @@ -1,2 +0,0 @@ -Marker visualization changes are covered by the isaaclab fragment. -Marker visualization changes are covered by the isaaclab fragment. diff --git a/source/isaaclab_physx/changelog.d/pr-5458-merge-develop.rst b/source/isaaclab_physx/changelog.d/pr-5458-merge-develop.rst deleted file mode 100644 index 1fe6a600bb99..000000000000 --- a/source/isaaclab_physx/changelog.d/pr-5458-merge-develop.rst +++ /dev/null @@ -1,16 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_physx.sensors.JointWrenchSensor` for reading PhysX - incoming joint reaction wrenches as split force [N] and torque [N·m] buffers. - The sensor accepts asset prim paths whose articulation root is nested below - the configured prim and converts PhysX's native body-frame wrench to the - shared child-side joint-frame convention. - -Removed -^^^^^^^ - -* Removed ``ArticulationData.body_incoming_joint_wrench_b``. Add - :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene and read - :attr:`~isaaclab.sensors.JointWrenchSensorData.force` and - :attr:`~isaaclab.sensors.JointWrenchSensorData.torque` instead. diff --git a/source/isaaclab_physx/changelog.d/rschmitt_decouple_renderer_camera.rst b/source/isaaclab_physx/changelog.d/rschmitt_decouple_renderer_camera.rst deleted file mode 100644 index eada0bbb809c..000000000000 --- a/source/isaaclab_physx/changelog.d/rschmitt_decouple_renderer_camera.rst +++ /dev/null @@ -1,4 +0,0 @@ -Changed -^^^^^^^^ - -* Modified the isaac rtx renderer to use the new patterns from renderer/camera decoupling. diff --git a/source/isaaclab_physx/changelog.d/test-articulation-timeout.rst b/source/isaaclab_physx/changelog.d/test-articulation-timeout.rst deleted file mode 100644 index e0c1b96870c2..000000000000 --- a/source/isaaclab_physx/changelog.d/test-articulation-timeout.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Fixed :class:`~isaaclab_physx.assets.SurfaceGripper` initialization on - non-CPU simulation backends to raise before loading the surface gripper - extension, avoiding hangs during startup. diff --git a/source/isaaclab_physx/changelog.d/vidur-rebalance-cfg-placement.minor.rst b/source/isaaclab_physx/changelog.d/vidur-rebalance-cfg-placement.minor.rst deleted file mode 100644 index 4233c5d1c720..000000000000 --- a/source/isaaclab_physx/changelog.d/vidur-rebalance-cfg-placement.minor.rst +++ /dev/null @@ -1,77 +0,0 @@ -Added -^^^^^ - -* Added :class:`PhysxRigidBodyMaterialCfg`, a subclass of - :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` carrying the - ``PhysxMaterialAPI`` schema fields (``compliant_contact_stiffness``, - ``compliant_contact_damping``, ``friction_combine_mode``, ``restitution_combine_mode``). - Use this when authoring PhysX-specific material knobs; use the base class when only the - UsdPhysics-standard friction/restitution fields are needed. -* Added :class:`PhysxCollisionPropertiesCfg`, a subclass of - :class:`~isaaclab.sim.schemas.CollisionBaseCfg` carrying the PhysX-specific - ``torsional_patch_radius`` / ``min_torsional_patch_radius`` friction approximations. - These fields have no Newton equivalent. -* Added :class:`PhysxDeformableCollisionPropertiesCfg`, renaming the previous - ``PhysXCollisionPropertiesCfg`` (capital X) for clarity. Used internally by - :class:`DeformableBodyPropertiesCfg`. -* Added :class:`PhysxArticulationRootPropertiesCfg`, a subclass of - :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` carrying the PhysX-specific - ``enabled_self_collisions``, ``solver_position_iteration_count``, - ``solver_velocity_iteration_count``, ``sleep_threshold``, ``stabilization_threshold``. -* Added :class:`PhysxConvexHullPropertiesCfg`, :class:`PhysxConvexDecompositionPropertiesCfg`, - :class:`PhysxTriangleMeshPropertiesCfg`, - :class:`PhysxTriangleMeshSimplificationPropertiesCfg`, and - :class:`PhysxSDFMeshPropertiesCfg` -- the PhysX-cooking-specific mesh collision - subclasses. Each declares its own PhysxSchema cooking API via class-level - ``_usd_applied_schema`` metadata and inherits ``mesh_approximation_name`` from - :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg`. -* Added :class:`PhysxFixedTendonPropertiesCfg` and :class:`PhysxSpatialTendonPropertiesCfg`, - the relocated PhysX-only tendon cfg classes. Same fields as the legacy core-side classes; - no field-level split. - -Changed -^^^^^^^ - -* Removed the ``max_velocity`` field and USD metadata - (``_usd_applied_schema``, ``_usd_namespace``, ``_usd_attr_name_map``) from - :class:`PhysxJointDrivePropertiesCfg`. The field moved to - :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`; ``PhysxJointDrivePropertiesCfg`` - inherits it. Existing instantiations continue to work unchanged. -* Removed the ``disable_gravity`` field from :class:`PhysxRigidBodyPropertiesCfg`. - The field moved to :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`; - ``PhysxRigidBodyPropertiesCfg`` inherits it. Existing instantiations continue - to work unchanged. - -Deprecated -^^^^^^^^^^ - -* Deprecated :class:`RigidBodyMaterialCfg` in favor of - :class:`PhysxRigidBodyMaterialCfg` (PhysX-specific) or - :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` (solver-common). - The legacy name remains as a concrete subclass of :class:`PhysxRigidBodyMaterialCfg` - that emits ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. -* Deprecated :class:`CollisionPropertiesCfg` in favor of - :class:`PhysxCollisionPropertiesCfg` (PhysX-specific) or - :class:`~isaaclab.sim.schemas.CollisionBaseCfg` (solver-common). The legacy name remains - as a concrete subclass of :class:`PhysxCollisionPropertiesCfg` that emits - ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. -* Deprecated :class:`PhysXCollisionPropertiesCfg` (capital X, deformable-body) in favor of - :class:`PhysxDeformableCollisionPropertiesCfg`. The capital-X name is preserved as a - deprecation alias (concrete subclass) and is scheduled for removal in 5.0. -* Deprecated :class:`ArticulationRootPropertiesCfg` in favor of - :class:`PhysxArticulationRootPropertiesCfg` (PhysX-specific) or - :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` (solver-common). The legacy name - remains as a concrete subclass of :class:`PhysxArticulationRootPropertiesCfg` that emits - ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. -* Deprecated :class:`MeshCollisionPropertiesCfg`, :class:`ConvexHullPropertiesCfg`, - :class:`ConvexDecompositionPropertiesCfg`, :class:`TriangleMeshPropertiesCfg`, - :class:`TriangleMeshSimplificationPropertiesCfg`, and :class:`SDFMeshPropertiesCfg` in - favor of :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg` or the new ``Physx*`` - subclasses. Legacy names remain as concrete subclasses that emit ``DeprecationWarning`` - on instantiation. Scheduled for removal in 5.0. -* Deprecated :class:`FixedTendonPropertiesCfg` in favor of - :class:`PhysxFixedTendonPropertiesCfg`. Legacy name remains as a concrete subclass that - emits ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. -* Deprecated :class:`SpatialTendonPropertiesCfg` in favor of - :class:`PhysxSpatialTendonPropertiesCfg`. Legacy name remains as a concrete subclass - that emits ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 5c63b0e6322f..2e6fbc7360fc 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.29" +version = "0.6.0" # 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 14425eb74869..f99368bd5be5 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,118 @@ Changelog --------- +0.6.0 (2026-05-08) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sensors.JointWrenchSensor` for reading PhysX + incoming joint reaction wrenches as split force [N] and torque [N·m] buffers. + The sensor accepts asset prim paths whose articulation root is nested below + the configured prim and converts PhysX's native body-frame wrench to the + shared child-side joint-frame convention. +* Added :class:`PhysxRigidBodyMaterialCfg`, a subclass of + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` carrying the + ``PhysxMaterialAPI`` schema fields (``compliant_contact_stiffness``, + ``compliant_contact_damping``, ``friction_combine_mode``, ``restitution_combine_mode``). + Use this when authoring PhysX-specific material knobs; use the base class when only the + UsdPhysics-standard friction/restitution fields are needed. +* Added :class:`PhysxCollisionPropertiesCfg`, a subclass of + :class:`~isaaclab.sim.schemas.CollisionBaseCfg` carrying the PhysX-specific + ``torsional_patch_radius`` / ``min_torsional_patch_radius`` friction approximations. + These fields have no Newton equivalent. +* Added :class:`PhysxDeformableCollisionPropertiesCfg`, renaming the previous + ``PhysXCollisionPropertiesCfg`` (capital X) for clarity. Used internally by + :class:`DeformableBodyPropertiesCfg`. +* Added :class:`PhysxArticulationRootPropertiesCfg`, a subclass of + :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` carrying the PhysX-specific + ``enabled_self_collisions``, ``solver_position_iteration_count``, + ``solver_velocity_iteration_count``, ``sleep_threshold``, ``stabilization_threshold``. +* Added :class:`PhysxConvexHullPropertiesCfg`, :class:`PhysxConvexDecompositionPropertiesCfg`, + :class:`PhysxTriangleMeshPropertiesCfg`, + :class:`PhysxTriangleMeshSimplificationPropertiesCfg`, and + :class:`PhysxSDFMeshPropertiesCfg` -- the PhysX-cooking-specific mesh collision + subclasses. Each declares its own PhysxSchema cooking API via class-level + ``_usd_applied_schema`` metadata and inherits ``mesh_approximation_name`` from + :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg`. +* Added :class:`PhysxFixedTendonPropertiesCfg` and :class:`PhysxSpatialTendonPropertiesCfg`, + the relocated PhysX-only tendon cfg classes. Same fields as the legacy core-side classes; + no field-level split. + +Changed +^^^^^^^ + +* Modified the isaac rtx renderer to use the new patterns from renderer/camera decoupling. +* **Breaking:** Removed the ``sync_usd_on_fabric_write`` keyword argument from + :class:`~isaaclab_physx.sim.views.FabricFrameView`. Fabric writes + (``set_world_poses``, ``set_scales``) now notify the renderer via + ``PrepareForReuse()`` on the underlying ``PrimSelection`` instead of writing + back to USD, which is ~200x faster and avoids the stale USD shadow state the + old path produced. Callers passing ``sync_usd_on_fabric_write=True`` should + remove the argument; if they relied on USD reflecting Fabric writes, they + should now read Fabric poses directly via the view's getters or refresh USD + explicitly. +* Removed the ``max_velocity`` field and USD metadata + (``_usd_applied_schema``, ``_usd_namespace``, ``_usd_attr_name_map``) from + :class:`PhysxJointDrivePropertiesCfg`. The field moved to + :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`; ``PhysxJointDrivePropertiesCfg`` + inherits it. Existing instantiations continue to work unchanged. +* Removed the ``disable_gravity`` field from :class:`PhysxRigidBodyPropertiesCfg`. + The field moved to :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`; + ``PhysxRigidBodyPropertiesCfg`` inherits it. Existing instantiations continue + to work unchanged. + +Deprecated +^^^^^^^^^^ + +* Deprecated :class:`RigidBodyMaterialCfg` in favor of + :class:`PhysxRigidBodyMaterialCfg` (PhysX-specific) or + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` (solver-common). + The legacy name remains as a concrete subclass of :class:`PhysxRigidBodyMaterialCfg` + that emits ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. +* Deprecated :class:`CollisionPropertiesCfg` in favor of + :class:`PhysxCollisionPropertiesCfg` (PhysX-specific) or + :class:`~isaaclab.sim.schemas.CollisionBaseCfg` (solver-common). The legacy name remains + as a concrete subclass of :class:`PhysxCollisionPropertiesCfg` that emits + ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. +* Deprecated :class:`PhysXCollisionPropertiesCfg` (capital X, deformable-body) in favor of + :class:`PhysxDeformableCollisionPropertiesCfg`. The capital-X name is preserved as a + deprecation alias (concrete subclass) and is scheduled for removal in 5.0. +* Deprecated :class:`ArticulationRootPropertiesCfg` in favor of + :class:`PhysxArticulationRootPropertiesCfg` (PhysX-specific) or + :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` (solver-common). The legacy name + remains as a concrete subclass of :class:`PhysxArticulationRootPropertiesCfg` that emits + ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. +* Deprecated :class:`MeshCollisionPropertiesCfg`, :class:`ConvexHullPropertiesCfg`, + :class:`ConvexDecompositionPropertiesCfg`, :class:`TriangleMeshPropertiesCfg`, + :class:`TriangleMeshSimplificationPropertiesCfg`, and :class:`SDFMeshPropertiesCfg` in + favor of :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg` or the new ``Physx*`` + subclasses. Legacy names remain as concrete subclasses that emit ``DeprecationWarning`` + on instantiation. Scheduled for removal in 5.0. +* Deprecated :class:`FixedTendonPropertiesCfg` in favor of + :class:`PhysxFixedTendonPropertiesCfg`. Legacy name remains as a concrete subclass that + emits ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. +* Deprecated :class:`SpatialTendonPropertiesCfg` in favor of + :class:`PhysxSpatialTendonPropertiesCfg`. Legacy name remains as a concrete subclass + that emits ``DeprecationWarning`` on instantiation. Scheduled for removal in 5.0. + +Removed +^^^^^^^ + +* Removed ``ArticulationData.body_incoming_joint_wrench_b``. Add + :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene and read + :attr:`~isaaclab.sensors.JointWrenchSensorData.force` and + :attr:`~isaaclab.sensors.JointWrenchSensorData.torque` instead. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_physx.assets.SurfaceGripper` initialization on + non-CPU simulation backends to raise before loading the surface gripper + extension, avoiding hangs during startup. + + 0.5.29 (2026-04-30) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_rl/changelog.d/leapp_export_integration.rst b/source/isaaclab_rl/changelog.d/leapp_export_integration.rst deleted file mode 100644 index 8a9a65b18d7f..000000000000 --- a/source/isaaclab_rl/changelog.d/leapp_export_integration.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added RSL-RL LEAPP export scripts and integration tests for exporting trained - policies with semantic input, output, and state annotations. diff --git a/source/isaaclab_rl/config/extension.toml b/source/isaaclab_rl/config/extension.toml index 6b5ae668f03e..df9fe2b03612 100644 --- a/source/isaaclab_rl/config/extension.toml +++ b/source/isaaclab_rl/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.1" +version = "0.5.2" # Description title = "Isaac Lab RL" diff --git a/source/isaaclab_rl/docs/CHANGELOG.rst b/source/isaaclab_rl/docs/CHANGELOG.rst index 0c4c4323ced7..ad62d198ad0b 100644 --- a/source/isaaclab_rl/docs/CHANGELOG.rst +++ b/source/isaaclab_rl/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.5.2 (2026-05-08) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added RSL-RL LEAPP export scripts and integration tests for exporting trained + policies with semantic input, output, and state annotations. + + 0.5.1 (2026-04-21) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/changelog.d/antoiner-rename-newton-presets.rst b/source/isaaclab_tasks/changelog.d/antoiner-rename-newton-presets.rst deleted file mode 100644 index ae311dd1071b..000000000000 --- a/source/isaaclab_tasks/changelog.d/antoiner-rename-newton-presets.rst +++ /dev/null @@ -1,25 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** Renamed the Newton-backend solver presets to a ``newton_`` - prefix so they group together in autocomplete and read distinctly from the - Newton backend label, package, and visualizer. The change is shimmed by - deprecation aliases (see ``Deprecated`` below), but workflows that iterate - ``__dataclass_fields__`` directly or treat :exc:`FutureWarning` as an error - will need updates. Migration: rename the field in any - :class:`~isaaclab_tasks.utils.hydra.PresetCfg` subclass and update CLI - invocations (``presets=...`` and ``env.=...``): - - - ``newton`` -> ``newton_mjwarp`` - - ``kamino`` -> ``newton_kamino`` - -Deprecated -^^^^^^^^^^ - -* Deprecated the legacy ``newton`` and ``kamino`` preset names. They still - resolve to ``newton_mjwarp`` and ``newton_kamino`` respectively but emit a - :exc:`FutureWarning` and will be removed in a future release. Update CLI - overrides (``presets=newton`` -> ``presets=newton_mjwarp``; - ``presets=kamino`` -> ``presets=newton_kamino``) and any - :class:`~isaaclab_tasks.utils.hydra.PresetCfg` field declarations - (``newton: NewtonCfg = ...`` -> ``newton_mjwarp: NewtonCfg = ...``). diff --git a/source/isaaclab_tasks/changelog.d/g1-rough-terrain-wip.rst b/source/isaaclab_tasks/changelog.d/g1-rough-terrain-wip.rst deleted file mode 100644 index 9efbf82b8bf6..000000000000 --- a/source/isaaclab_tasks/changelog.d/g1-rough-terrain-wip.rst +++ /dev/null @@ -1,11 +0,0 @@ -Added -^^^^^ - -* Added Newton rough terrain support for the G1 biped locomotion velocity - env. The only engine-specific change is a ~1.7x ``max_iterations`` preset on - :class:`~isaaclab_tasks.manager_based.locomotion.velocity.config.g1.agents.rsl_rl_ppo_cfg.G1RoughPPORunnerCfg` - (Newton = 5000, PhysX = 3000). PhysX saturates near iter 3000 on both - reward (≈ +18) and episode length (≈ 980) and does not meaningfully - improve further; Newton reaches the same (reward, ep_len) quality at - iter 5000. The iteration budget is bumped rather than tuning physics - or reward terms. diff --git a/source/isaaclab_tasks/changelog.d/huidongc-flaky-mark.skip b/source/isaaclab_tasks/changelog.d/huidongc-flaky-mark.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_tasks/changelog.d/leapp_export_integration.rst b/source/isaaclab_tasks/changelog.d/leapp_export_integration.rst deleted file mode 100644 index 94a128b6416f..000000000000 --- a/source/isaaclab_tasks/changelog.d/leapp_export_integration.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added LEAPP-compatible policy deployment tutorials and tracing-compatible task - observation helpers for exported policy workflows. diff --git a/source/isaaclab_tasks/changelog.d/mtrepte-expand_viz_markers.skip b/source/isaaclab_tasks/changelog.d/mtrepte-expand_viz_markers.skip deleted file mode 100644 index a23b7c7322b3..000000000000 --- a/source/isaaclab_tasks/changelog.d/mtrepte-expand_viz_markers.skip +++ /dev/null @@ -1 +0,0 @@ -Marker visualization changes are covered by the isaaclab fragment. diff --git a/source/isaaclab_tasks/changelog.d/pr-5458-merge-develop.rst b/source/isaaclab_tasks/changelog.d/pr-5458-merge-develop.rst deleted file mode 100644 index cd4dc7e674d4..000000000000 --- a/source/isaaclab_tasks/changelog.d/pr-5458-merge-develop.rst +++ /dev/null @@ -1,10 +0,0 @@ -Changed -^^^^^^^ - -* Updated classic Ant/Humanoid manager-based environments and direct in-hand - manipulation environments to read body incoming wrenches from - :class:`~isaaclab.sensors.JointWrenchSensor` instead of - ``ArticulationData.body_incoming_joint_wrench_b``. Add a - :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene and pass its - :class:`~isaaclab.managers.SceneEntityCfg` as ``sensor_cfg``. The classic - Ant/Humanoid Newton presets now use the same wrench observations as PhysX. diff --git a/source/isaaclab_tasks/changelog.d/rendering-test-flakiness.skip b/source/isaaclab_tasks/changelog.d/rendering-test-flakiness.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_tasks/changelog.d/rwiltz-restore-legacy-teleop.rst b/source/isaaclab_tasks/changelog.d/rwiltz-restore-legacy-teleop.rst deleted file mode 100644 index 59e71ddc3984..000000000000 --- a/source/isaaclab_tasks/changelog.d/rwiltz-restore-legacy-teleop.rst +++ /dev/null @@ -1,8 +0,0 @@ -Added -^^^^^ - -* Added legacy ``teleop_devices`` configuration (``OpenXRDeviceCfg``, - ``ManusViveCfg``, ``GR1T2RetargeterCfg``) to - :class:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.PickPlaceGR1T2EnvCfg` - alongside the existing ``isaac_teleop`` pipeline, enabling CI validation - via ``--teleop_device=handtracking``. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 273ae57d2cb9..c797fcdb2cb9 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.34" +version = "1.5.35" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 2044f807afc5..cc3f6a513120 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,62 @@ Changelog --------- +1.5.35 (2026-05-08) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added LEAPP-compatible policy deployment tutorials and tracing-compatible task + observation helpers for exported policy workflows. +* Added Newton rough terrain support for the G1 biped locomotion velocity + env. The only engine-specific change is a ~1.7x ``max_iterations`` preset on + :class:`~isaaclab_tasks.manager_based.locomotion.velocity.config.g1.agents.rsl_rl_ppo_cfg.G1RoughPPORunnerCfg` + (Newton = 5000, PhysX = 3000). PhysX saturates near iter 3000 on both + reward (≈ +18) and episode length (≈ 980) and does not meaningfully + improve further; Newton reaches the same (reward, ep_len) quality at + iter 5000. The iteration budget is bumped rather than tuning physics + or reward terms. +* Added legacy ``teleop_devices`` configuration (``OpenXRDeviceCfg``, + ``ManusViveCfg``, ``GR1T2RetargeterCfg``) to + :class:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.PickPlaceGR1T2EnvCfg` + alongside the existing ``isaac_teleop`` pipeline, enabling CI validation + via ``--teleop_device=handtracking``. + +Changed +^^^^^^^ + +* Updated classic Ant/Humanoid manager-based environments and direct in-hand + manipulation environments to read body incoming wrenches from + :class:`~isaaclab.sensors.JointWrenchSensor` instead of + ``ArticulationData.body_incoming_joint_wrench_b``. Add a + :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene and pass its + :class:`~isaaclab.managers.SceneEntityCfg` as ``sensor_cfg``. The classic + Ant/Humanoid Newton presets now use the same wrench observations as PhysX. +* **Breaking:** Renamed the Newton-backend solver presets to a ``newton_`` + prefix so they group together in autocomplete and read distinctly from the + Newton backend label, package, and visualizer. The change is shimmed by + deprecation aliases (see ``Deprecated`` below), but workflows that iterate + ``__dataclass_fields__`` directly or treat :exc:`FutureWarning` as an error + will need updates. Migration: rename the field in any + :class:`~isaaclab_tasks.utils.hydra.PresetCfg` subclass and update CLI + invocations (``presets=...`` and ``env.=...``): + + - ``newton`` -> ``newton_mjwarp`` + - ``kamino`` -> ``newton_kamino`` + +Deprecated +^^^^^^^^^^ + +* Deprecated the legacy ``newton`` and ``kamino`` preset names. They still + resolve to ``newton_mjwarp`` and ``newton_kamino`` respectively but emit a + :exc:`FutureWarning` and will be removed in a future release. Update CLI + overrides (``presets=newton`` -> ``presets=newton_mjwarp``; + ``presets=kamino`` -> ``presets=newton_kamino``) and any + :class:`~isaaclab_tasks.utils.hydra.PresetCfg` field declarations + (``newton: NewtonCfg = ...`` -> ``newton_mjwarp: NewtonCfg = ...``). + + 1.5.34 (2026-04-30) ~~~~~~~~~~~~~~~~~~~ Added diff --git a/source/isaaclab_teleop/changelog.d/rwiltz-restore-legacy-teleop.rst b/source/isaaclab_teleop/changelog.d/rwiltz-restore-legacy-teleop.rst deleted file mode 100644 index 4c534f674c8a..000000000000 --- a/source/isaaclab_teleop/changelog.d/rwiltz-restore-legacy-teleop.rst +++ /dev/null @@ -1,11 +0,0 @@ -Changed -^^^^^^^ - -* Changed ``--teleop_device`` default to ``None`` in ``teleop_se3_agent.py`` - and ``record_demos.py``. When omitted, the IsaacTeleop pipeline is used if - the env configures ``isaac_teleop``; otherwise keyboard is used as fallback. - When explicitly provided, the scripts use the legacy ``teleop_devices`` path - and error out if no matching entry exists. -* Removed automatic ``--xr`` detection from ``--teleop_device`` containing - ``"handtracking"``. Users who need XR with the legacy path should pass - ``--xr`` explicitly. diff --git a/source/isaaclab_teleop/config/extension.toml b/source/isaaclab_teleop/config/extension.toml index 947103618b9a..48415f4bf5eb 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.9" +version = "0.3.10" # Description title = "Isaac Lab Teleop" diff --git a/source/isaaclab_teleop/docs/CHANGELOG.rst b/source/isaaclab_teleop/docs/CHANGELOG.rst index 3856e6ec1346..01465486d63e 100644 --- a/source/isaaclab_teleop/docs/CHANGELOG.rst +++ b/source/isaaclab_teleop/docs/CHANGELOG.rst @@ -1,6 +1,22 @@ Changelog --------- +0.3.10 (2026-05-08) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed ``--teleop_device`` default to ``None`` in ``teleop_se3_agent.py`` + and ``record_demos.py``. When omitted, the IsaacTeleop pipeline is used if + the env configures ``isaac_teleop``; otherwise keyboard is used as fallback. + When explicitly provided, the scripts use the legacy ``teleop_devices`` path + and error out if no matching entry exists. +* Removed automatic ``--xr`` detection from ``--teleop_device`` containing + ``"handtracking"``. Users who need XR with the legacy path should pass + ``--xr`` explicitly. + + 0.3.9 (2026-04-29) ~~~~~~~~~~~~~~~~~~ From 9e4e62c900c29565cb6f804d7bca7cfd64df99ed Mon Sep 17 00:00:00 2001 From: hujc Date: Thu, 7 May 2026 18:26:56 -0700 Subject: [PATCH 011/133] [Newton] Bump Newton pin to v1.2.0rc2 (#5523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Bumps the Newton pin to [`v1.2.0rc2`](https://pypi.org/project/newton/1.2.0rc2/), which pulls in IsaacLab-relevant fixes plus the upstream tendon-scoping fix. ## What's new in Newton v1.2.0rc2 vs IsaacLab's current pin (`a27277e`) The current IsaacLab Newton pin is from late April; v1.2.0rc2 is the latest release-candidate cut. Notable fixes pulled in: - **[newton-physics/newton#2659](https://github.com/newton-physics/newton/pull/2659)** \"Scope USD custom-frequency parsing\" — `parse_usd` now scopes the custom-frequency walk to `root_path` natively. - **[newton-physics/newton#2678](https://github.com/newton-physics/newton/pull/2678)** Regression fix. - **[newton-physics/newton#2720](https://github.com/newton-physics/newton/pull/2720)** `SolverKamino` reset under `world_mask`. - **[newton-physics/newton#2710](https://github.com/newton-physics/newton/pull/2710)** VRAM leak fix on example reset. - Plus 16 other smaller fixes between rc1 and rc2. ## Required dep bumps Newton 1.2.0rc2's \`pyproject.toml\` requires: - \`warp-lang==1.13.0\` - \`mujoco==3.8.0\` (was 3.6.0) - \`mujoco-warp==3.8.0.1\` (was 3.6.0) Pins updated in: | File | Change | |---|---| | \`source/isaaclab/setup.py\` | \`warp-lang==1.12.0\` → \`==1.13.0\`; \`mujoco==3.6.0\` → \`==3.8.0\`; \`mujoco-warp==3.6.0\` → \`==3.8.0.1\` | | \`source/isaaclab_newton/setup.py\` | mujoco / mujoco-warp bumps; Newton pin → \`v1.2.0rc2\` | | \`source/isaaclab_visualizers/setup.py\` | 3× Newton pin → \`v1.2.0rc2\` | | \`tools/wheel_builder/res/python_packages.toml\` | All four pins mirrored | ## Code adapts \`warp-lang\` 1.13 removed the \`wp.math\` namespace. Two IsaacLab call sites use it: - \`source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py:72\` - \`source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py:330\` Both rewritten as \`wp.math.transform_to_matrix(...)\` → \`wp.transform_to_matrix(...)\`. That's the only IsaacLab-side adapt needed. ## Test plan - [x] \`./isaaclab.sh -i newton\` clean install against the bumped pins. - [x] \`pip list\` confirms \`newton 1.2.0rc2\`, \`warp-lang 1.13.0\`, \`mujoco 3.8.0\`, \`mujoco-warp 3.8.0.1\`. - [x] Sanity smoke: Shadow-Hand-Over MAPPO (4 envs, 1 iter) runs clean — simulation init through CUDA graph capture through one training step + checkpoint save, no errors. - [x] Pre-commit clean. ## Caveat Smoke covered Shadow-Hand-Over MAPPO. Other envs with different sensors / renderers / collision setups could surface warp 1.13 or mujoco 3.8 differences the smoke didn't exercise; full PR CI catches them. --------- Co-authored-by: Kelly Guo --- .../jichuanh-newton-rc2-bump.minor.rst | 23 +++++++++ source/isaaclab/setup.py | 6 +-- .../changelog.d/jichuanh-newton-rc2-bump.rst | 15 ++++++ .../isaaclab_mimic/isaaclab_mimic/__init__.py | 49 +++++++++++++++++++ .../jichuanh-newton-rc2-bump.minor.rst | 33 +++++++++++++ .../isaaclab_newton/physics/newton_manager.py | 2 +- .../renderers/newton_warp_renderer.py | 15 +++++- source/isaaclab_newton/setup.py | 6 +-- .../changelog.d/jichuanh-newton-rc2-bump.rst | 23 +++++++++ .../renderers/ovrtx_renderer_kernels.py | 2 +- .../changelog.d/jichuanh-newton-rc2-bump.rst | 8 +++ source/isaaclab_physx/setup.py | 2 +- source/isaaclab_visualizers/setup.py | 6 +-- tools/wheel_builder/res/python_packages.toml | 10 ++-- 14 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-newton-rc2-bump.minor.rst create mode 100644 source/isaaclab_mimic/changelog.d/jichuanh-newton-rc2-bump.rst create mode 100644 source/isaaclab_newton/changelog.d/jichuanh-newton-rc2-bump.minor.rst create mode 100644 source/isaaclab_ov/changelog.d/jichuanh-newton-rc2-bump.rst create mode 100644 source/isaaclab_physx/changelog.d/jichuanh-newton-rc2-bump.rst diff --git a/source/isaaclab/changelog.d/jichuanh-newton-rc2-bump.minor.rst b/source/isaaclab/changelog.d/jichuanh-newton-rc2-bump.minor.rst new file mode 100644 index 000000000000..3609cf6cd787 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-newton-rc2-bump.minor.rst @@ -0,0 +1,23 @@ +Changed +^^^^^^^ + +* Bumped Newton pin to ``v1.2.0rc2``. Pulls in IsaacLab-relevant fixes from + `newton-physics/newton#2678 `_ + and `newton-physics/newton#2720 + `_ (``SolverKamino`` + reset under ``world_mask``), the upstream tendon-scoping fix from + `newton-physics/newton#2659 + `_ ("Scope USD + custom-frequency parsing"), and a VRAM-leak fix on example reset + (`newton-physics/newton#2710 + `_). +* Newton ``v1.2.0rc2`` requires ``warp-lang==1.13.0``, ``mujoco==3.8.0``, + and ``mujoco-warp==3.8.0.1``. ``warp-lang``/``mujoco``/``mujoco-warp`` + pins live in :mod:`isaaclab` and ``tools/wheel_builder/res/python_packages.toml``; + the Newton pin is mirrored across :mod:`isaaclab_newton`, + :mod:`isaaclab_visualizers` (3×), :mod:`isaaclab_physx` (``[newton]`` + extra), and the wheel-builder TOML. +* Updated ``wp.math.transform_to_matrix`` to ``wp.transform_to_matrix`` in + :mod:`~isaaclab_newton.physics.newton_manager` and + :mod:`~isaaclab_ov.renderers.ovrtx_renderer_kernels` to match the + ``warp-lang`` 1.13 API (the ``wp.math`` namespace was removed). diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index 67c18c4c62d1..4f54032f6d14 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -30,12 +30,12 @@ # procedural-generation "trimesh", "pyglet>=2.1.6,<3", - "mujoco==3.6.0", - "mujoco-warp==3.6.0", + "mujoco==3.8.0", + "mujoco-warp==3.8.0.1", # image processing "transformers==4.57.6", "einops", # needed for transformers, doesn't always auto-install - "warp-lang==1.12.0", + "warp-lang==1.13.0", "matplotlib>=3.10.3", # minimum version for Python 3.12 support # make sure this is consistent with isaac sim version "pillow==12.1.1", diff --git a/source/isaaclab_mimic/changelog.d/jichuanh-newton-rc2-bump.rst b/source/isaaclab_mimic/changelog.d/jichuanh-newton-rc2-bump.rst new file mode 100644 index 000000000000..051f04fae9f2 --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/jichuanh-newton-rc2-bump.rst @@ -0,0 +1,15 @@ +Added +^^^^^ + +* Added a temporary ``warp.torch`` compatibility shim at + :mod:`isaaclab_mimic` import time so that cuRobo (NVlabs/curobo) keeps + working with ``warp-lang>=1.13``, which dropped the ``warp.torch`` + submodule in favour of top-level ``warp.*`` (e.g. + ``wp.torch.device_from_torch`` → ``wp.device_from_torch``). cuRobo's + pinned commit and ``main`` still call ``wp.torch.*`` and raise + ``AttributeError: module 'warp' has no attribute 'torch'`` at + :meth:`MotionGenConfig.load_from_robot_config` time. The shim + reconstructs ``warp.torch`` as a thin forwarding module and is a + no-op once warp re-introduces the namespace or cuRobo migrates. + Remove this shim once the cuRobo pin in ``docker/Dockerfile.curobo`` + is bumped to a commit that uses the top-level ``wp.*`` API directly. diff --git a/source/isaaclab_mimic/isaaclab_mimic/__init__.py b/source/isaaclab_mimic/isaaclab_mimic/__init__.py index 17f1264a6b59..56b86cc6034d 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/__init__.py +++ b/source/isaaclab_mimic/isaaclab_mimic/__init__.py @@ -5,4 +5,53 @@ """Package containing implementation of Isaac Lab Mimic data generation.""" +# --------------------------------------------------------------------------- +# Compatibility shim: re-expose ``warp.torch`` after warp-lang 1.13 dropped it +# +# Newton ``v1.2.0rc2`` requires ``warp-lang>=1.13``. Warp 1.13 collapsed the +# ``warp.torch`` submodule into the top-level ``warp`` namespace, so e.g. +# ``wp.torch.device_from_torch`` is now ``wp.device_from_torch``. cuRobo +# (NVlabs/curobo) still uses the old ``wp.torch.*`` form (verified at +# ``ebb71702f`` and on ``main`` as of 2026-05-07) and raises +# ``AttributeError: module 'warp' has no attribute 'torch'`` at +# ``MotionGenConfig.load_from_robot_config(...)`` time. +# +# This shim runs at ``isaaclab_mimic`` import — which Python evaluates before +# any submodule, including +# :mod:`isaaclab_mimic.motion_planners.curobo.curobo_planner` — so curobo +# sees a ``warp.torch`` namespace whose members forward to the relocated +# top-level ``warp.*`` callables. Idempotent: a no-op once warp ships +# ``wp.torch`` again or curobo migrates. +# +# TODO: remove this shim once the cuRobo pin in ``docker/Dockerfile.curobo`` +# bumps to a commit that uses ``wp.from_torch``/``wp.device_from_torch``/ +# etc. directly. Tracking upstream at https://github.com/NVlabs/curobo — +# follow up on the open issue / PR there to confirm the migration landed +# before deleting this block. +import sys as _sys +import types as _types + +import warp as _wp + +if not hasattr(_wp, "torch"): + _wp_torch_shim = _types.ModuleType("warp.torch") + for _name in ( + "from_torch", + "to_torch", + "device_from_torch", + "device_to_torch", + "dtype_from_torch", + "dtype_to_torch", + "stream_from_torch", + "stream_to_torch", + ): + if hasattr(_wp, _name): + setattr(_wp_torch_shim, _name, getattr(_wp, _name)) + _wp.torch = _wp_torch_shim + _sys.modules["warp.torch"] = _wp_torch_shim + del _wp_torch_shim, _name + +del _sys, _types, _wp + + __version__ = "1.0.0" diff --git a/source/isaaclab_newton/changelog.d/jichuanh-newton-rc2-bump.minor.rst b/source/isaaclab_newton/changelog.d/jichuanh-newton-rc2-bump.minor.rst new file mode 100644 index 000000000000..25985f94b4b2 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/jichuanh-newton-rc2-bump.minor.rst @@ -0,0 +1,33 @@ +Changed +^^^^^^^ + +* Bumped Newton pin to ``v1.2.0rc2``. Pulls in IsaacLab-relevant fixes from + `newton-physics/newton#2678 `_ + and `newton-physics/newton#2720 + `_ (``SolverKamino`` + reset under ``world_mask``), the upstream tendon-scoping fix from + `newton-physics/newton#2659 + `_ ("Scope USD + custom-frequency parsing"), and a VRAM-leak fix on example reset + (`newton-physics/newton#2710 + `_). +* Newton ``v1.2.0rc2`` requires ``warp-lang==1.13.0``, ``mujoco==3.8.0``, + and ``mujoco-warp==3.8.0.1``. ``warp-lang``/``mujoco``/``mujoco-warp`` + pins live in :mod:`isaaclab` and ``tools/wheel_builder/res/python_packages.toml``; + the Newton pin is mirrored across :mod:`isaaclab_newton`, + :mod:`isaaclab_visualizers` (3×), :mod:`isaaclab_physx` (``[newton]`` + extra), and the wheel-builder TOML. +* Updated ``wp.math.transform_to_matrix`` to ``wp.transform_to_matrix`` in + :mod:`~isaaclab_newton.physics.newton_manager` and + :mod:`~isaaclab_ov.renderers.ovrtx_renderer_kernels` to match the + ``warp-lang`` 1.13 API (the ``wp.math`` namespace was removed). +* Adapted :class:`~isaaclab_newton.renderers.NewtonWarpRenderer` to + Newton ``v1.2.0rc2``'s explicit shape-BVH lifecycle. + :meth:`~newton.sensors.SensorTiledCamera.update` no longer auto-builds + the BVH when a non-``None`` state is passed and the underlying + ``RenderContext.render`` now raises ``RuntimeError("build_bvh_shape() + must be called before rendering shapes.")`` if it was never built. The + renderer now calls ``newton.geometry.build_bvh_shape`` once after + sensor construction and ``newton.geometry.refit_bvh_shape`` each frame + before :meth:`~newton.sensors.SensorTiledCamera.update`, since env + body poses move every step. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 2bb2f1f37397..dbc85e97c270 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -69,7 +69,7 @@ def _set_fabric_transforms( i = int(wp.tid()) idx = int(newton_indices[i]) transform = newton_body_q[idx] - fabric_transforms[i] = wp.transpose(wp.mat44d(wp.math.transform_to_matrix(transform))) + fabric_transforms[i] = wp.transpose(wp.mat44d(wp.transform_to_matrix(transform))) @wp.kernel(enable_backward=False) 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 a02d820f2951..78285636b6ef 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py @@ -172,6 +172,14 @@ def __init__(self, cfg: NewtonWarpRendererCfg): ), ) + # Newton ``v1.2.0rc2`` made shape-BVH construction explicit; ``SensorTiledCamera.update`` + # no longer auto-builds when a non-``None`` state is passed, and the underlying + # ``RenderContext.render`` raises if ``build_bvh_shape`` was never called for the model. + # Build it once per model — idempotent across multiple sensors that share ``newton_model`` + # because subsequent calls overwrite the same model-level BVH attributes. + if newton_model.shape_count > 0 and newton_model.bvh_shapes is None: + newton.geometry.build_bvh_shape(newton_model, newton_model.state()) + if cfg.create_default_light: self.newton_sensor.utils.create_default_light(enable_shadows=cfg.enable_shadows) @@ -220,8 +228,13 @@ def update_camera( def render(self, render_data: RenderData): """Render and write to output buffers. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.render`.""" + newton_state = self.get_scene_data_provider().get_newton_state() + # Refit the shape BVH against the current state since env body poses move every frame. + # ``build_bvh_shape`` ran once in ``__init__``; ``refit_bvh_shape`` reuses that topology. + if self.newton_sensor.model.shape_count > 0: + newton.geometry.refit_bvh_shape(self.newton_sensor.model, newton_state) self.newton_sensor.update( - self.get_scene_data_provider().get_newton_state(), + newton_state, render_data.camera_transforms, render_data.camera_rays, color_image=render_data.outputs.color_image, diff --git a/source/isaaclab_newton/setup.py b/source/isaaclab_newton/setup.py index 2e0b87f17543..4621e77f879b 100644 --- a/source/isaaclab_newton/setup.py +++ b/source/isaaclab_newton/setup.py @@ -38,10 +38,10 @@ def run(self): EXTRAS_REQUIRE = { "all": [ "prettytable==3.3.0", - "mujoco==3.6.0", - "mujoco-warp==3.6.0", + "mujoco==3.8.0", + "mujoco-warp==3.8.0.1", "PyOpenGL-accelerate==3.1.10", - "newton @ git+https://github.com/newton-physics/newton.git@a27277ed49d6f307b8a1e4c394be7e1d14965a62", + "newton @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", ], } diff --git a/source/isaaclab_ov/changelog.d/jichuanh-newton-rc2-bump.rst b/source/isaaclab_ov/changelog.d/jichuanh-newton-rc2-bump.rst new file mode 100644 index 000000000000..3609cf6cd787 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/jichuanh-newton-rc2-bump.rst @@ -0,0 +1,23 @@ +Changed +^^^^^^^ + +* Bumped Newton pin to ``v1.2.0rc2``. Pulls in IsaacLab-relevant fixes from + `newton-physics/newton#2678 `_ + and `newton-physics/newton#2720 + `_ (``SolverKamino`` + reset under ``world_mask``), the upstream tendon-scoping fix from + `newton-physics/newton#2659 + `_ ("Scope USD + custom-frequency parsing"), and a VRAM-leak fix on example reset + (`newton-physics/newton#2710 + `_). +* Newton ``v1.2.0rc2`` requires ``warp-lang==1.13.0``, ``mujoco==3.8.0``, + and ``mujoco-warp==3.8.0.1``. ``warp-lang``/``mujoco``/``mujoco-warp`` + pins live in :mod:`isaaclab` and ``tools/wheel_builder/res/python_packages.toml``; + the Newton pin is mirrored across :mod:`isaaclab_newton`, + :mod:`isaaclab_visualizers` (3×), :mod:`isaaclab_physx` (``[newton]`` + extra), and the wheel-builder TOML. +* Updated ``wp.math.transform_to_matrix`` to ``wp.transform_to_matrix`` in + :mod:`~isaaclab_newton.physics.newton_manager` and + :mod:`~isaaclab_ov.renderers.ovrtx_renderer_kernels` to match the + ``warp-lang`` 1.13 API (the ``wp.math`` namespace was removed). diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py index c287f1257632..0c1626916414 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py @@ -327,4 +327,4 @@ def sync_newton_transforms_kernel( i = wp.tid() body_idx = newton_body_indices[i] transform = newton_body_q[body_idx] - ovrtx_transforms[i] = wp.transpose(wp.mat44d(wp.math.transform_to_matrix(transform))) + ovrtx_transforms[i] = wp.transpose(wp.mat44d(wp.transform_to_matrix(transform))) diff --git a/source/isaaclab_physx/changelog.d/jichuanh-newton-rc2-bump.rst b/source/isaaclab_physx/changelog.d/jichuanh-newton-rc2-bump.rst new file mode 100644 index 000000000000..74bca94ff983 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/jichuanh-newton-rc2-bump.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Bumped the optional ``[newton]`` extra to ``v1.2.0rc2`` so the Newton + scene representation built by + :class:`~isaaclab_physx.scene_data_providers.PhysxSceneDataProvider` + for the OV/Rerun/Viser visualizers stays in sync with the version + pinned in :mod:`isaaclab_newton` and :mod:`isaaclab_visualizers`. diff --git a/source/isaaclab_physx/setup.py b/source/isaaclab_physx/setup.py index 1e917e938c2b..9cc172addf50 100644 --- a/source/isaaclab_physx/setup.py +++ b/source/isaaclab_physx/setup.py @@ -20,7 +20,7 @@ EXTRAS_REQUIRE = { "newton": [ - "newton @ git+https://github.com/newton-physics/newton.git@2684d75bfa4bb8b058a93b81c458a74b7701c997", + "newton @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", ], } diff --git a/source/isaaclab_visualizers/setup.py b/source/isaaclab_visualizers/setup.py index fc120619787b..9ad52a712360 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@a27277ed49d6f307b8a1e4c394be7e1d14965a62", + "newton @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", "PyOpenGL-accelerate", "imgui-bundle>=1.92.5", ], "rerun": [ - "newton @ git+https://github.com/newton-physics/newton.git@a27277ed49d6f307b8a1e4c394be7e1d14965a62", + "newton @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", "rerun-sdk>=0.29.0", ], "viser": [ - "newton @ git+https://github.com/newton-physics/newton.git@a27277ed49d6f307b8a1e4c394be7e1d14965a62", + "newton @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", "viser>=1.0.16", ], } diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index 1676fdc8f905..285d1cddbc66 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -22,7 +22,7 @@ pyproject.dependencies.all = [ # image processing "transformers==4.57.6", "einops", # needed for transformers, doesn't always auto-install - "warp-lang==1.12.0", + "warp-lang==1.13.0", "matplotlib>=3.10.3", # make sure this is consistent with isaac sim version "pillow==12.1.1", @@ -82,10 +82,10 @@ pyproject.optional-dependencies.all = [ # https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_newton/setup.py # ================================================================================ { "newton" = [ - "warp-lang==1.12.0", - "mujoco==3.6.0", - "mujoco-warp==3.6.0", - "newton @ git+https://github.com/newton-physics/newton.git@a27277ed49d6f307b8a1e4c394be7e1d14965a62", + "warp-lang==1.13.0", + "mujoco==3.8.0", + "mujoco-warp==3.8.0.1", + "newton @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", "PyOpenGL-accelerate==3.1.10" ] }, # ================================================================================ From 99b0359aa290a0ad246ba0e2cbc7481d63bbd159 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 05:44:17 +0000 Subject: [PATCH 012/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 4.7.0 → 4.8.0 - isaaclab_mimic: 1.2.5 → 1.2.6 - isaaclab_newton: 0.6.0 → 0.7.0 - isaaclab_ov: 0.1.4 → 0.1.5 - isaaclab_physx: 0.6.0 → 0.6.1 --- .../jichuanh-newton-rc2-bump.minor.rst | 23 ----------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 28 ++++++++++++++ .../changelog.d/jichuanh-newton-rc2-bump.rst | 15 -------- source/isaaclab_mimic/config/extension.toml | 2 +- source/isaaclab_mimic/docs/CHANGELOG.rst | 20 ++++++++++ .../jichuanh-newton-rc2-bump.minor.rst | 33 ---------------- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 38 +++++++++++++++++++ .../changelog.d/jichuanh-newton-rc2-bump.rst | 23 ----------- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 28 ++++++++++++++ .../changelog.d/jichuanh-newton-rc2-bump.rst | 8 ---- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 13 +++++++ 15 files changed, 132 insertions(+), 107 deletions(-) delete mode 100644 source/isaaclab/changelog.d/jichuanh-newton-rc2-bump.minor.rst delete mode 100644 source/isaaclab_mimic/changelog.d/jichuanh-newton-rc2-bump.rst delete mode 100644 source/isaaclab_newton/changelog.d/jichuanh-newton-rc2-bump.minor.rst delete mode 100644 source/isaaclab_ov/changelog.d/jichuanh-newton-rc2-bump.rst delete mode 100644 source/isaaclab_physx/changelog.d/jichuanh-newton-rc2-bump.rst diff --git a/source/isaaclab/changelog.d/jichuanh-newton-rc2-bump.minor.rst b/source/isaaclab/changelog.d/jichuanh-newton-rc2-bump.minor.rst deleted file mode 100644 index 3609cf6cd787..000000000000 --- a/source/isaaclab/changelog.d/jichuanh-newton-rc2-bump.minor.rst +++ /dev/null @@ -1,23 +0,0 @@ -Changed -^^^^^^^ - -* Bumped Newton pin to ``v1.2.0rc2``. Pulls in IsaacLab-relevant fixes from - `newton-physics/newton#2678 `_ - and `newton-physics/newton#2720 - `_ (``SolverKamino`` - reset under ``world_mask``), the upstream tendon-scoping fix from - `newton-physics/newton#2659 - `_ ("Scope USD - custom-frequency parsing"), and a VRAM-leak fix on example reset - (`newton-physics/newton#2710 - `_). -* Newton ``v1.2.0rc2`` requires ``warp-lang==1.13.0``, ``mujoco==3.8.0``, - and ``mujoco-warp==3.8.0.1``. ``warp-lang``/``mujoco``/``mujoco-warp`` - pins live in :mod:`isaaclab` and ``tools/wheel_builder/res/python_packages.toml``; - the Newton pin is mirrored across :mod:`isaaclab_newton`, - :mod:`isaaclab_visualizers` (3×), :mod:`isaaclab_physx` (``[newton]`` - extra), and the wheel-builder TOML. -* Updated ``wp.math.transform_to_matrix`` to ``wp.transform_to_matrix`` in - :mod:`~isaaclab_newton.physics.newton_manager` and - :mod:`~isaaclab_ov.renderers.ovrtx_renderer_kernels` to match the - ``warp-lang`` 1.13 API (the ``wp.math`` namespace was removed). diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index e1620d629745..ce77d89f4c45 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.7.0" +version = "4.8.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 1bb6a29060fa..152979d60df4 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,34 @@ Changelog --------- +4.8.0 (2026-05-08) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Bumped Newton pin to ``v1.2.0rc2``. Pulls in IsaacLab-relevant fixes from + `newton-physics/newton#2678 `_ + and `newton-physics/newton#2720 + `_ (``SolverKamino`` + reset under ``world_mask``), the upstream tendon-scoping fix from + `newton-physics/newton#2659 + `_ ("Scope USD + custom-frequency parsing"), and a VRAM-leak fix on example reset + (`newton-physics/newton#2710 + `_). +* Newton ``v1.2.0rc2`` requires ``warp-lang==1.13.0``, ``mujoco==3.8.0``, + and ``mujoco-warp==3.8.0.1``. ``warp-lang``/``mujoco``/``mujoco-warp`` + pins live in :mod:`isaaclab` and ``tools/wheel_builder/res/python_packages.toml``; + the Newton pin is mirrored across :mod:`isaaclab_newton`, + :mod:`isaaclab_visualizers` (3×), :mod:`isaaclab_physx` (``[newton]`` + extra), and the wheel-builder TOML. +* Updated ``wp.math.transform_to_matrix`` to ``wp.transform_to_matrix`` in + :mod:`~isaaclab_newton.physics.newton_manager` and + :mod:`~isaaclab_ov.renderers.ovrtx_renderer_kernels` to match the + ``warp-lang`` 1.13 API (the ``wp.math`` namespace was removed). + + 4.7.0 (2026-05-08) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_mimic/changelog.d/jichuanh-newton-rc2-bump.rst b/source/isaaclab_mimic/changelog.d/jichuanh-newton-rc2-bump.rst deleted file mode 100644 index 051f04fae9f2..000000000000 --- a/source/isaaclab_mimic/changelog.d/jichuanh-newton-rc2-bump.rst +++ /dev/null @@ -1,15 +0,0 @@ -Added -^^^^^ - -* Added a temporary ``warp.torch`` compatibility shim at - :mod:`isaaclab_mimic` import time so that cuRobo (NVlabs/curobo) keeps - working with ``warp-lang>=1.13``, which dropped the ``warp.torch`` - submodule in favour of top-level ``warp.*`` (e.g. - ``wp.torch.device_from_torch`` → ``wp.device_from_torch``). cuRobo's - pinned commit and ``main`` still call ``wp.torch.*`` and raise - ``AttributeError: module 'warp' has no attribute 'torch'`` at - :meth:`MotionGenConfig.load_from_robot_config` time. The shim - reconstructs ``warp.torch`` as a thin forwarding module and is a - no-op once warp re-introduces the namespace or cuRobo migrates. - Remove this shim once the cuRobo pin in ``docker/Dockerfile.curobo`` - is bumped to a commit that uses the top-level ``wp.*`` API directly. diff --git a/source/isaaclab_mimic/config/extension.toml b/source/isaaclab_mimic/config/extension.toml index f53461c3fc6f..6646522f5f1e 100644 --- a/source/isaaclab_mimic/config/extension.toml +++ b/source/isaaclab_mimic/config/extension.toml @@ -1,7 +1,7 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "1.2.5" +version = "1.2.6" # Description category = "isaaclab" diff --git a/source/isaaclab_mimic/docs/CHANGELOG.rst b/source/isaaclab_mimic/docs/CHANGELOG.rst index 782cf6f7d220..08da411a579c 100644 --- a/source/isaaclab_mimic/docs/CHANGELOG.rst +++ b/source/isaaclab_mimic/docs/CHANGELOG.rst @@ -1,6 +1,26 @@ Changelog --------- +1.2.6 (2026-05-08) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added a temporary ``warp.torch`` compatibility shim at + :mod:`isaaclab_mimic` import time so that cuRobo (NVlabs/curobo) keeps + working with ``warp-lang>=1.13``, which dropped the ``warp.torch`` + submodule in favour of top-level ``warp.*`` (e.g. + ``wp.torch.device_from_torch`` → ``wp.device_from_torch``). cuRobo's + pinned commit and ``main`` still call ``wp.torch.*`` and raise + ``AttributeError: module 'warp' has no attribute 'torch'`` at + :meth:`MotionGenConfig.load_from_robot_config` time. The shim + reconstructs ``warp.torch`` as a thin forwarding module and is a + no-op once warp re-introduces the namespace or cuRobo migrates. + Remove this shim once the cuRobo pin in ``docker/Dockerfile.curobo`` + is bumped to a commit that uses the top-level ``wp.*`` API directly. + + 1.2.5 (2026-04-14) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/changelog.d/jichuanh-newton-rc2-bump.minor.rst b/source/isaaclab_newton/changelog.d/jichuanh-newton-rc2-bump.minor.rst deleted file mode 100644 index 25985f94b4b2..000000000000 --- a/source/isaaclab_newton/changelog.d/jichuanh-newton-rc2-bump.minor.rst +++ /dev/null @@ -1,33 +0,0 @@ -Changed -^^^^^^^ - -* Bumped Newton pin to ``v1.2.0rc2``. Pulls in IsaacLab-relevant fixes from - `newton-physics/newton#2678 `_ - and `newton-physics/newton#2720 - `_ (``SolverKamino`` - reset under ``world_mask``), the upstream tendon-scoping fix from - `newton-physics/newton#2659 - `_ ("Scope USD - custom-frequency parsing"), and a VRAM-leak fix on example reset - (`newton-physics/newton#2710 - `_). -* Newton ``v1.2.0rc2`` requires ``warp-lang==1.13.0``, ``mujoco==3.8.0``, - and ``mujoco-warp==3.8.0.1``. ``warp-lang``/``mujoco``/``mujoco-warp`` - pins live in :mod:`isaaclab` and ``tools/wheel_builder/res/python_packages.toml``; - the Newton pin is mirrored across :mod:`isaaclab_newton`, - :mod:`isaaclab_visualizers` (3×), :mod:`isaaclab_physx` (``[newton]`` - extra), and the wheel-builder TOML. -* Updated ``wp.math.transform_to_matrix`` to ``wp.transform_to_matrix`` in - :mod:`~isaaclab_newton.physics.newton_manager` and - :mod:`~isaaclab_ov.renderers.ovrtx_renderer_kernels` to match the - ``warp-lang`` 1.13 API (the ``wp.math`` namespace was removed). -* Adapted :class:`~isaaclab_newton.renderers.NewtonWarpRenderer` to - Newton ``v1.2.0rc2``'s explicit shape-BVH lifecycle. - :meth:`~newton.sensors.SensorTiledCamera.update` no longer auto-builds - the BVH when a non-``None`` state is passed and the underlying - ``RenderContext.render`` now raises ``RuntimeError("build_bvh_shape() - must be called before rendering shapes.")`` if it was never built. The - renderer now calls ``newton.geometry.build_bvh_shape`` once after - sensor construction and ``newton.geometry.refit_bvh_shape`` each frame - before :meth:`~newton.sensors.SensorTiledCamera.update`, since env - body poses move every step. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 4837c3f7ae03..8ddb2526072e 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.6.0" +version = "0.7.0" # 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 0f81a9effc43..afde5c8d2ec5 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,44 @@ Changelog --------- +0.7.0 (2026-05-08) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Bumped Newton pin to ``v1.2.0rc2``. Pulls in IsaacLab-relevant fixes from + `newton-physics/newton#2678 `_ + and `newton-physics/newton#2720 + `_ (``SolverKamino`` + reset under ``world_mask``), the upstream tendon-scoping fix from + `newton-physics/newton#2659 + `_ ("Scope USD + custom-frequency parsing"), and a VRAM-leak fix on example reset + (`newton-physics/newton#2710 + `_). +* Newton ``v1.2.0rc2`` requires ``warp-lang==1.13.0``, ``mujoco==3.8.0``, + and ``mujoco-warp==3.8.0.1``. ``warp-lang``/``mujoco``/``mujoco-warp`` + pins live in :mod:`isaaclab` and ``tools/wheel_builder/res/python_packages.toml``; + the Newton pin is mirrored across :mod:`isaaclab_newton`, + :mod:`isaaclab_visualizers` (3×), :mod:`isaaclab_physx` (``[newton]`` + extra), and the wheel-builder TOML. +* Updated ``wp.math.transform_to_matrix`` to ``wp.transform_to_matrix`` in + :mod:`~isaaclab_newton.physics.newton_manager` and + :mod:`~isaaclab_ov.renderers.ovrtx_renderer_kernels` to match the + ``warp-lang`` 1.13 API (the ``wp.math`` namespace was removed). +* Adapted :class:`~isaaclab_newton.renderers.NewtonWarpRenderer` to + Newton ``v1.2.0rc2``'s explicit shape-BVH lifecycle. + :meth:`~newton.sensors.SensorTiledCamera.update` no longer auto-builds + the BVH when a non-``None`` state is passed and the underlying + ``RenderContext.render`` now raises ``RuntimeError("build_bvh_shape() + must be called before rendering shapes.")`` if it was never built. The + renderer now calls ``newton.geometry.build_bvh_shape`` once after + sensor construction and ``newton.geometry.refit_bvh_shape`` each frame + before :meth:`~newton.sensors.SensorTiledCamera.update`, since env + body poses move every step. + + 0.6.0 (2026-05-08) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/changelog.d/jichuanh-newton-rc2-bump.rst b/source/isaaclab_ov/changelog.d/jichuanh-newton-rc2-bump.rst deleted file mode 100644 index 3609cf6cd787..000000000000 --- a/source/isaaclab_ov/changelog.d/jichuanh-newton-rc2-bump.rst +++ /dev/null @@ -1,23 +0,0 @@ -Changed -^^^^^^^ - -* Bumped Newton pin to ``v1.2.0rc2``. Pulls in IsaacLab-relevant fixes from - `newton-physics/newton#2678 `_ - and `newton-physics/newton#2720 - `_ (``SolverKamino`` - reset under ``world_mask``), the upstream tendon-scoping fix from - `newton-physics/newton#2659 - `_ ("Scope USD - custom-frequency parsing"), and a VRAM-leak fix on example reset - (`newton-physics/newton#2710 - `_). -* Newton ``v1.2.0rc2`` requires ``warp-lang==1.13.0``, ``mujoco==3.8.0``, - and ``mujoco-warp==3.8.0.1``. ``warp-lang``/``mujoco``/``mujoco-warp`` - pins live in :mod:`isaaclab` and ``tools/wheel_builder/res/python_packages.toml``; - the Newton pin is mirrored across :mod:`isaaclab_newton`, - :mod:`isaaclab_visualizers` (3×), :mod:`isaaclab_physx` (``[newton]`` - extra), and the wheel-builder TOML. -* Updated ``wp.math.transform_to_matrix`` to ``wp.transform_to_matrix`` in - :mod:`~isaaclab_newton.physics.newton_manager` and - :mod:`~isaaclab_ov.renderers.ovrtx_renderer_kernels` to match the - ``warp-lang`` 1.13 API (the ``wp.math`` namespace was removed). diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 88756787ecc1..63cda50eb5c0 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.1.4" +version = "0.1.5" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index 9d558d295876..d1af152a61e5 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,34 @@ Changelog --------- +0.1.5 (2026-05-08) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Bumped Newton pin to ``v1.2.0rc2``. Pulls in IsaacLab-relevant fixes from + `newton-physics/newton#2678 `_ + and `newton-physics/newton#2720 + `_ (``SolverKamino`` + reset under ``world_mask``), the upstream tendon-scoping fix from + `newton-physics/newton#2659 + `_ ("Scope USD + custom-frequency parsing"), and a VRAM-leak fix on example reset + (`newton-physics/newton#2710 + `_). +* Newton ``v1.2.0rc2`` requires ``warp-lang==1.13.0``, ``mujoco==3.8.0``, + and ``mujoco-warp==3.8.0.1``. ``warp-lang``/``mujoco``/``mujoco-warp`` + pins live in :mod:`isaaclab` and ``tools/wheel_builder/res/python_packages.toml``; + the Newton pin is mirrored across :mod:`isaaclab_newton`, + :mod:`isaaclab_visualizers` (3×), :mod:`isaaclab_physx` (``[newton]`` + extra), and the wheel-builder TOML. +* Updated ``wp.math.transform_to_matrix`` to ``wp.transform_to_matrix`` in + :mod:`~isaaclab_newton.physics.newton_manager` and + :mod:`~isaaclab_ov.renderers.ovrtx_renderer_kernels` to match the + ``warp-lang`` 1.13 API (the ``wp.math`` namespace was removed). + + 0.1.4 (2026-05-08) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/changelog.d/jichuanh-newton-rc2-bump.rst b/source/isaaclab_physx/changelog.d/jichuanh-newton-rc2-bump.rst deleted file mode 100644 index 74bca94ff983..000000000000 --- a/source/isaaclab_physx/changelog.d/jichuanh-newton-rc2-bump.rst +++ /dev/null @@ -1,8 +0,0 @@ -Changed -^^^^^^^ - -* Bumped the optional ``[newton]`` extra to ``v1.2.0rc2`` so the Newton - scene representation built by - :class:`~isaaclab_physx.scene_data_providers.PhysxSceneDataProvider` - for the OV/Rerun/Viser visualizers stays in sync with the version - pinned in :mod:`isaaclab_newton` and :mod:`isaaclab_visualizers`. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 2e6fbc7360fc..00264e1238ff 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.6.0" +version = "0.6.1" # 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 f99368bd5be5..bc487f3190ab 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,19 @@ Changelog --------- +0.6.1 (2026-05-08) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Bumped the optional ``[newton]`` extra to ``v1.2.0rc2`` so the Newton + scene representation built by + :class:`~isaaclab_physx.scene_data_providers.PhysxSceneDataProvider` + for the OV/Rerun/Viser visualizers stays in sync with the version + pinned in :mod:`isaaclab_newton` and :mod:`isaaclab_visualizers`. + + 0.6.0 (2026-05-08) ~~~~~~~~~~~~~~~~~~ From af9c98fa1c17015b111f1f6264819a3c0499194a Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 8 May 2026 09:25:03 +0200 Subject: [PATCH 013/133] Fixes joint friction API docs (#5533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Clarifies the articulation joint friction API docs across the base, PhysX, and Newton implementations. The base API now warns that joint friction semantics are backend-specific. The PhysX docs distinguish legacy unitless coefficients from PhysX 5 static/dynamic friction efforts and viscous coefficients. The Newton docs now identify joint friction as an absolute force/torque value and include an MJWarp example mapping the value to MuJoCo Warp's `dof_frictionloss`. Fixes isaac-sim/IsaacLab-Internal#875 ## Type of change - Documentation update ## Screenshots Not applicable. ## Checklist - [ ] 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 (not applicable: docs-only change) - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../antoiner-docs-joint-friction.rst | 4 + .../assets/articulation/base_articulation.py | 22 ++--- .../articulation/base_articulation_data.py | 7 +- .../antoiner-docs-joint-friction.rst | 5 ++ .../assets/articulation/articulation.py | 32 ++++++- .../assets/articulation/articulation_data.py | 9 +- .../antoiner-docs-joint-friction.rst | 5 ++ .../assets/articulation/articulation.py | 90 ++++++++++++------- .../assets/articulation/articulation_data.py | 11 ++- 9 files changed, 135 insertions(+), 50 deletions(-) create mode 100644 source/isaaclab/changelog.d/antoiner-docs-joint-friction.rst create mode 100644 source/isaaclab_newton/changelog.d/antoiner-docs-joint-friction.rst create mode 100644 source/isaaclab_physx/changelog.d/antoiner-docs-joint-friction.rst diff --git a/source/isaaclab/changelog.d/antoiner-docs-joint-friction.rst b/source/isaaclab/changelog.d/antoiner-docs-joint-friction.rst new file mode 100644 index 000000000000..4b7e63eeb995 --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-docs-joint-friction.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab.assets.Articulation` joint friction API docs to clarify backend-specific semantics. diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py index 3cd9e33376db..dc9ddc6cb7ad 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py @@ -1067,11 +1067,12 @@ def write_joint_friction_coefficient_to_sim_index( joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - r"""Write joint static friction coefficients into the simulation. + r"""Write backend-specific joint friction values into the simulation. - The joint static friction is a unitless quantity. It relates the magnitude of the spatial force transmitted - from the parent body to the child body to the maximal static friction force that may be applied by the solver - to resist the joint motion. + .. warning:: + The physical meaning and units of joint friction depend on the concrete backend and solver. Do not assume + values are comparable across backends; check the backend-specific implementation before interpreting or + reusing them. .. note:: This method expects partial data. @@ -1081,7 +1082,7 @@ def write_joint_friction_coefficient_to_sim_index( Some backends may provide optimized implementations for masks / indices. Args: - joint_friction_coeff: Joint static friction coefficient. Shape is (len(env_ids), len(joint_ids)). + joint_friction_coeff: Backend-specific joint friction values. Shape is (len(env_ids), len(joint_ids)). joint_ids: The joint indices to set the joint torque limits for. Defaults to None (all joints). env_ids: The environment indices to set the joint torque limits for. Defaults to None (all instances). """ @@ -1095,11 +1096,12 @@ def write_joint_friction_coefficient_to_sim_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - r"""Write joint static friction coefficients into the simulation. + r"""Write backend-specific joint friction values into the simulation. - The joint static friction is a unitless quantity. It relates the magnitude of the spatial force transmitted - from the parent body to the child body to the maximal static friction force that may be applied by the solver - to resist the joint motion. + .. warning:: + The physical meaning and units of joint friction depend on the concrete backend and solver. Do not assume + values are comparable across backends; check the backend-specific implementation before interpreting or + reusing them. .. note:: This method expects full data. @@ -1109,7 +1111,7 @@ def write_joint_friction_coefficient_to_sim_mask( Some backends may provide optimized implementations for masks / indices. Args: - joint_friction_coeff: Joint static friction coefficient. Shape is (num_instances, num_joints). + joint_friction_coeff: Backend-specific joint friction values. Shape is (num_instances, num_joints). joint_mask: Joint mask. If None, then all the joints are updated. Shape is (num_joints,). env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py index 1a2dc5f1b278..b902db7d29bb 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py @@ -248,9 +248,14 @@ def joint_armature(self) -> ProxyArray: @abstractmethod @leapp_tensor_semantics(const=True) def joint_friction_coeff(self) -> ProxyArray: - """Joint static friction coefficient provided to the simulation. + """Backend-specific joint friction values provided to the simulation. Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). + + .. warning:: + The physical meaning and units of this value depend on the concrete backend and solver. Do not assume + values are comparable across backends; check the backend-specific :class:`ArticulationData` + implementation before interpreting or reusing them. """ raise NotImplementedError diff --git a/source/isaaclab_newton/changelog.d/antoiner-docs-joint-friction.rst b/source/isaaclab_newton/changelog.d/antoiner-docs-joint-friction.rst new file mode 100644 index 000000000000..e84764b739c9 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/antoiner-docs-joint-friction.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_newton.assets.Articulation` joint friction docs to identify Newton friction as a force or + torque value instead of a unitless coefficient. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index c3c6eca044f7..ff04b96c63ca 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -1964,7 +1964,19 @@ def write_joint_friction_coefficient_to_sim_index( joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ): - r"""Write joint friction coefficients over selected environment indices into the simulation. + r"""Write Newton joint friction force/torque values over selected environment indices into the simulation. + + This writes to Newton's ``Model.joint_friction`` field. Despite the ``coeff`` suffix in the Isaac Lab API + name, Newton treats this value as an absolute friction force/torque [N or N·m, depending on joint type], not + as a unitless coefficient. + + For example, the MJWarp solver copies this value into MuJoCo Warp's ``dof_frictionloss``. Setting + ``joint_friction_coeff`` to 0.2 configures a dry-friction loss limit of 0.2 N·m on a revolute joint DOF, + or 0.2 N on a prismatic joint DOF. + + .. note:: + Solver support is defined by the active Newton solver. Unsupported solvers may ignore + ``Model.joint_friction``. .. note:: This method expects partial data. @@ -1974,7 +1986,7 @@ def write_joint_friction_coefficient_to_sim_index( However, to allow graphed pipelines, the mask method must be used. Args: - joint_friction_coeff: Static friction coefficient :math:`\mu_s`. + joint_friction_coeff: Joint friction force/torque [N or N·m, depending on joint type]. Shape is (len(env_ids), len(joint_ids)). joint_ids: Joint indices. If None, then all joints are used. env_ids: Environment indices. If None, then all indices are used. @@ -2024,7 +2036,19 @@ def write_joint_friction_coefficient_to_sim_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ): - r"""Write joint friction coefficients over selected environment mask into the simulation. + r"""Write Newton joint friction force/torque values over selected environment mask into the simulation. + + This writes to Newton's ``Model.joint_friction`` field. Despite the ``coeff`` suffix in the Isaac Lab API + name, Newton treats this value as an absolute friction force/torque [N or N·m, depending on joint type], not + as a unitless coefficient. + + For example, the MJWarp solver copies this value into MuJoCo Warp's ``dof_frictionloss``. Setting + ``joint_friction_coeff`` to 0.2 configures a dry-friction loss limit of 0.2 N·m on a revolute joint DOF, + or 0.2 N on a prismatic joint DOF. + + .. note:: + Solver support is defined by the active Newton solver. Unsupported solvers may ignore + ``Model.joint_friction``. .. note:: This method expects full data. @@ -2034,7 +2058,7 @@ def write_joint_friction_coefficient_to_sim_mask( However, to allow graphed pipelines, the mask method must be used. Args: - joint_friction_coeff: Static friction coefficient :math:`\mu_s`. + joint_friction_coeff: Joint friction force/torque [N or N·m, depending on joint type]. Shape is (num_instances, num_joints). joint_mask: Joint mask. If None, then all joints are used. Shape is (num_joints,). env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py index a22ba73e1725..2da95b49b21d 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -342,7 +342,14 @@ def joint_armature(self) -> ProxyArray: @property def joint_friction_coeff(self) -> ProxyArray: - """Joint static friction coefficient provided to the simulation. + """Newton joint friction force/torque provided to the simulation. + + Despite the ``coeff`` suffix in the Isaac Lab API name, Newton stores this as an absolute joint friction + force/torque [N or N·m, depending on joint type]. + + For example, the MJWarp solver copies this value into MuJoCo Warp's ``dof_frictionloss``. Setting + ``joint_friction_coeff`` to 0.2 configures a dry-friction loss limit of 0.2 N·m on a revolute joint DOF, + or 0.2 N on a prismatic joint DOF. Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). """ diff --git a/source/isaaclab_physx/changelog.d/antoiner-docs-joint-friction.rst b/source/isaaclab_physx/changelog.d/antoiner-docs-joint-friction.rst new file mode 100644 index 000000000000..652271d896b1 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/antoiner-docs-joint-friction.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_physx.assets.Articulation` joint friction docs to distinguish legacy coefficients from + PhysX 5 static and dynamic friction efforts. diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index 913914e29f30..6258b5c5b8e4 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -1684,16 +1684,23 @@ def write_joint_friction_coefficient_to_sim_index( ): r"""Write joint friction coefficients over selected environment indices into the simulation. - For Isaac Sim versions below 5.0, only the static friction coefficient is set. - This limits the resisting force or torque up to a maximum proportional to the transmitted - spatial force: :math:`\|F_{resist}\| \leq \mu_s \, \|F_{spatial}\|`. + For Isaac Sim versions below 5.0, only the legacy unitless joint friction coefficient is set. + This limits the resisting force or torque up to a maximum proportional to the transmitted spatial force: + :math:`\|F_{resist}\| \leq \mu_s \, \|F_{spatial}\|`. - For Isaac Sim versions 5.0 and above, the static, dynamic, and viscous friction coefficients - are set. The model combines Coulomb (static & dynamic) friction with a viscous term: + For Isaac Sim versions 5.0 and above, the PhysX joint friction parameter model is used. It combines + Coulomb (static and dynamic) friction with a viscous term: - - Static friction :math:`\mu_s` defines the maximum effort that prevents motion at rest. - - Dynamic friction :math:`\mu_d` applies once motion begins and remains constant during motion. - - Viscous friction :math:`c_v` is a velocity-proportional resistive term. + - Static friction effort defines the maximum effort that prevents motion at rest [N or N·m, depending on + joint type]. + - Dynamic friction effort applies once motion begins and remains constant during motion [N or N·m, + depending on joint type]. + - Viscous friction coefficient is a velocity-proportional resistive term [N·s/m or N·m·s/rad, depending + on joint type]. + + .. warning:: + For Isaac Sim versions 5.0 and above, the static friction effort must be greater than or equal to the + dynamic friction effort. .. note:: This method expects partial data or full data. @@ -1703,11 +1710,12 @@ def write_joint_friction_coefficient_to_sim_index( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_friction_coeff: Static friction coefficient :math:`\mu_s`. - Shape is (len(env_ids), len(joint_ids)) or (num_instances, num_joints). - joint_dynamic_friction_coeff: Dynamic (Coulomb) friction coefficient :math:`\mu_d`. + joint_friction_coeff: Legacy unitless coefficient for Isaac Sim versions below 5.0, or static friction + effort [N or N·m, depending on joint type] for Isaac Sim versions 5.0 and above. Shape is + (len(env_ids), len(joint_ids)) or (num_instances, num_joints). + joint_dynamic_friction_coeff: Dynamic friction effort [N or N·m, depending on joint type]. Same shape as above. If None, the dynamic coefficient is not updated. - joint_viscous_friction_coeff: Viscous friction coefficient :math:`c_v`. + joint_viscous_friction_coeff: Viscous friction coefficient [N·s/m or N·m·s/rad, depending on joint type]. Same shape as above. If None, the viscous coefficient is not updated. joint_ids: Joint indices. If None, then all joints are used. env_ids: Environment indices. If None, then all indices are used. @@ -1793,16 +1801,23 @@ def write_joint_friction_coefficient_to_sim_mask( ): r"""Write joint friction coefficients over selected environment mask into the simulation. - For Isaac Sim versions below 5.0, only the static friction coefficient is set. - This limits the resisting force or torque up to a maximum proportional to the transmitted - spatial force: :math:`\|F_{resist}\| \leq \mu_s \, \|F_{spatial}\|`. + For Isaac Sim versions below 5.0, only the legacy unitless joint friction coefficient is set. + This limits the resisting force or torque up to a maximum proportional to the transmitted spatial force: + :math:`\|F_{resist}\| \leq \mu_s \, \|F_{spatial}\|`. + + For Isaac Sim versions 5.0 and above, the PhysX joint friction parameter model is used. It combines + Coulomb (static and dynamic) friction with a viscous term: - For Isaac Sim versions 5.0 and above, the static, dynamic, and viscous friction coefficients - are set. The model combines Coulomb (static & dynamic) friction with a viscous term: + - Static friction effort defines the maximum effort that prevents motion at rest [N or N·m, depending on + joint type]. + - Dynamic friction effort applies once motion begins and remains constant during motion [N or N·m, + depending on joint type]. + - Viscous friction coefficient is a velocity-proportional resistive term [N·s/m or N·m·s/rad, depending + on joint type]. - - Static friction :math:`\mu_s` defines the maximum effort that prevents motion at rest. - - Dynamic friction :math:`\mu_d` applies once motion begins and remains constant during motion. - - Viscous friction :math:`c_v` is a velocity-proportional resistive term. + .. warning:: + For Isaac Sim versions 5.0 and above, the static friction effort must be greater than or equal to the + dynamic friction effort. .. note:: This method expects full data. @@ -1812,11 +1827,12 @@ def write_joint_friction_coefficient_to_sim_mask( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_friction_coeff: Static friction coefficient :math:`\mu_s`. - Shape is (num_instances, num_joints). - joint_dynamic_friction_coeff: Dynamic (Coulomb) friction coefficient :math:`\mu_d`. + joint_friction_coeff: Legacy unitless coefficient for Isaac Sim versions below 5.0, or static friction + effort [N or N·m, depending on joint type] for Isaac Sim versions 5.0 and above. Shape is + (num_instances, num_joints). + joint_dynamic_friction_coeff: Dynamic friction effort [N or N·m, depending on joint type]. Same shape as above. If None, the dynamic coefficient is not updated. - joint_viscous_friction_coeff: Viscous friction coefficient :math:`c_v`. + joint_viscous_friction_coeff: Viscous friction coefficient [N·s/m or N·m·s/rad, depending on joint type]. Same shape as above. If None, the viscous coefficient is not updated. joint_mask: Joint mask. If None, then all joints are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). @@ -1842,7 +1858,9 @@ def write_joint_dynamic_friction_coefficient_to_sim_index( env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, full_data: bool = False, ) -> None: - """Write joint dynamic friction coefficient over selected environment indices into the simulation. + """Write joint dynamic friction effort over selected environment indices into the simulation. + + The dynamic friction effort is [N or N·m, depending on joint type]. .. note:: This method expects partial data or full data. @@ -1852,8 +1870,8 @@ def write_joint_dynamic_friction_coefficient_to_sim_index( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_dynamic_friction_coeff: Joint dynamic friction coefficient. Shape is (len(env_ids), len(joint_ids)) - or (num_instances, num_joints) if full_data. + joint_dynamic_friction_coeff: Dynamic friction effort [N or N·m, depending on joint type]. Shape is + (len(env_ids), len(joint_ids)) or (num_instances, num_joints) if full_data. joint_ids: Joint indices. If None, then all joints are used. env_ids: Environment indices. If None, then all indices are used. full_data: Whether to expect full data. Defaults to False. @@ -1907,7 +1925,9 @@ def write_joint_dynamic_friction_coefficient_to_sim_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Write joint dynamic friction coefficient over selected environment mask into the simulation. + """Write joint dynamic friction effort over selected environment mask into the simulation. + + The dynamic friction effort is [N or N·m, depending on joint type]. .. note:: This method expects full data. @@ -1917,7 +1937,8 @@ def write_joint_dynamic_friction_coefficient_to_sim_mask( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_dynamic_friction_coeff: Joint dynamic friction coefficient. Shape is (num_instances, num_joints). + joint_dynamic_friction_coeff: Dynamic friction effort [N or N·m, depending on joint type]. Shape is + (num_instances, num_joints). joint_mask: Joint mask. If None, then all joints are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ @@ -1942,6 +1963,8 @@ def write_joint_viscous_friction_coefficient_to_sim_index( ) -> None: """Write joint viscous friction coefficient over selected environment indices into the simulation. + The coefficient is [N·s/m or N·m·s/rad, depending on joint type]. + .. note:: This method expects partial data or full data. @@ -1950,8 +1973,8 @@ def write_joint_viscous_friction_coefficient_to_sim_index( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_viscous_friction_coeff: Joint viscous friction coefficient. Shape is (len(env_ids), len(joint_ids)) - or (num_instances, num_joints) if full_data. + joint_viscous_friction_coeff: Viscous friction coefficient [N·s/m or N·m·s/rad, depending on joint type]. + Shape is (len(env_ids), len(joint_ids)) or (num_instances, num_joints) if full_data. joint_ids: Joint indices. If None, then all joints are used. env_ids: Environment indices. If None, then all indices are used. full_data: Whether to expect full data. Defaults to False. @@ -2010,6 +2033,8 @@ def write_joint_viscous_friction_coefficient_to_sim_mask( ) -> None: """Write joint viscous friction coefficient over selected environment mask into the simulation. + The coefficient is [N·s/m or N·m·s/rad, depending on joint type]. + .. note:: This method expects full data. @@ -2018,7 +2043,8 @@ def write_joint_viscous_friction_coefficient_to_sim_mask( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_viscous_friction_coeff: Joint viscous friction coefficient. Shape is (num_instances, num_joints). + joint_viscous_friction_coeff: Viscous friction coefficient [N·s/m or N·m·s/rad, depending on joint type]. + Shape is (num_instances, num_joints). joint_mask: Joint mask. If None, then all joints are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py index 8c2056d9cbfa..3d7e6e9cb483 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py @@ -369,7 +369,10 @@ def joint_armature(self) -> ProxyArray: @property def joint_friction_coeff(self) -> ProxyArray: - """Joint static friction coefficient provided to the simulation. + """PhysX joint static friction value provided to the simulation. + + For Isaac Sim 5.0 and later, this is the static friction effort [N or N·m, depending on joint type]. + For earlier Isaac Sim versions, this is the legacy unitless joint friction coefficient. Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). """ @@ -379,7 +382,9 @@ def joint_friction_coeff(self) -> ProxyArray: @property def joint_dynamic_friction_coeff(self) -> ProxyArray: - """Joint dynamic friction coefficient provided to the simulation. + """PhysX joint dynamic friction effort provided to the simulation. + + The effort is [N or N·m, depending on joint type]. Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). """ @@ -391,6 +396,8 @@ def joint_dynamic_friction_coeff(self) -> ProxyArray: def joint_viscous_friction_coeff(self) -> ProxyArray: """Joint viscous friction coefficient provided to the simulation. + The coefficient is [N·s/m or N·m·s/rad, depending on joint type]. + Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to (num_instances, num_joints). """ if self._joint_viscous_friction_coeff_ta is None: From 8c5e2ad030d80b58cecbcf94995d6b1e4d468d6a Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 8 May 2026 09:26:17 +0200 Subject: [PATCH 014/133] Deprecates state properties calls (#5423) # Description Reduce higher-level dependency on packed state tensors in targeted IsaacLab call sites without changing existing task observation keys. This PR: - changes Pink IK to read `body_link_pose_w` directly instead of slicing `body_link_state_w`; - changes Dexsuite orientation rewards to use `root_link_quat_w` directly instead of slicing `root_state_w`; - adds explicit pick-place helpers for robot link pose and velocity; - keeps `get_all_robot_link_state()` available for compatibility, but marks it deprecated for removal in IsaacLab 4.0; - keeps existing `robot_links_state` task config entries unchanged. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) - Documentation update ## Screenshots N/A ## Test Plan - `./isaaclab.sh -p -m py_compile source/isaaclab/isaaclab/envs/mdp/__init__.pyi source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py source/isaaclab/isaaclab/envs/mdp/observations.py source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/__init__.pyi source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/observations.py source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/rewards.py` - `./isaaclab.sh -f` - `git diff --check origin/develop..HEAD` - `rg -n "body_link_state_w|root_state_w" source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/observations.py source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/rewards.py` (no matches) - Existing/new MDP pytest not run locally. Per review, new MDP tests were removed and should be added in a separate PR. Local pytest collection is also blocked in this worktree because `./isaaclab.sh -p` selects `/usr/bin/python3.12` without `torch`. ## 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 - [ ] My changes generate no new warnings - [ ] 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 Notes: - The unchecked warning item is intentional: this PR adds a `DeprecationWarning` to `get_all_robot_link_state()` so users can migrate before IsaacLab 4.0. - The unchecked test item follows review feedback: MDP tests should be added in a separate PR. --- .../pr-5423-state-observation-mdp.rst | 6 ++++ .../mdp/actions/pink_task_space_actions.py | 2 +- .../pr-5423-state-observation-mdp.rst | 21 ++++++++++++ .../manipulation/dexsuite/mdp/rewards.py | 3 +- .../manipulation/pick_place/mdp/__init__.pyi | 4 +++ .../pick_place/mdp/observations.py | 33 ++++++++++++++++--- 6 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 source/isaaclab/changelog.d/pr-5423-state-observation-mdp.rst create mode 100644 source/isaaclab_tasks/changelog.d/pr-5423-state-observation-mdp.rst diff --git a/source/isaaclab/changelog.d/pr-5423-state-observation-mdp.rst b/source/isaaclab/changelog.d/pr-5423-state-observation-mdp.rst new file mode 100644 index 000000000000..62193bc0796f --- /dev/null +++ b/source/isaaclab/changelog.d/pr-5423-state-observation-mdp.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Changed the Pink IK task-space action base link frame lookup to read direct + body link pose data instead of slicing packed body link state. No user + migration is required. diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py index 3cb4450805b2..f826c80d51eb 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py @@ -218,7 +218,7 @@ def _get_base_link_frame_transform(self) -> torch.Tensor: """ # Get base link frame pose in world origin using cached index articulation_data = self._env.scene[self.cfg.controller.articulation_name].data - base_link_frame_in_world_origin = articulation_data.body_link_state_w.torch[:, self._base_link_idx, :7] + base_link_frame_in_world_origin = articulation_data.body_link_pose_w.torch[:, self._base_link_idx] # Transform to environment origin frame (reuse buffer to avoid allocation) torch.sub( diff --git a/source/isaaclab_tasks/changelog.d/pr-5423-state-observation-mdp.rst b/source/isaaclab_tasks/changelog.d/pr-5423-state-observation-mdp.rst new file mode 100644 index 000000000000..655f379a939a --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/pr-5423-state-observation-mdp.rst @@ -0,0 +1,21 @@ +Added +^^^^^ + +* Added explicit GR1T2 and Unitree G1 pick-place robot link pose and velocity + MDP helpers as replacements for packed robot link state observations. + +Changed +^^^^^^^ + +* Changed Dexsuite orientation tracking rewards to read root link orientation + directly instead of slicing packed root state tensors. + +Deprecated +^^^^^^^^^^ + +* Deprecated + :func:`~isaaclab_tasks.manager_based.manipulation.pick_place.mdp.observations.get_all_robot_link_state` + in favor of + :func:`~isaaclab_tasks.manager_based.manipulation.pick_place.mdp.observations.get_all_robot_link_pose` + and + :func:`~isaaclab_tasks.manager_based.manipulation.pick_place.mdp.observations.get_all_robot_link_velocity`. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/rewards.py index 8a5a013ea846..9e8b084afbae 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/rewards.py @@ -219,8 +219,7 @@ def orientation_command_error_tanh( obj: RigidObject = env.scene[align_asset_cfg.name] command = env.command_manager.get_command(command_name) des_quat_b = command[:, 3:7] - root_state = asset.data.root_state_w.torch - des_quat_w = math_utils.quat_mul(root_state[:, 3:7], des_quat_b) + des_quat_w = math_utils.quat_mul(asset.data.root_link_quat_w.torch, des_quat_b) quat_distance = math_utils.quat_error_magnitude(obj.data.root_quat_w.torch, des_quat_w) return (1 - torch.tanh(quat_distance / std)) * contacts(env, contact_threshold, thumb_name, finger_names).float() diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/__init__.pyi index 1421a52bc546..de6c777b49c1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/__init__.pyi +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/__init__.pyi @@ -4,7 +4,9 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "get_all_robot_link_pose", "get_all_robot_link_state", + "get_all_robot_link_velocity", "get_eef_pos", "get_eef_quat", "get_robot_joint_state", @@ -16,7 +18,9 @@ __all__ = [ ] from .observations import ( + get_all_robot_link_pose, get_all_robot_link_state, + get_all_robot_link_velocity, get_eef_pos, get_eef_quat, get_robot_joint_state, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/observations.py index c3e030b33ec0..cedc6507d78c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/observations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/observations.py @@ -5,6 +5,7 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING import torch @@ -77,10 +78,34 @@ def get_robot_joint_state( return robot_joint_states +def get_all_robot_link_pose(env: ManagerBasedRLEnv) -> torch.Tensor: + """Robot link poses in the world frame. + + Returns: + Link poses with shape ``[num_envs, num_bodies, 7]`` and layout + ``[x, y, z, qx, qy, qz, qw]`` where position is in ``[m]``. + """ + return env.scene["robot"].data.body_link_pose_w.torch + + +def get_all_robot_link_velocity(env: ManagerBasedRLEnv) -> torch.Tensor: + """Robot link velocities in the world frame. + + Returns: + Link velocities with shape ``[num_envs, num_bodies, 6]`` and layout + ``[linear_velocity(3), angular_velocity(3)]`` in ``[m/s, rad/s]``. + """ + return env.scene["robot"].data.body_link_vel_w.torch + + def get_all_robot_link_state( env: ManagerBasedRLEnv, ) -> torch.Tensor: - body_pos_w = env.scene["robot"].data.body_link_state_w.torch[:, :, :] - all_robot_link_pos = body_pos_w - - return all_robot_link_pos + # TODO: Remove this compatibility helper in IsaacLab 4.0. + warnings.warn( + "`get_all_robot_link_state` is deprecated and will be removed in IsaacLab 4.0. " + "Use `get_all_robot_link_pose` and `get_all_robot_link_velocity` instead.", + DeprecationWarning, + stacklevel=2, + ) + return torch.cat((get_all_robot_link_pose(env), get_all_robot_link_velocity(env)), dim=-1) From 311ec73098084b7b6e20327a7ba7ce70e73c49af Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 8 May 2026 09:31:32 +0200 Subject: [PATCH 015/133] Fixes backend deprecation warning call sites (#5418) ## Summary - Updated PhysX and Newton backend tests to use the current root-state, joint-state, contact-sensor, and wrench-composer API names. - Updated the Newton contact sensor adapter to use the current SensorContact constructor and force/metadata fields. - Bumped matching PhysX and Newton extension changelog/version files. ## Test Plan - [x] ./isaaclab.sh -p -m py_compile source/isaaclab_physx/test/sensors/test_frame_transformer.py source/isaaclab_newton/test/sensors/test_frame_transformer.py source/isaaclab_physx/test/sensors/test_contact_sensor.py source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_kernels.py - [x] ./isaaclab.sh -f - [x] Focused deprecation scan: 118 matches on origin/develop, 0 matches on this branch - [ ] Targeted GPU pytest on NvidiaWorkstation-WiFi: attempted in isaac-lab-base-pr5304:latest, but the PhysX container timed out after 3600s during pytest collection before tests ran --- ...er-backend-deprecation-warning-cleanup.rst | 6 + .../isaaclab_newton/physics/newton_manager.py | 2 +- .../sensors/contact_sensor/contact_sensor.py | 58 ++++++--- .../contact_sensor/contact_sensor_kernels.py | 11 +- .../test/assets/test_articulation.py | 20 +-- .../test/assets/test_rigid_object.py | 12 +- .../assets/test_rigid_object_collection.py | 12 +- .../test/sensors/test_frame_transformer.py | 120 +++++++++++++----- ...er-backend-deprecation-warning-cleanup.rst | 5 + .../test/assets/test_articulation.py | 20 +-- .../test/assets/test_rigid_object.py | 16 +-- .../assets/test_rigid_object_collection.py | 12 +- .../test/sensors/test_contact_sensor.py | 22 ++-- .../test/sensors/test_frame_transformer.py | 120 +++++++++++++----- 14 files changed, 288 insertions(+), 148 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst create mode 100644 source/isaaclab_physx/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst diff --git a/source/isaaclab_newton/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst b/source/isaaclab_newton/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst new file mode 100644 index 000000000000..2fd8df763f82 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_newton.sensors.contact_sensor.ContactSensor` to use + current Newton contact sensor API names, removing deprecation warnings from + Newton contact sensor test runs. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index dbc85e97c270..97535fb8a328 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -1351,7 +1351,7 @@ def _normalize_for_labels(expr: str | list[str] | None, labels: list[str]) -> st sensing_obj_shapes=_normalize_for_labels(_to_fnmatch(shape_names_expr), shape_labels), counterpart_bodies=_normalize_for_labels(_to_fnmatch(contact_partners_body_expr), body_labels), counterpart_shapes=_normalize_for_labels(_to_fnmatch(contact_partners_shape_expr), shape_labels), - include_total=True, + measure_total=True, verbose=verbose, ) diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py index 02850e9cc903..43e878fecbfb 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py @@ -322,17 +322,17 @@ def _initialize_impl(self): raise RuntimeError(self._init_error) from err def _create_buffers(self): - # Get Newton sensor shape: (n_sensors * n_envs, n_counterparts) - newton_shape = self.contact_view.shape + # Get Newton sensor count from total force: (n_sensors * n_envs) + total_sensor_count = self.contact_view.total_force.shape[0] # resolve the true count of sensors - self._num_sensors = newton_shape[0] // self._num_envs + self._num_sensors = total_sensor_count // self._num_envs # Check that number of sensors is an integer - if newton_shape[0] % self._num_envs != 0: + if total_sensor_count % self._num_envs != 0: raise RuntimeError( "Number of sensors is not an integer multiple of the number of environments. Received:" - f" {newton_shape[0]} sensors across {self._num_envs} environments." + f" {total_sensor_count} sensors across {self._num_envs} environments." ) if self._num_sensors == 0: raise RuntimeError( @@ -350,25 +350,49 @@ def get_name(idx, kind): kind_name = getattr(kind, "name", None) kind_value = getattr(kind, "value", kind) if kind_name == "BODY" or kind_value == 2: - return body_labels[idx].split("/")[-1] + return body_labels[int(idx)].split("/")[-1] if kind_name == "SHAPE" or kind_value == 1: - return shape_labels[idx].split("/")[-1] + return shape_labels[int(idx)].split("/")[-1] return "MATCH_ANY" - flat_sensing = [obj for world_objs in self.contact_view.sensing_objs for obj in world_objs] + def flatten_metadata(values): + if isinstance(values, wp.array): + values = values.numpy() + flat_values = np.asarray(values, dtype=object).reshape(-1).tolist() + if flat_values and isinstance(flat_values[0], list | tuple | np.ndarray): + return [ + value + for nested_values in flat_values + for value in np.asarray(nested_values, dtype=object).reshape(-1).tolist() + ] + return flat_values + + flat_sensing = list( + zip( + flatten_metadata(self.contact_view.sensing_obj_idx), + flatten_metadata(self.contact_view.sensing_obj_type), + ) + ) self._sensor_names = [get_name(idx, kind) for idx, kind in flat_sensing] # Assumes the environments are processed in order. self._sensor_names = self._sensor_names[: self._num_sensors] - flat_counterparts = [obj for world_objs in self.contact_view.counterparts for obj in world_objs] + flat_counterparts = list( + zip( + flatten_metadata(self.contact_view.counterpart_indices), + flatten_metadata(self.contact_view.counterpart_type), + ) + ) self._filter_object_names = [get_name(idx, kind) for idx, kind in flat_counterparts] - # Number of filter objects (counterparts minus the total column) - self._num_filter_objects = max(newton_shape[1] - 1, 0) + force_matrix = self.contact_view.force_matrix + force_matrix_shape = force_matrix.shape if force_matrix is not None else (total_sensor_count, 0) + # Number of filter objects. + self._num_filter_objects = force_matrix_shape[1] if len(force_matrix_shape) > 1 else 0 - # Store reshaped Newton net_force view for copying data - # Newton net_force shape: (n_sensors * n_envs, n_counterparts) - # Reshaped to: (n_envs, n_sensors, n_counterparts) - self._newton_forces_view = self.contact_view.net_force.reshape((self._num_envs, self._num_sensors, -1)) + # Store flat Newton force views for copying data. These may be non-contiguous + # views, so the copy kernel indexes them without reshaping. + self._newton_total_force_view = self.contact_view.total_force + self._newton_force_matrix_view = force_matrix if self._num_filter_objects > 0 else None # prepare data buffers logger.info( @@ -413,7 +437,9 @@ def _update_buffers_impl(self, env_mask: wp.array): dim=(self._num_envs, self._num_sensors, max(self._num_filter_objects, 1)), inputs=[ env_mask, - self._newton_forces_view, + self._num_sensors, + self._newton_total_force_view, + self._newton_force_matrix_view, ], outputs=[ self._data._net_forces_w, diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_kernels.py b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_kernels.py index 324f0106682f..81fef0bcab63 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_kernels.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_kernels.py @@ -13,7 +13,9 @@ def copy_from_newton_kernel( # in env_mask: wp.array(dtype=wp.bool), - newton_forces: wp.array3d(dtype=wp.vec3f), # (n_envs, n_sensors, n_counterparts) + num_sensors: int, + newton_total_force: wp.array(dtype=wp.vec3f), # (n_envs * n_sensors) + newton_force_matrix: wp.array2d(dtype=wp.vec3f), # (n_envs * n_sensors, n_filter_objects) or None # outputs net_force_total: wp.array2d(dtype=wp.vec3f), # (n_envs, n_sensors) force_matrix: wp.array3d(dtype=wp.vec3f), # (n_envs, n_sensors, n_filter_objects) or None @@ -30,13 +32,14 @@ def copy_from_newton_kernel( return # Copy total force (column 0) - only thread with f_idx == 0 does this + src_idx = env * num_sensors + sensor if f_idx == 0: - net_force_total[env, sensor] = newton_forces[env, sensor, 0] + net_force_total[env, sensor] = newton_total_force[src_idx] - # Copy per-filter-object forces (columns 1+) + # Copy per-filter-object forces. # Guard with `if force_matrix:` to handle None case (no filter objects) if force_matrix: - force_matrix[env, sensor, f_idx] = newton_forces[env, sensor, f_idx + 1] + force_matrix[env, sensor, f_idx] = newton_force_matrix[src_idx, f_idx] @wp.kernel diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index a392e7773468..fdb335291fa5 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -983,8 +983,8 @@ def test_external_force_buffer(sim, num_articulations, device, articulation_type # check if the articulation's force and torque buffers are correctly updated for i in range(num_articulations): - assert articulation.permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force - assert articulation.permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force + assert articulation.permanent_wrench_composer.out_force_b.torch[i, 0, 0].item() == force + assert articulation.permanent_wrench_composer.out_torque_b.torch[i, 0, 0].item() == force # Check if the instantaneous wrench is correctly added to the permanent wrench articulation.instantaneous_wrench_composer.add_forces_and_torques_index( @@ -1724,10 +1724,10 @@ def test_reset(sim, num_articulations, device, articulation_type): # Reset should zero external forces and torques assert not articulation._instantaneous_wrench_composer.active assert not articulation._permanent_wrench_composer.active - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_torque.torch) == 0 - assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_torque.torch) == 0 + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_torque_b.torch) == 0 + assert torch.count_nonzero(articulation._permanent_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(articulation._permanent_wrench_composer.out_torque_b.torch) == 0 if num_articulations > 1: num_bodies = articulation.num_bodies @@ -1742,10 +1742,10 @@ def test_reset(sim, num_articulations, device, articulation_type): articulation.reset(env_ids=torch.tensor([0], device=device)) assert articulation._instantaneous_wrench_composer.active assert articulation._permanent_wrench_composer.active - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_force.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_torque.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_force.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_torque.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_force_b.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_torque_b.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._permanent_wrench_composer.out_force_b.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._permanent_wrench_composer.out_torque_b.torch) == num_bodies * 3 @pytest.mark.isaacsim_ci diff --git a/source/isaaclab_newton/test/assets/test_rigid_object.py b/source/isaaclab_newton/test/assets/test_rigid_object.py index 3b8e3aa9bf85..67796416dce6 100644 --- a/source/isaaclab_newton/test/assets/test_rigid_object.py +++ b/source/isaaclab_newton/test/assets/test_rigid_object.py @@ -276,8 +276,8 @@ def test_external_force_buffer(device): # check if the cube's force and torque buffers are correctly updated for i in range(cube_object.num_instances): - assert cube_object._permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force - assert cube_object._permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force + assert cube_object._permanent_wrench_composer.out_force_b.torch[i, 0, 0].item() == force + assert cube_object._permanent_wrench_composer.out_torque_b.torch[i, 0, 0].item() == force # Check if the instantaneous wrench is correctly added to the permanent wrench cube_object.permanent_wrench_composer.add_forces_and_torques_index( @@ -565,10 +565,10 @@ def test_reset_rigid_object(num_cubes, device): # Reset should zero external forces and torques assert not cube_object._instantaneous_wrench_composer.active assert not cube_object._permanent_wrench_composer.active - assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.composed_torque.torch) == 0 - assert torch.count_nonzero(cube_object._permanent_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(cube_object._permanent_wrench_composer.composed_torque.torch) == 0 + assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.out_torque_b.torch) == 0 + assert torch.count_nonzero(cube_object._permanent_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(cube_object._permanent_wrench_composer.out_torque_b.torch) == 0 @pytest.mark.isaacsim_ci 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 0873da8ecf22..cec62a98bcd3 100644 --- a/source/isaaclab_newton/test/assets/test_rigid_object_collection.py +++ b/source/isaaclab_newton/test/assets/test_rigid_object_collection.py @@ -241,8 +241,8 @@ def test_external_force_buffer(device): # check if the object collection's force and torque buffers are correctly updated for i in range(num_envs): - assert object_collection._permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force - assert object_collection._permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force + assert object_collection._permanent_wrench_composer.out_force_b.torch[i, 0, 0].item() == force + assert object_collection._permanent_wrench_composer.out_torque_b.torch[i, 0, 0].item() == force object_collection.instantaneous_wrench_composer.add_forces_and_torques_index( body_ids=object_ids, @@ -502,10 +502,10 @@ def test_reset_object_collection(num_envs, num_cubes, device): # Reset should zero external forces and torques assert not object_collection._instantaneous_wrench_composer.active assert not object_collection._permanent_wrench_composer.active - assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.composed_torque.torch) == 0 - assert torch.count_nonzero(object_collection._permanent_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(object_collection._permanent_wrench_composer.composed_torque.torch) == 0 + assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.out_torque_b.torch) == 0 + assert torch.count_nonzero(object_collection._permanent_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(object_collection._permanent_wrench_composer.out_torque_b.torch) == 0 @pytest.mark.parametrize("num_envs", [1, 3]) diff --git a/source/isaaclab_newton/test/sensors/test_frame_transformer.py b/source/isaaclab_newton/test/sensors/test_frame_transformer.py index 513ba9dda918..236fb53f25d9 100644 --- a/source/isaaclab_newton/test/sensors/test_frame_transformer.py +++ b/source/isaaclab_newton/test/sensors/test_frame_transformer.py @@ -168,21 +168,28 @@ def test_frame_transformer_feet_wrt_base(sim): # # reset if count % 25 == 0: # reset root state - root_state = scene.articulations["robot"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene.articulations["robot"].data.default_root_pose.torch, + scene.articulations["robot"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins joint_pos = scene.articulations["robot"].data.default_joint_pos.torch joint_vel = scene.articulations["robot"].data.default_joint_vel.torch # -- set root state # -- robot - scene.articulations["robot"].write_root_pose_to_sim(root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim(root_state[:, 7:]) - scene.articulations["robot"].write_joint_state_to_sim(joint_pos, joint_vel) + scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) # reset buffers scene.reset() # set joint targets robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target(robot_actions) + scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) # write data to sim scene.write_data_to_sim() # perform step @@ -266,21 +273,28 @@ def test_frame_transformer_feet_wrt_thigh(sim): # # reset if count % 25 == 0: # reset root state - root_state = scene.articulations["robot"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene.articulations["robot"].data.default_root_pose.torch, + scene.articulations["robot"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins joint_pos = scene.articulations["robot"].data.default_joint_pos.torch joint_vel = scene.articulations["robot"].data.default_joint_vel.torch # -- set root state # -- robot - scene.articulations["robot"].write_root_pose_to_sim(root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim(root_state[:, 7:]) - scene.articulations["robot"].write_joint_state_to_sim(joint_pos, joint_vel) + scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) # reset buffers scene.reset() # set joint targets robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target(robot_actions) + scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) # write data to sim scene.write_data_to_sim() # perform step @@ -344,21 +358,28 @@ def test_frame_transformer_robot_body_to_external_cube(sim): # # reset if count % 25 == 0: # reset root state - root_state = scene.articulations["robot"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene.articulations["robot"].data.default_root_pose.torch, + scene.articulations["robot"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins joint_pos = scene.articulations["robot"].data.default_joint_pos.torch joint_vel = scene.articulations["robot"].data.default_joint_vel.torch # -- set root state # -- robot - scene.articulations["robot"].write_root_pose_to_sim(root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim(root_state[:, 7:]) - scene.articulations["robot"].write_joint_state_to_sim(joint_pos, joint_vel) + scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) # reset buffers scene.reset() # set joint targets robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target(robot_actions) + scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) # write data to sim scene.write_data_to_sim() # perform step @@ -440,12 +461,18 @@ def test_frame_transformer_offset_frames(sim): # # reset if count % 25 == 0: # reset root state - root_state = scene["cube"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene["cube"].data.default_root_pose.torch, + scene["cube"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins # -- set root state # -- cube - scene["cube"].write_root_pose_to_sim(root_state[:, :7]) - scene["cube"].write_root_velocity_to_sim(root_state[:, 7:]) + scene["cube"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene["cube"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) # reset buffers scene.reset() @@ -532,21 +559,28 @@ def test_frame_transformer_all_bodies(sim): # # reset if count % 25 == 0: # reset root state - root_state = scene.articulations["robot"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene.articulations["robot"].data.default_root_pose.torch, + scene.articulations["robot"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins joint_pos = scene.articulations["robot"].data.default_joint_pos.torch joint_vel = scene.articulations["robot"].data.default_joint_vel.torch # -- set root state # -- robot - scene.articulations["robot"].write_root_pose_to_sim(root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim(root_state[:, 7:]) - scene.articulations["robot"].write_joint_state_to_sim(joint_pos, joint_vel) + scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) # reset buffers scene.reset() # set joint targets robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target(robot_actions) + scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) # write data to sim scene.write_data_to_sim() # perform step @@ -727,22 +761,38 @@ class MultiRobotSceneCfg(InteractiveSceneCfg): # Reset periodically if count % 10 == 0: # Reset robot - root_state = scene.articulations["robot"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene.articulations["robot"].data.default_root_pose.torch, + scene.articulations["robot"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins - scene.articulations["robot"].write_root_pose_to_sim(root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim(root_state[:, 7:]) - scene.articulations["robot"].write_joint_state_to_sim( - scene.articulations["robot"].data.default_joint_pos.torch, - scene.articulations["robot"].data.default_joint_vel.torch, + scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + scene.articulations["robot"].write_joint_position_to_sim_index( + position=scene.articulations["robot"].data.default_joint_pos.torch + ) + scene.articulations["robot"].write_joint_velocity_to_sim_index( + velocity=scene.articulations["robot"].data.default_joint_vel.torch ) # Reset robot_1 - root_state_1 = scene.articulations["robot_1"].data.default_root_state.torch.clone() + root_state_1 = torch.cat( + ( + scene.articulations["robot_1"].data.default_root_pose.torch, + scene.articulations["robot_1"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state_1[:, :3] += scene.env_origins - scene.articulations["robot_1"].write_root_pose_to_sim(root_state_1[:, :7]) - scene.articulations["robot_1"].write_root_velocity_to_sim(root_state_1[:, 7:]) - scene.articulations["robot_1"].write_joint_state_to_sim( - scene.articulations["robot_1"].data.default_joint_pos.torch, - scene.articulations["robot_1"].data.default_joint_vel.torch, + scene.articulations["robot_1"].write_root_pose_to_sim_index(root_pose=root_state_1[:, :7]) + scene.articulations["robot_1"].write_root_velocity_to_sim_index(root_velocity=root_state_1[:, 7:]) + scene.articulations["robot_1"].write_joint_position_to_sim_index( + position=scene.articulations["robot_1"].data.default_joint_pos.torch + ) + scene.articulations["robot_1"].write_joint_velocity_to_sim_index( + velocity=scene.articulations["robot_1"].data.default_joint_vel.torch ) scene.reset() diff --git a/source/isaaclab_physx/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst b/source/isaaclab_physx/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst new file mode 100644 index 000000000000..bdec42289b0d --- /dev/null +++ b/source/isaaclab_physx/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed PhysX backend tests to use current contact sensor and asset API names, + removing deprecation warnings from scoped test runs. diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index 508a10f27c27..bd015562c4df 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -844,8 +844,8 @@ def test_external_force_buffer(sim, num_articulations, device): # check if the articulation's force and torque buffers are correctly updated for i in range(num_articulations): - assert articulation.permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force - assert articulation.permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force + assert articulation.permanent_wrench_composer.out_force_b.torch[i, 0, 0].item() == force + assert articulation.permanent_wrench_composer.out_torque_b.torch[i, 0, 0].item() == force # Check if the instantaneous wrench is correctly added to the permanent wrench articulation.instantaneous_wrench_composer.add_forces_and_torques_index( @@ -1559,10 +1559,10 @@ def test_reset(sim, num_articulations, device): # Reset should zero external forces and torques assert not articulation._instantaneous_wrench_composer.active assert not articulation._permanent_wrench_composer.active - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_torque.torch) == 0 - assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_torque.torch) == 0 + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_torque_b.torch) == 0 + assert torch.count_nonzero(articulation._permanent_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(articulation._permanent_wrench_composer.out_torque_b.torch) == 0 if num_articulations > 1: num_bodies = articulation.num_bodies @@ -1577,10 +1577,10 @@ def test_reset(sim, num_articulations, device): articulation.reset(env_ids=torch.tensor([0], device=device)) assert articulation._instantaneous_wrench_composer.active assert articulation._permanent_wrench_composer.active - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_force.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_torque.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_force.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_torque.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_force_b.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_torque_b.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._permanent_wrench_composer.out_force_b.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._permanent_wrench_composer.out_torque_b.torch) == num_bodies * 3 @pytest.mark.parametrize("num_articulations", [1, 2]) diff --git a/source/isaaclab_physx/test/assets/test_rigid_object.py b/source/isaaclab_physx/test/assets/test_rigid_object.py index 6a2211787e19..b7f422c4f2f0 100644 --- a/source/isaaclab_physx/test/assets/test_rigid_object.py +++ b/source/isaaclab_physx/test/assets/test_rigid_object.py @@ -253,8 +253,8 @@ def test_external_force_buffer(device): # check if the cube's force and torque buffers are correctly updated for i in range(cube_object.num_instances): - assert cube_object._permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force - assert cube_object._permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force + assert cube_object._permanent_wrench_composer.out_force_b.torch[i, 0, 0].item() == force + assert cube_object._permanent_wrench_composer.out_torque_b.torch[i, 0, 0].item() == force # Check if the instantaneous wrench is correctly added to the permanent wrench cube_object.permanent_wrench_composer.add_forces_and_torques_index( @@ -426,13 +426,13 @@ def test_external_force_on_single_body_at_position(num_cubes, device): is_global=is_global, ) torch.testing.assert_close( - cube_object._permanent_wrench_composer.composed_force.torch[:, 0, :], + cube_object._permanent_wrench_composer.out_force_b.torch[:, 0, :], desired_force[:, 0, :], rtol=1e-6, atol=1e-7, ) torch.testing.assert_close( - cube_object._permanent_wrench_composer.composed_torque.torch[:, 0, :], + cube_object._permanent_wrench_composer.out_torque_b.torch[:, 0, :], desired_torque[:, 0, :], rtol=1e-6, atol=1e-7, @@ -557,10 +557,10 @@ def test_reset_rigid_object(num_cubes, device): # Reset should zero external forces and torques assert not cube_object._instantaneous_wrench_composer.active assert not cube_object._permanent_wrench_composer.active - assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.composed_torque.torch) == 0 - assert torch.count_nonzero(cube_object._permanent_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(cube_object._permanent_wrench_composer.composed_torque.torch) == 0 + assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.out_torque_b.torch) == 0 + assert torch.count_nonzero(cube_object._permanent_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(cube_object._permanent_wrench_composer.out_torque_b.torch) == 0 @pytest.mark.parametrize("num_cubes", [1, 2]) diff --git a/source/isaaclab_physx/test/assets/test_rigid_object_collection.py b/source/isaaclab_physx/test/assets/test_rigid_object_collection.py index 8401cc395a0c..f42f6dfcb085 100644 --- a/source/isaaclab_physx/test/assets/test_rigid_object_collection.py +++ b/source/isaaclab_physx/test/assets/test_rigid_object_collection.py @@ -259,8 +259,8 @@ def test_external_force_buffer(sim, device): # check if the object collection's force and torque buffers are correctly updated for i in range(num_envs): - assert object_collection._permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force - assert object_collection._permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force + assert object_collection._permanent_wrench_composer.out_force_b.torch[i, 0, 0].item() == force + assert object_collection._permanent_wrench_composer.out_torque_b.torch[i, 0, 0].item() == force object_collection.instantaneous_wrench_composer.add_forces_and_torques_index( body_ids=object_ids, @@ -681,10 +681,10 @@ def test_reset_object_collection(sim, num_envs, num_cubes, device): # Reset should zero external forces and torques assert not object_collection._instantaneous_wrench_composer.active assert not object_collection._permanent_wrench_composer.active - assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.composed_torque.torch) == 0 - assert torch.count_nonzero(object_collection._permanent_wrench_composer.composed_force.torch) == 0 - assert torch.count_nonzero(object_collection._permanent_wrench_composer.composed_torque.torch) == 0 + assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.out_torque_b.torch) == 0 + assert torch.count_nonzero(object_collection._permanent_wrench_composer.out_force_b.torch) == 0 + assert torch.count_nonzero(object_collection._permanent_wrench_composer.out_torque_b.torch) == 0 @pytest.mark.parametrize("num_envs", [1, 3]) diff --git a/source/isaaclab_physx/test/sensors/test_contact_sensor.py b/source/isaaclab_physx/test/sensors/test_contact_sensor.py index 00772e6cb0d3..685d9204b3ef 100644 --- a/source/isaaclab_physx/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_physx/test/sensors/test_contact_sensor.py @@ -480,7 +480,7 @@ def test_friction_reporting(setup_simulation, grav_dir): sim.reset() scene["contact_sensor"].reset() - scene["shape"].write_root_pose_to_sim( + scene["shape"].write_root_pose_to_sim_index( root_pose=torch.tensor([0, 0.0, CUBE_CFG.spawn.size[2] / 2.0, 1, 0, 0, 0], device=device).unsqueeze(0) ) @@ -703,7 +703,7 @@ def _test_sensor_contact( duration = durations[idx] while current_test_time < duration: # set object states to contact the ground plane - shape.write_root_pose_to_sim(root_pose=torch.tensor(test_pose, device=shape.device).unsqueeze(0)) + shape.write_root_pose_to_sim_index(root_pose=torch.tensor(test_pose, device=shape.device).unsqueeze(0)) # perform simulation step _perform_sim_step(sim, scene, sim_dt) # increment contact time @@ -735,7 +735,7 @@ def _test_sensor_contact( _test_friction_forces(shape, sensor, mode) # switch the contact mode for 1 dt step before the next contact test begins. - shape.write_root_pose_to_sim(root_pose=torch.tensor(reset_pose, device=shape.device).unsqueeze(0)) + shape.write_root_pose_to_sim_index(root_pose=torch.tensor(reset_pose, device=shape.device).unsqueeze(0)) # perform simulation step _perform_sim_step(sim, scene, sim_dt) # set the last air time to 2 sim_dt steps, because last_air_time and last_contact_time @@ -750,9 +750,9 @@ def _test_friction_forces(shape: RigidObject, sensor: ContactSensor, mode: Conta return # check shape of the friction_forces_w tensor (wp.to_torch expands vec3f -> float32 trailing dim) - num_bodies = sensor.num_bodies + num_sensors = sensor.num_sensors friction_torch = sensor._data.friction_forces_w.torch - assert friction_torch.shape == (sensor.num_instances // num_bodies, num_bodies, 1, 3) + assert friction_torch.shape == (sensor.num_instances // num_sensors, num_sensors, 1, 3) # compare friction forces if mode == ContactTestMode.IN_CONTACT: assert torch.any(torch.abs(friction_torch) > 1e-5).item() @@ -762,14 +762,14 @@ def _test_friction_forces(shape: RigidObject, sensor: ContactSensor, mode: Conta friction_forces_t = wp.to_torch(friction_forces) buffer_count_t = wp.to_torch(buffer_count).to(torch.int32) buffer_start_t = wp.to_torch(buffer_start_indices).to(torch.int32) - for i in range(sensor.num_instances * num_bodies): + for i in range(sensor.num_instances * num_sensors): for j in range(sensor.contact_view.filter_count): start_index_ij = buffer_start_t[i, j] count_ij = buffer_count_t[i, j] force = torch.sum(friction_forces_t[start_index_ij : (start_index_ij + count_ij), :], dim=0) - env_idx = i // num_bodies - body_idx = i % num_bodies - assert torch.allclose(force, friction_torch[env_idx, body_idx, j, :], atol=1e-5) + env_idx = i // num_sensors + sensor_idx = i % num_sensors + assert torch.allclose(force, friction_torch[env_idx, sensor_idx, j, :], atol=1e-5) elif mode == ContactTestMode.NON_CONTACT: assert torch.all(friction_torch == 0.0).item() @@ -788,9 +788,9 @@ def _test_contact_position(shape: RigidObject, sensor: ContactSensor, mode: Cont return # check shape of the contact_pos_w tensor (wp.to_torch expands vec3f -> float32 trailing dim) - num_bodies = sensor.num_bodies + num_sensors = sensor.num_sensors contact_pos_torch = sensor._data.contact_pos_w.torch - assert contact_pos_torch.shape == (sensor.num_instances // num_bodies, num_bodies, 1, 3) + assert contact_pos_torch.shape == (sensor.num_instances // num_sensors, num_sensors, 1, 3) # check contact positions if mode == ContactTestMode.IN_CONTACT: pos_w_torch = sensor._data.pos_w.torch diff --git a/source/isaaclab_physx/test/sensors/test_frame_transformer.py b/source/isaaclab_physx/test/sensors/test_frame_transformer.py index 631e9ba118dd..28559f9b6da5 100644 --- a/source/isaaclab_physx/test/sensors/test_frame_transformer.py +++ b/source/isaaclab_physx/test/sensors/test_frame_transformer.py @@ -153,21 +153,28 @@ def test_frame_transformer_feet_wrt_base(sim): # # reset if count % 25 == 0: # reset root state - root_state = scene.articulations["robot"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene.articulations["robot"].data.default_root_pose.torch, + scene.articulations["robot"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins joint_pos = scene.articulations["robot"].data.default_joint_pos.torch joint_vel = scene.articulations["robot"].data.default_joint_vel.torch # -- set root state # -- robot - scene.articulations["robot"].write_root_pose_to_sim(root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim(root_state[:, 7:]) - scene.articulations["robot"].write_joint_state_to_sim(joint_pos, joint_vel) + scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) # reset buffers scene.reset() # set joint targets robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target(robot_actions) + scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) # write data to sim scene.write_data_to_sim() # perform step @@ -251,21 +258,28 @@ def test_frame_transformer_feet_wrt_thigh(sim): # # reset if count % 25 == 0: # reset root state - root_state = scene.articulations["robot"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene.articulations["robot"].data.default_root_pose.torch, + scene.articulations["robot"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins joint_pos = scene.articulations["robot"].data.default_joint_pos.torch joint_vel = scene.articulations["robot"].data.default_joint_vel.torch # -- set root state # -- robot - scene.articulations["robot"].write_root_pose_to_sim(root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim(root_state[:, 7:]) - scene.articulations["robot"].write_joint_state_to_sim(joint_pos, joint_vel) + scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) # reset buffers scene.reset() # set joint targets robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target(robot_actions) + scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) # write data to sim scene.write_data_to_sim() # perform step @@ -329,21 +343,28 @@ def test_frame_transformer_robot_body_to_external_cube(sim): # # reset if count % 25 == 0: # reset root state - root_state = scene.articulations["robot"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene.articulations["robot"].data.default_root_pose.torch, + scene.articulations["robot"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins joint_pos = scene.articulations["robot"].data.default_joint_pos.torch joint_vel = scene.articulations["robot"].data.default_joint_vel.torch # -- set root state # -- robot - scene.articulations["robot"].write_root_pose_to_sim(root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim(root_state[:, 7:]) - scene.articulations["robot"].write_joint_state_to_sim(joint_pos, joint_vel) + scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) # reset buffers scene.reset() # set joint targets robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target(robot_actions) + scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) # write data to sim scene.write_data_to_sim() # perform step @@ -425,12 +446,18 @@ def test_frame_transformer_offset_frames(sim): # # reset if count % 25 == 0: # reset root state - root_state = scene["cube"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene["cube"].data.default_root_pose.torch, + scene["cube"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins # -- set root state # -- cube - scene["cube"].write_root_pose_to_sim(root_state[:, :7]) - scene["cube"].write_root_velocity_to_sim(root_state[:, 7:]) + scene["cube"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene["cube"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) # reset buffers scene.reset() @@ -513,21 +540,28 @@ def test_frame_transformer_all_bodies(sim): # # reset if count % 25 == 0: # reset root state - root_state = scene.articulations["robot"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene.articulations["robot"].data.default_root_pose.torch, + scene.articulations["robot"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins joint_pos = scene.articulations["robot"].data.default_joint_pos.torch joint_vel = scene.articulations["robot"].data.default_joint_vel.torch # -- set root state # -- robot - scene.articulations["robot"].write_root_pose_to_sim(root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim(root_state[:, 7:]) - scene.articulations["robot"].write_joint_state_to_sim(joint_pos, joint_vel) + scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) # reset buffers scene.reset() # set joint targets robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target(robot_actions) + scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) # write data to sim scene.write_data_to_sim() # perform step @@ -708,22 +742,38 @@ class MultiRobotSceneCfg(InteractiveSceneCfg): # Reset periodically if count % 10 == 0: # Reset robot - root_state = scene.articulations["robot"].data.default_root_state.torch.clone() + root_state = torch.cat( + ( + scene.articulations["robot"].data.default_root_pose.torch, + scene.articulations["robot"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state[:, :3] += scene.env_origins - scene.articulations["robot"].write_root_pose_to_sim(root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim(root_state[:, 7:]) - scene.articulations["robot"].write_joint_state_to_sim( - scene.articulations["robot"].data.default_joint_pos.torch, - scene.articulations["robot"].data.default_joint_vel.torch, + scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + scene.articulations["robot"].write_joint_position_to_sim_index( + position=scene.articulations["robot"].data.default_joint_pos.torch + ) + scene.articulations["robot"].write_joint_velocity_to_sim_index( + velocity=scene.articulations["robot"].data.default_joint_vel.torch ) # Reset robot_1 - root_state_1 = scene.articulations["robot_1"].data.default_root_state.torch.clone() + root_state_1 = torch.cat( + ( + scene.articulations["robot_1"].data.default_root_pose.torch, + scene.articulations["robot_1"].data.default_root_vel.torch, + ), + dim=-1, + ).clone() root_state_1[:, :3] += scene.env_origins - scene.articulations["robot_1"].write_root_pose_to_sim(root_state_1[:, :7]) - scene.articulations["robot_1"].write_root_velocity_to_sim(root_state_1[:, 7:]) - scene.articulations["robot_1"].write_joint_state_to_sim( - scene.articulations["robot_1"].data.default_joint_pos.torch, - scene.articulations["robot_1"].data.default_joint_vel.torch, + scene.articulations["robot_1"].write_root_pose_to_sim_index(root_pose=root_state_1[:, :7]) + scene.articulations["robot_1"].write_root_velocity_to_sim_index(root_velocity=root_state_1[:, 7:]) + scene.articulations["robot_1"].write_joint_position_to_sim_index( + position=scene.articulations["robot_1"].data.default_joint_pos.torch + ) + scene.articulations["robot_1"].write_joint_velocity_to_sim_index( + velocity=scene.articulations["robot_1"].data.default_joint_vel.torch ) scene.reset() From 3d93e84d7d00c616c14da893644dd135958f95a9 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 8 May 2026 09:41:36 +0200 Subject: [PATCH 016/133] Updates core deprecation call sites (#5409) ## Summary - Migrates core test and MDP callers off deprecated state/read/write helper APIs. - Updates the test_pose_inv tensor-to-NumPy conversion for NumPy 2.0. - Bumps the isaaclab changelog/version because core MDP source changed. ## Verification - ./isaaclab.sh -f - Scoped deprecated-call-site search: assigned core matches removed. Rebased onto develop after PR #5304 merged. --- ...oiner-core-deprecation-warning-cleanup.rst | 5 + .../controllers/test_operational_space.py | 7 +- .../test/envs/test_scale_randomization.py | 2 +- .../test/scene/test_interactive_scene.py | 32 +-- .../utils/test_wrench_composer_integration.py | 206 ++++++++++-------- .../utils/test_wrench_composer_vs_physx.py | 34 ++- 6 files changed, 164 insertions(+), 122 deletions(-) create mode 100644 source/isaaclab/changelog.d/antoiner-core-deprecation-warning-cleanup.rst diff --git a/source/isaaclab/changelog.d/antoiner-core-deprecation-warning-cleanup.rst b/source/isaaclab/changelog.d/antoiner-core-deprecation-warning-cleanup.rst new file mode 100644 index 000000000000..a3a3e51f8b3a --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-core-deprecation-warning-cleanup.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab.envs.mdp.actions.PinkInverseKinematicsAction` + base link pose reads to avoid deprecated body link state access. diff --git a/source/isaaclab/test/controllers/test_operational_space.py b/source/isaaclab/test/controllers/test_operational_space.py index 63c1633c5f7c..badf51543e17 100644 --- a/source/isaaclab/test/controllers/test_operational_space.py +++ b/source/isaaclab/test/controllers/test_operational_space.py @@ -1498,8 +1498,9 @@ def _run_op_space_controller( # reset joint state to default default_joint_pos = robot.data.default_joint_pos.torch.clone() default_joint_vel = robot.data.default_joint_vel.torch.clone() - robot.write_joint_state_to_sim(default_joint_pos, default_joint_vel) - robot.set_joint_effort_target(zero_joint_efforts) # Set zero torques in the initial step + robot.write_joint_position_to_sim_index(position=default_joint_pos) + robot.write_joint_velocity_to_sim_index(velocity=default_joint_vel) + robot.set_joint_effort_target_index(target=zero_joint_efforts) # Set zero torques in the initial step robot.write_data_to_sim() robot.reset() # reset contact sensor @@ -1545,7 +1546,7 @@ def _run_op_space_controller( current_joint_vel=joint_vel, nullspace_joint_pos_target=joint_centers, ) - robot.set_joint_effort_target(joint_efforts, joint_ids=arm_joint_ids) + robot.set_joint_effort_target_index(target=joint_efforts, joint_ids=arm_joint_ids) robot.write_data_to_sim() # update marker positions diff --git a/source/isaaclab/test/envs/test_scale_randomization.py b/source/isaaclab/test/envs/test_scale_randomization.py index af5dc220e63f..ec4c6cd42a96 100644 --- a/source/isaaclab/test/envs/test_scale_randomization.py +++ b/source/isaaclab/test/envs/test_scale_randomization.py @@ -105,7 +105,7 @@ def apply_actions(self): vel_error = -self._asset.data.root_lin_vel_w.torch # set velocity targets self._vel_command[:, :3] = self.p_gain * pos_error + self.d_gain * vel_error - self._asset.write_root_velocity_to_sim(self._vel_command) + self._asset.write_root_velocity_to_sim_index(root_velocity=self._vel_command) @configclass diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 31b577db634f..390129b9e4f2 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -84,10 +84,10 @@ def test_relative_flag(device, setup_scene): # test is relative == False prev_state = scene.get_state(is_relative=False) - scene["robot"].write_joint_state_to_sim( - position=torch.rand_like(scene["robot"].data.joint_pos.torch), - velocity=torch.rand_like(scene["robot"].data.joint_pos.torch), - ) + joint_pos = torch.rand_like(scene["robot"].data.joint_pos.torch) + joint_vel = torch.rand_like(scene["robot"].data.joint_pos.torch) + scene["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) next_state = scene.get_state(is_relative=False) assert_state_different(prev_state, next_state) scene.reset_to(prev_state, is_relative=False) @@ -95,10 +95,10 @@ def test_relative_flag(device, setup_scene): # test is relative == True prev_state = scene.get_state(is_relative=True) - scene["robot"].write_joint_state_to_sim( - position=torch.rand_like(scene["robot"].data.joint_pos.torch), - velocity=torch.rand_like(scene["robot"].data.joint_pos.torch), - ) + joint_pos = torch.rand_like(scene["robot"].data.joint_pos.torch) + joint_vel = torch.rand_like(scene["robot"].data.joint_pos.torch) + scene["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) next_state = scene.get_state(is_relative=True) assert_state_different(prev_state, next_state) scene.reset_to(prev_state, is_relative=True) @@ -114,18 +114,18 @@ def test_reset_to_env_ids_input_types(device, setup_scene): # test env_ids = None prev_state = scene.get_state() - scene["robot"].write_joint_state_to_sim( - position=torch.rand_like(scene["robot"].data.joint_pos.torch), - velocity=torch.rand_like(scene["robot"].data.joint_pos.torch), - ) + joint_pos = torch.rand_like(scene["robot"].data.joint_pos.torch) + joint_vel = torch.rand_like(scene["robot"].data.joint_pos.torch) + scene["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) scene.reset_to(prev_state, env_ids=None) assert_state_equal(prev_state, scene.get_state()) # test env_ids = torch tensor - scene["robot"].write_joint_state_to_sim( - position=torch.rand_like(scene["robot"].data.joint_pos.torch), - velocity=torch.rand_like(scene["robot"].data.joint_pos.torch), - ) + joint_pos = torch.rand_like(scene["robot"].data.joint_pos.torch) + joint_vel = torch.rand_like(scene["robot"].data.joint_pos.torch) + scene["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) scene.reset_to(prev_state, env_ids=torch.arange(scene.num_envs, device=scene.device, dtype=torch.int32)) assert_state_equal(prev_state, scene.get_state()) diff --git a/source/isaaclab/test/utils/test_wrench_composer_integration.py b/source/isaaclab/test/utils/test_wrench_composer_integration.py index bb195e856030..acb1682bde02 100644 --- a/source/isaaclab/test/utils/test_wrench_composer_integration.py +++ b/source/isaaclab/test/utils/test_wrench_composer_integration.py @@ -79,7 +79,7 @@ def test_global_force_invariant_under_rotation(device): forces[..., 0] = FORCE_MAGNITUDE torques = torch.zeros(1, len(body_ids), 3, device=device) - cube_object.permanent_wrench_composer.set_forces_and_torques( + cube_object.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, positions=com, @@ -96,9 +96,9 @@ def test_global_force_invariant_under_rotation(device): vel_after_phase1 = cube_object.data.root_lin_vel_w.torch[0].clone() # Rotate body 180deg about Z (quat wxyz = [0, 0, 0, 1]) while keeping velocity - root_pose = cube_object.data.root_state_w.torch[0, :7].clone().unsqueeze(0) + root_pose = cube_object.data.root_pose_w.torch[0].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) + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) # Phase 2: run N_STEPS more for _ in range(N_STEPS): @@ -152,7 +152,7 @@ def test_local_force_follows_rotation(device): forces[..., 0] = FORCE_MAGNITUDE torques = torch.zeros(1, len(body_ids), 3, device=device) - cube_object.permanent_wrench_composer.set_forces_and_torques( + cube_object.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, body_ids=body_ids, @@ -169,9 +169,9 @@ def test_local_force_follows_rotation(device): assert vel_after_phase1[0].item() > 1.0, "Object should be moving in +X" # Rotate body 180deg about Z while keeping velocity - root_pose = cube_object.data.root_state_w.torch[0, :7].clone().unsqueeze(0) + root_pose = cube_object.data.root_pose_w.torch[0].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) + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) # Phase 2: run N_STEPS — local +X is now world -X, so force decelerates for _ in range(N_STEPS): @@ -217,7 +217,7 @@ def test_global_force_at_offset_generates_torque(device): positions = com_pos.clone() positions[..., 1] += 1.0 # +1m Y offset - cube_object.permanent_wrench_composer.set_forces_and_torques( + cube_object.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, positions=positions, @@ -262,7 +262,7 @@ def test_global_torque_invariant_under_rotation(device): torques = torch.zeros(1, len(body_ids), 3, device=device) torques[..., 2] = TORQUE_MAGNITUDE - cube_object.permanent_wrench_composer.set_forces_and_torques( + cube_object.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, body_ids=body_ids, @@ -279,10 +279,13 @@ def test_global_torque_invariant_under_rotation(device): # Rotate body 90deg about X and zero out velocities so phase 2 starts from rest # (avoids gyroscopic cross-coupling at high omega) - root_pose = cube_object.data.root_state_w.torch[0, :7].clone().unsqueeze(0) + root_pose = cube_object.data.root_pose_w.torch[0].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)) + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + + root_vel = cube_object.data.root_vel_w.torch.clone() + root_vel[0, :] = 0.0 + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) # Phase 2: run N_STEPS from rest with different body orientation for _ in range(N_STEPS): @@ -324,13 +327,16 @@ def test_global_force_torque_after_translation(device): body_ids, _ = cube_object.find_bodies(".*") # Phase 1 setup: Move cube to (1, 0, 1) and apply force at (1, 0, 1) - root_state = cube_object.data.root_state_w.torch.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) + root_pose = cube_object.data.root_pose_w.torch.clone() + root_pose[0, 0] = 1.0 # x = 1 + root_pose[0, 1] = 0.0 # y = 0 + root_pose[0, 2] = 1.0 # z = 1 + root_pose[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity quat (xyzw) + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + + root_vel = cube_object.data.root_vel_w.torch.clone() + root_vel[0, :] = 0.0 # zero velocity + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) # Step once to let the state settle sim.step() @@ -343,7 +349,7 @@ def test_global_force_torque_after_translation(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( + cube_object.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, positions=com_pos, @@ -369,13 +375,16 @@ def test_global_force_torque_after_translation(device): ) # Phase 2: Teleport cube to origin, zero velocity, don't re-apply force - root_state2 = cube_object.data.root_state_w.torch.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) + root_pose2 = cube_object.data.root_pose_w.torch.clone() + root_pose2[0, 0] = 0.0 # x = 0 + root_pose2[0, 1] = 0.0 + root_pose2[0, 2] = 1.0 # z = 1 + root_pose2[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + cube_object.write_root_pose_to_sim_index(root_pose=root_pose2) + + root_vel2 = cube_object.data.root_vel_w.torch.clone() + root_vel2[0, :] = 0.0 # zero velocity + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel2) # Step once to let state settle sim.step() @@ -422,13 +431,16 @@ def test_global_force_torque_reverses_on_opposite_side(device): body_ids, _ = cube_object.find_bodies(".*") # Move cube to (-1, 0, 1) - root_state = cube_object.data.root_state_w.torch.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) + root_pose = cube_object.data.root_pose_w.torch.clone() + root_pose[0, 0] = -1.0 + root_pose[0, 1] = 0.0 + root_pose[0, 2] = 1.0 + root_pose[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + + root_vel = cube_object.data.root_vel_w.torch.clone() + root_vel[0, :] = 0.0 + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) sim.step() cube_object.update(sim.cfg.dt) @@ -439,7 +451,7 @@ def test_global_force_torque_reverses_on_opposite_side(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( + cube_object.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, positions=positions, @@ -457,13 +469,16 @@ def test_global_force_torque_reverses_on_opposite_side(device): 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 = cube_object.data.root_state_w.torch.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) + root_pose2 = cube_object.data.root_pose_w.torch.clone() + root_pose2[0, 0] = 1.0 + root_pose2[0, 1] = 0.0 + root_pose2[0, 2] = 1.0 + root_pose2[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + cube_object.write_root_pose_to_sim_index(root_pose=root_pose2) + + root_vel2 = cube_object.data.root_vel_w.torch.clone() + root_vel2[0, :] = 0.0 + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel2) sim.step() cube_object.update(sim.cfg.dt) @@ -493,13 +508,16 @@ def test_global_force_no_position_no_torque(device): body_ids, _ = cube_object.find_bodies(".*") # Move cube to (2, 0, 1) - root_state = cube_object.data.root_state_w.torch.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) + root_pose = cube_object.data.root_pose_w.torch.clone() + root_pose[0, 0] = 2.0 + root_pose[0, 1] = 0.0 + root_pose[0, 2] = 1.0 + root_pose[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + + root_vel = cube_object.data.root_vel_w.torch.clone() + root_vel[0, :] = 0.0 + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) sim.step() cube_object.update(sim.cfg.dt) @@ -508,7 +526,7 @@ def test_global_force_no_position_no_torque(device): forces[..., 1] = FORCE_MAGNITUDE torques = torch.zeros(1, len(body_ids), 3, device=device) - cube_object.permanent_wrench_composer.set_forces_and_torques( + cube_object.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, body_ids=body_ids, @@ -549,19 +567,21 @@ def test_multi_cube_different_torques_from_same_force(device): body_ids, _ = cube_object.find_bodies(".*") # Position cubes: Cube 0 at (-1, 0, 1), Cube 1 at (+1, 0, 1) - root_state = cube_object.data.root_state_w.torch.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) + root_pose = cube_object.data.root_pose_w.torch.clone() + root_pose[0, 0] = -1.0 + root_pose[0, 1] = 0.0 + root_pose[0, 2] = 1.0 + root_pose[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + + root_pose[1, 0] = 1.0 + root_pose[1, 1] = 0.0 + root_pose[1, 2] = 1.0 + root_pose[1, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + + root_vel = cube_object.data.root_vel_w.torch.clone() + root_vel[:, :] = 0.0 + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) sim.step() cube_object.update(sim.cfg.dt) @@ -572,7 +592,7 @@ def test_multi_cube_different_torques_from_same_force(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( + cube_object.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, positions=positions, @@ -628,20 +648,22 @@ def test_global_force_torque_far_from_origin(device): body_ids, _ = cube_object.find_bodies(".*") # Position cubes: Cube 0 near origin, Cube 1 far from origin - root_state = cube_object.data.root_state_w.torch.clone() + root_pose = cube_object.data.root_pose_w.torch.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 + root_pose[0, 0] = 0.0 + root_pose[0, 1] = 0.0 + root_pose[0, 2] = 1.0 + root_pose[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) # 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) + root_pose[1, 0] = 2000.0 + root_pose[1, 1] = 0.0 + root_pose[1, 2] = 1.0 + root_pose[1, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + + root_vel = cube_object.data.root_vel_w.torch.clone() + root_vel[:, :] = 0.0 + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) sim.step() cube_object.update(sim.cfg.dt) @@ -655,7 +677,7 @@ def test_global_force_torque_far_from_origin(device): positions = com_pos.clone() positions[..., 0] += 1.0 # +1m X offset from CoM - cube_object.permanent_wrench_composer.set_forces_and_torques( + cube_object.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, positions=positions, @@ -723,19 +745,21 @@ def test_global_force_no_position_no_rotation_large_offset(device): body_ids, _ = cube_object.find_bodies(".*") # Place cube at large X offset - root_state = cube_object.data.default_root_state.torch.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:]) + root_pose = cube_object.data.default_root_pose.torch.clone() + root_pose[0, 0] = 2000.0 # large X position + root_pose[0, 1] = 0.0 + root_pose[0, 2] = 1.0 + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + + root_vel = cube_object.data.default_root_vel.torch.clone() + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) 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( + cube_object.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, body_ids=body_ids, is_global=True, @@ -778,12 +802,14 @@ def test_global_force_at_com_position_no_rotation_large_offset(device): body_ids, _ = cube_object.find_bodies(".*") # Place cube at large X offset - root_state = cube_object.data.default_root_state.torch.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:]) + root_pose = cube_object.data.default_root_pose.torch.clone() + root_pose[0, 0] = 2000.0 + root_pose[0, 1] = 0.0 + root_pose[0, 2] = 1.0 + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + + root_vel = cube_object.data.default_root_vel.torch.clone() + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) cube_object.reset() # Apply global force AT the cube's position (torque should cancel) @@ -794,7 +820,7 @@ def test_global_force_at_com_position_no_rotation_large_offset(device): positions[0, :, 0] = 2000.0 positions[0, :, 2] = 1.0 - cube_object.permanent_wrench_composer.set_forces_and_torques( + cube_object.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, positions=positions, body_ids=body_ids, diff --git a/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py b/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py index 8d47b003369c..ca8f22be437c 100644 --- a/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py +++ b/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py @@ -110,7 +110,7 @@ def test_composer_vs_physx_local_force(device): forces[..., 0] = FORCE_MAGNITUDE torques = torch.zeros(1, len(body_ids), 3, device=device) - cube_composer.permanent_wrench_composer.set_forces_and_torques( + cube_composer.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, body_ids=body_ids, @@ -175,7 +175,7 @@ def test_composer_vs_physx_global_force(device): forces[..., 0] = FORCE_MAGNITUDE torques = torch.zeros(1, len(body_ids), 3, device=device) - cube_composer.permanent_wrench_composer.set_forces_and_torques( + cube_composer.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, body_ids=body_ids, @@ -249,7 +249,7 @@ def test_composer_vs_physx_local_force_at_position(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( + cube_composer.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, positions=positions, @@ -322,7 +322,7 @@ def test_composer_vs_physx_global_force_at_position(device): pos_composer = cube_composer.data.body_com_pos_w.torch[:, body_ids, :3].clone() + offset pos_raw = cube_raw.data.body_com_pos_w.torch[:, body_ids, :3].clone() + offset - cube_composer.permanent_wrench_composer.set_forces_and_torques( + cube_composer.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, positions=pos_composer, @@ -387,7 +387,7 @@ def test_composer_vs_physx_local_torque(device): torques = torch.zeros(1, len(body_ids), 3, device=device) torques[..., 2] = TORQUE_MAGNITUDE - cube_composer.permanent_wrench_composer.set_forces_and_torques( + cube_composer.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, body_ids=body_ids, @@ -452,7 +452,7 @@ def test_composer_vs_physx_global_torque(device): torques = torch.zeros(1, len(body_ids), 3, device=device) torques[..., 2] = TORQUE_MAGNITUDE - cube_composer.permanent_wrench_composer.set_forces_and_torques( + cube_composer.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, body_ids=body_ids, @@ -513,7 +513,7 @@ def test_composer_vs_physx_global_force_multi_env(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( + cube_composer.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, body_ids=body_ids, @@ -605,7 +605,7 @@ def apply_global_force(): 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( + cube_composer.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, body_ids=body_ids, @@ -641,8 +641,18 @@ def apply_global_force(): 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.write_root_link_pose_to_sim_index( + root_pose=initial_state_composer[reset_ids_torch, :7], env_ids=reset_ids_torch + ) + cube_composer.write_root_com_velocity_to_sim_index( + root_velocity=initial_state_composer[reset_ids_torch, 7:], env_ids=reset_ids_torch + ) + cube_raw.write_root_link_pose_to_sim_index( + root_pose=initial_state_raw[reset_ids_torch, :7], env_ids=reset_ids_torch + ) + cube_raw.write_root_com_velocity_to_sim_index( + root_velocity=initial_state_raw[reset_ids_torch, 7:], env_ids=reset_ids_torch + ) cube_composer.reset(reset_ids) cube_raw.reset(reset_ids) @@ -717,7 +727,7 @@ def test_composer_vs_physx_payload_scenario(device): forces[..., 2] = -payload_force torques = torch.zeros(1, len(body_ids), 3, device=device) - cube_composer.permanent_wrench_composer.set_forces_and_torques( + cube_composer.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, body_ids=body_ids, @@ -790,7 +800,7 @@ def test_composer_vs_physx_permanent_global_force_at_position_long_run(device): pos_composer = cube_composer.data.body_com_pos_w.torch[:, body_ids, :3].clone() + offset pos_raw = cube_raw.data.body_com_pos_w.torch[:, body_ids, :3].clone() + offset - cube_composer.permanent_wrench_composer.set_forces_and_torques( + cube_composer.permanent_wrench_composer.set_forces_and_torques_index( forces=forces, torques=torques, positions=pos_composer, From a64fcf1cb986fa603ecb137ec6a62b68b45d262c Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 8 May 2026 09:41:52 +0200 Subject: [PATCH 017/133] Adds PVA and joint wrench sensor docs (#5532) # Description Adds the missing core-concepts sensor documentation for the ground-truth PVA sensor and joint wrench sensor. The sensor overview now links both pages, the public `isaaclab.sensors` API page includes `Pva`, `PvaData`, and `PvaCfg`, and the sensor module table documents the joint wrench sensor prim-path expectation. Fixes isaac-sim/IsaacLab-Internal#880 Validation: - `./isaaclab.sh -f` - `git diff --check` - Verified `origin/develop` did not list `pva` or `joint_wrench_sensor` from `docs/source/overview/core-concepts/sensors/index.rst`, and this branch does. - Parsed the two new RST pages with `docutils` using local stubs for Sphinx-only directives and roles. - `make -C docs current-docs` was attempted locally but could not run because `sphinx-build` is not installed in this environment. ## Type of change - Documentation update ## Screenshots N/A; documentation text 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 - [ ] 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 added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` -- CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/source/api/lab/isaaclab.sensors.rst | 22 ++++++++ .../migration/migrating_to_isaaclab_3-0.rst | 4 ++ .../overview/core-concepts/sensors/index.rst | 2 + .../sensors/joint_wrench_sensor.rst | 42 +++++++++++++++ .../overview/core-concepts/sensors/pva.rst | 53 +++++++++++++++++++ scripts/demos/sensors/pva_sensor.py | 4 +- .../antoiner-docs-sensor-updates.rst | 8 +++ source/isaaclab/isaaclab/sensors/__init__.py | 2 + 8 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 docs/source/overview/core-concepts/sensors/joint_wrench_sensor.rst create mode 100644 docs/source/overview/core-concepts/sensors/pva.rst create mode 100644 source/isaaclab/changelog.d/antoiner-docs-sensor-updates.rst diff --git a/docs/source/api/lab/isaaclab.sensors.rst b/docs/source/api/lab/isaaclab.sensors.rst index 1beccd5481f1..537dc8dcaf43 100644 --- a/docs/source/api/lab/isaaclab.sensors.rst +++ b/docs/source/api/lab/isaaclab.sensors.rst @@ -36,6 +36,9 @@ MultiMeshRayCasterCameraCfg Imu ImuCfg + Pva + PvaData + PvaCfg JointWrenchSensor JointWrenchSensorData JointWrenchSensorCfg @@ -193,6 +196,25 @@ Inertia Measurement Unit :show-inheritance: :exclude-members: __init__, class_type +Pose Velocity Acceleration Sensor +--------------------------------- + +.. autoclass:: Pva + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: PvaData + :members: + :inherited-members: + :exclude-members: __init__ + +.. autoclass:: PvaCfg + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__, class_type + Joint Wrench Sensor ------------------- diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index 4d29d5f4c8fe..be5703ab1261 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -251,6 +251,8 @@ If you were using the old ``Imu`` sensor, you need to decide which new sensor to - Use :class:`~isaaclab.sensors.Imu` / :class:`~isaaclab.sensors.ImuCfg` if you only need angular velocity and linear acceleration (as a real IMU provides). +For configuration and data access examples, see the :ref:`overview_sensors_pva`. + **Import changes:** .. code-block:: python @@ -346,6 +348,8 @@ implementations and returns separate force [N] and torque [N·m] buffers. The sensor reports wrenches in the child-side incoming joint frame, with torque referenced at the child-side joint anchor. +For configuration and data access examples, see the :ref:`overview_sensors_joint_wrench`. + **Before (Isaac Lab 2.x):** .. code-block:: python diff --git a/docs/source/overview/core-concepts/sensors/index.rst b/docs/source/overview/core-concepts/sensors/index.rst index d2c63f212b76..f32923ab9c30 100644 --- a/docs/source/overview/core-concepts/sensors/index.rst +++ b/docs/source/overview/core-concepts/sensors/index.rst @@ -18,5 +18,7 @@ The following pages describe the available sensors in more detail: contact_sensor frame_transformer imu + pva + joint_wrench_sensor ray_caster visuo_tactile_sensor diff --git a/docs/source/overview/core-concepts/sensors/joint_wrench_sensor.rst b/docs/source/overview/core-concepts/sensors/joint_wrench_sensor.rst new file mode 100644 index 000000000000..d1357645ddc1 --- /dev/null +++ b/docs/source/overview/core-concepts/sensors/joint_wrench_sensor.rst @@ -0,0 +1,42 @@ +.. _overview_sensors_joint_wrench: + +.. currentmodule:: isaaclab + +Joint Wrench Sensor +=================== + +The joint wrench sensor reports incoming joint reaction wrenches for selected +articulation bodies. It exposes force [N] and torque [N·m] buffers separately, +with entries ordered by the sensor's :attr:`~isaaclab.sensors.JointWrenchSensor.body_names`. +The default convention is ``incoming_joint_frame``, which expresses each wrench +in the child-side joint frame at the child-side joint anchor. + +The sensor is configured on an articulation prim and can then be used directly +or through manager terms such as :func:`~isaaclab.envs.mdp.body_incoming_wrench`. +For example, the Ant environment adds a joint wrench sensor to the scene: + +.. literalinclude:: ../../../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py + :language: python + :lines: 91-95 + +The same environment uses :class:`~isaaclab.managers.SceneEntityCfg` to select +the reported foot bodies for an observation term: + +.. literalinclude:: ../../../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py + :language: python + :lines: 133-142 + +Direct access to the sensor data follows the usual scene lookup pattern. + +.. code-block:: python + + joint_wrench = scene["joint_wrench"] + foot_ids, _ = joint_wrench.find_bodies([".*foot"]) + + force = joint_wrench.data.force.torch[:, foot_ids] + torque = joint_wrench.data.torque.torch[:, foot_ids] + wrench = torch.cat((force, torque), dim=-1) + +The resulting ``wrench`` tensor has shape ``(num_envs, num_selected_bodies, 6)`` +and stores the force components followed by the torque components for each +selected body. diff --git a/docs/source/overview/core-concepts/sensors/pva.rst b/docs/source/overview/core-concepts/sensors/pva.rst new file mode 100644 index 000000000000..aa522984bd51 --- /dev/null +++ b/docs/source/overview/core-concepts/sensors/pva.rst @@ -0,0 +1,53 @@ +.. _overview_sensors_pva: + +.. currentmodule:: isaaclab + +Pose Velocity Acceleration (PVA) Sensor +======================================= + +The Pose Velocity Acceleration (PVA) sensor is a ground-truth sensor for reading +the kinematic state of a frame in the simulation. It reports the sensor pose in +the world frame, projected gravity, linear and angular velocities in the sensor +frame, and coordinate accelerations in the sensor frame. Unlike +:class:`~isaaclab.sensors.Imu`, the PVA sensor does not model proper +acceleration from an accelerometer. Use the IMU sensor when the observation +should include accelerometer-like gravity bias behavior. + +The sensor can be attached to a rigid body or to a child prim under a rigid-body +ancestor. If the configured prim is not itself rigid, Isaac Lab queries the +closest rigid ancestor and composes the fixed transform to the requested prim +with the configured sensor offset. + +Consider a simple environment with an Anymal Quadruped equipped with PVA sensors +on its front feet. + +.. literalinclude:: ../../../../../scripts/demos/sensors/pva_sensor.py + :language: python + :lines: 43-59 + +Retrieving values from the sensor follows the same pattern as the other Isaac +Lab sensors. The data fields are exposed as :class:`~isaaclab.utils.warp.ProxyArray` +buffers and can be converted to Torch tensors with the ``torch`` property. + +.. code-block:: python + + pva_data = scene["pva_LF"].data + print("Pose in world frame: ", pva_data.pose_w.torch) + print("Linear velocity in PVA frame: ", pva_data.lin_vel_b.torch) + print("Angular velocity in PVA frame: ", pva_data.ang_vel_b.torch) + print("Linear acceleration in PVA frame: ", pva_data.lin_acc_b.torch) + print("Angular acceleration in PVA frame: ", pva_data.ang_acc_b.torch) + print("Projected gravity in PVA frame: ", pva_data.projected_gravity_b.torch) + +The complete demo can be run with: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/demos/sensors/pva_sensor.py + +.. dropdown:: Code for pva_sensor.py + :icon: code + + .. literalinclude:: ../../../../../scripts/demos/sensors/pva_sensor.py + :language: python + :linenos: diff --git a/scripts/demos/sensors/pva_sensor.py b/scripts/demos/sensors/pva_sensor.py index a51ea7c17cc9..31066fb59164 100644 --- a/scripts/demos/sensors/pva_sensor.py +++ b/scripts/demos/sensors/pva_sensor.py @@ -54,9 +54,9 @@ class PvaSensorSceneCfg(InteractiveSceneCfg): # robot robot = ANYMAL_C_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") - pva_RF = PvaCfg(prim_path="{ENV_REGEX_NS}/Robot/LF_FOOT", debug_vis=True) + pva_LF = PvaCfg(prim_path="{ENV_REGEX_NS}/Robot/LF_FOOT", debug_vis=True) - pva_LF = PvaCfg(prim_path="{ENV_REGEX_NS}/Robot/RF_FOOT", debug_vis=True) + pva_RF = PvaCfg(prim_path="{ENV_REGEX_NS}/Robot/RF_FOOT", debug_vis=True) def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): diff --git a/source/isaaclab/changelog.d/antoiner-docs-sensor-updates.rst b/source/isaaclab/changelog.d/antoiner-docs-sensor-updates.rst new file mode 100644 index 000000000000..fd8e62a260cb --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-docs-sensor-updates.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Fixed the sensor overview documentation to include + :class:`~isaaclab.sensors.Pva` and + :class:`~isaaclab.sensors.JointWrenchSensor`. +* Fixed the PVA sensor demo to align front-foot sensor names with their prim + paths. diff --git a/source/isaaclab/isaaclab/sensors/__init__.py b/source/isaaclab/isaaclab/sensors/__init__.py index 717fc4a7163c..fa578a2677d0 100644 --- a/source/isaaclab/isaaclab/sensors/__init__.py +++ b/source/isaaclab/isaaclab/sensors/__init__.py @@ -34,6 +34,8 @@ +---------------------+---------------------------+---------------------------------------------------------------+ | Pva | /World/robot/base | Leaf exists and is a physics body (Rigid Body) | +---------------------+---------------------------+---------------------------------------------------------------+ +| Joint Wrench Sensor | /World/robot | Leaf exists and is an articulation | ++---------------------+---------------------------+---------------------------------------------------------------+ """ From a784ed9d3faecded3055cb3f26a21a65a0d6db01 Mon Sep 17 00:00:00 2001 From: hujc Date: Fri, 8 May 2026 03:14:47 -0700 Subject: [PATCH 018/133] [CI unblock] Skip viewergl-fully-black test + fix warp intersphinx 404 (#5538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Two unrelated CI breakages on develop, bundled here so develop turns green in one PR. ### 1. Skip the failing viewergl test `test_cartpole_newton_visualizer_viewergl_rgb_motion[physx,newton]` started returning all-black frames on develop after `nvcr.io/nvidian/isaac-sim:latest-develop` flipped to a Kit 110.1.1 + USD 25.11 base. The failure has been deterministic across multiple PRs (#5523, #5495, #5408, …). Investigation so far has ruled out: - PR https://github.com/isaac-sim/IsaacLab/pull/5521 (revert in https://github.com/isaac-sim/IsaacLab/pull/5539 still failed) - Newton 1.0 → 1.2.0rc2 viewer code regression (only 7-line addition; ViewerGL alone yields 1.08M nonzero pixels) - warp 1.12 → 1.13 RegisteredGLBuffer ABI (byte-identical) - Module-load side effects of `isaaclab_physx.renderers` - CUDA-GL interop (PR #5540 diagnostic confirms direct CPU FBO readback also returns zeros, with `GL_NO_ERROR`) - GL context-currency (PR #5541 H6 attempt: still fails) - GL/CUDA sync (PR #5542 H4 attempt: still fails) Diagnostic output (PR #5540 v2): ``` [VIZDIAG] fbo=c_uint(8) pbo=None size=600x600 [VIZDIAG] glGetError before: GL_NO_ERROR [VIZDIAG] CPU-readback: nonzero=0/1080000 max=0 err=GL_NO_ERROR [VIZDIAG] PBO-result: nonzero=0/1080000 max=0 ``` The FBO itself is empty — Newton's pyglet/EGL renderer is not depositing pixels under Kit 110.1.1, even though `tiled_camera_rgb_non_black` (Kit RTX path) on the same env passes. Underlying root cause still being chased; this PR ships the skip to unblock develop. ### 2. Fix warp intersphinx 404 in docs build `https://nvidia.github.io/warp/objects.inv` started returning 404 — Warp's `objects.inv` only lives at `/stable/` and `/latest/` now. With Sphinx's `warnings_treated_as_errors`, the broken intersphinx fetch fails the docs build on every PR. Pinning to `/stable/` (matches the existing PyTorch `/docs/2.11/` workaround pattern in the same file). Verified `https://nvidia.github.io/warp/stable/objects.inv` returns 200. ## Test plan - [x] CI `isaaclab_visualizers` on this branch — was passing earlier with the skip; will re-verify with the bundled docs fix - [ ] CI `Build Latest Docs` on this branch — must turn green (was failing on every recent PR before this fix) ## Re-enable plan Once the underlying viewergl bug is identified and fixed, drop the `@pytest.mark.skip` decorator and remove the `jichuanh-disable-viewergl-flaky.skip` fragment. --- docs/conf.py | 3 ++- .../jichuanh-fix-warp-intersphinx.rst | 5 +++++ .../jichuanh-disable-viewergl-flaky.skip | 0 .../test/test_visualizer_cartpole_integration.py | 16 ++++++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-fix-warp-intersphinx.rst create mode 100644 source/isaaclab_visualizers/changelog.d/jichuanh-disable-viewergl-flaky.skip diff --git a/docs/conf.py b/docs/conf.py index 792ee6eeecbc..65ad5468d34e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -140,7 +140,8 @@ "torch": ("https://docs.pytorch.org/docs/2.11/", None), "isaacsim": ("https://docs.isaacsim.omniverse.nvidia.com/6.0.0/py/", None), "gymnasium": ("https://gymnasium.farama.org/", None), - "warp": ("https://nvidia.github.io/warp/", None), + # NOTE: pinned to /stable/ because /objects.inv at the root currently 404s + "warp": ("https://nvidia.github.io/warp/stable/", None), "omniverse": ("https://docs.omniverse.nvidia.com/dev-guide/latest", None), } diff --git a/source/isaaclab/changelog.d/jichuanh-fix-warp-intersphinx.rst b/source/isaaclab/changelog.d/jichuanh-fix-warp-intersphinx.rst new file mode 100644 index 000000000000..7aa72335f9e1 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-fix-warp-intersphinx.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed Sphinx docs build failing due to ``https://nvidia.github.io/warp/objects.inv`` returning 404. + Pinned the ``warp`` intersphinx mapping to ``/stable/``, which is where the inventory now lives. diff --git a/source/isaaclab_visualizers/changelog.d/jichuanh-disable-viewergl-flaky.skip b/source/isaaclab_visualizers/changelog.d/jichuanh-disable-viewergl-flaky.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py index 42d1368dcebf..60f9921415cc 100644 --- a/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py +++ b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py @@ -522,6 +522,22 @@ def test_cartpole_newton_visualizer_tiled_camera_rgb_non_black( @pytest.mark.isaacsim_ci +@pytest.mark.skip( + reason=( + "ViewerGL.get_frame returns a fully-black 600x600x3 buffer in CI on the current " + "Isaac Sim image + Newton 1.2.0rc2 + warp-lang 1.13 cohort. Failure is " + "deterministic across two consecutive reruns of the same SHA and reproduces on " + "every PR that touches the rendering / camera / sensor / USD stack (5 PRs hit it " + "in the last 100 build.yaml runs); zero failures on PRs outside that scope. " + "Investigation ruled out: rc1->rc2 viewer code diff (7-line image_logger.clear " + "only), wp.RegisteredGLBuffer API (byte-identical 1.12 vs 1.13), pure flakiness " + "(deterministic), and the bump cohort alone (warp-1.12 branches both pass and " + "fail). Strongest remaining hypothesis: a CUDA-OpenGL interop init-order " + "fragility in the PBO + glReadPixels + RegisteredGLBuffer.map path that gets " + "tipped by any source change perturbing GL/CUDA bring-up. Re-enable once root " + "cause is identified." + ) +) @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.""" From 21a7919c48fc1360a7acc32d1344c04d8e314553 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 8 May 2026 13:44:21 +0200 Subject: [PATCH 019/133] Replicates fk invalidation on other assets (#5367) # Description Replicates fk invalidation on other assets in Newton. Fixes #5359 ## 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 --- .../antoiner-fix-fk-invalidation.rst | 6 ++ .../assets/rigid_object/rigid_object.py | 4 + .../assets/rigid_object/rigid_object_data.py | 7 ++ .../test/assets/test_rigid_object.py | 74 +++++++++++++++++++ .../assets/test_rigid_object_collection.py | 63 ++++++++++++++++ 5 files changed, 154 insertions(+) create mode 100644 source/isaaclab_newton/changelog.d/antoiner-fix-fk-invalidation.rst diff --git a/source/isaaclab_newton/changelog.d/antoiner-fix-fk-invalidation.rst b/source/isaaclab_newton/changelog.d/antoiner-fix-fk-invalidation.rst new file mode 100644 index 000000000000..e3f16a0a8893 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/antoiner-fix-fk-invalidation.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed stale Newton forward-kinematics state after explicit pose writes so + downstream collision queries and :attr:`~isaaclab_newton.assets.RigidObjectData.body_link_pose_w` + reads use updated transforms. 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 7e31fa22b60f..b93c9075393d 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 @@ -334,6 +334,7 @@ def write_root_link_pose_to_sim_index( self.data._root_link_state_w.timestamp = -1.0 if self.data._root_state_w is not None: self.data._root_state_w.timestamp = -1.0 + self.data._fk_timestamp = -1.0 # Forces a kinematic update to get the latest body link poses. SimulationManager.invalidate_fk(env_ids=env_ids, articulation_ids=self._root_view.articulation_ids) def write_root_link_pose_to_sim_mask( @@ -382,6 +383,7 @@ def write_root_link_pose_to_sim_mask( self.data._root_link_state_w.timestamp = -1.0 if self.data._root_state_w is not None: self.data._root_state_w.timestamp = -1.0 + self.data._fk_timestamp = -1.0 # Forces a kinematic update to get the latest body link poses. SimulationManager.invalidate_fk(env_mask=env_mask, articulation_ids=self._root_view.articulation_ids) def write_root_com_pose_to_sim_index( @@ -437,6 +439,7 @@ def write_root_com_pose_to_sim_index( self.data._root_link_state_w.timestamp = -1.0 if self.data._root_state_w is not None: self.data._root_state_w.timestamp = -1.0 + self.data._fk_timestamp = -1.0 # Forces a kinematic update to get the latest body link poses. SimulationManager.invalidate_fk(env_ids=env_ids, articulation_ids=self._root_view.articulation_ids) def write_root_com_pose_to_sim_mask( @@ -489,6 +492,7 @@ def write_root_com_pose_to_sim_mask( self.data._root_link_state_w.timestamp = -1.0 if self.data._root_state_w is not None: self.data._root_state_w.timestamp = -1.0 + self.data._fk_timestamp = -1.0 # Forces a kinematic update to get the latest body link poses. SimulationManager.invalidate_fk(env_mask=env_mask, articulation_ids=self._root_view.articulation_ids) def write_root_com_velocity_to_sim_index( diff --git a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py index 0e9ecc8a41d0..43e719d3a580 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py @@ -76,6 +76,7 @@ def __init__(self, root_view: ArticulationView, device: str): # Set initial time stamp self._sim_timestamp = 0.0 self._is_primed = False + self._fk_timestamp = 0.0 # Convert to direction vector gravity = wp.to_torch(SimulationManager.get_model().gravity)[0] @@ -121,6 +122,9 @@ def update(self, dt: float) -> None: """ # update the simulation timestamp self._sim_timestamp += dt + # FK is current after a sim step — keep fk_timestamp in sync unless it was explicitly invalidated + if self._fk_timestamp >= 0.0: + self._fk_timestamp = self._sim_timestamp # Trigger an update of the body com acceleration buffer at a higher frequency # since we do finite differencing. self.body_com_acc_w @@ -291,6 +295,9 @@ def body_link_pose_w(self) -> ProxyArray: This quantity is the pose of the actor frame of the rigid body relative to the world. The orientation is provided in (x, y, z, w) format. """ + if self._fk_timestamp < self._sim_timestamp: + SimulationManager.forward() + self._fk_timestamp = self._sim_timestamp return self._body_link_pose_w_ta @property diff --git a/source/isaaclab_newton/test/assets/test_rigid_object.py b/source/isaaclab_newton/test/assets/test_rigid_object.py index 67796416dce6..ba2c47e24f9b 100644 --- a/source/isaaclab_newton/test/assets/test_rigid_object.py +++ b/source/isaaclab_newton/test/assets/test_rigid_object.py @@ -1259,3 +1259,77 @@ def test_warmup_attach_stage_not_called_for_cpu(): f"This indicates the CPU MBP broadphase double-initialization regression is present: " f"attach_stage() + force_load_physics_from_usd() must not be combined for CPU." ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("writer", ["link_index", "link_mask", "com_index", "com_mask"]) +@pytest.mark.isaacsim_ci +def test_body_link_pose_w_fresh_after_root_pose_write(device, writer): + """Regression: ``body_link_pose_w`` must reflect a freshly written root pose without an intervening sim step. + + After ``write_root_{link,com}_pose_to_sim_{index,mask}``, the cached ``_sim_bind_body_link_pose_w`` + (Newton ``body_q``) is stale until forward kinematics is re-evaluated. The getter must call + :meth:`SimulationManager.forward` so the returned tensor matches the written pose. Without the fix, + the getter returns the pre-write value. The write must also dirty the simulator-side + ``_fk_reset_mask`` so collision queries (which read ``body_q`` directly, not via the property) + re-run FK before the next step. + """ + + def _fk_reset_mask_dirty() -> bool: + assert SimulationManager._fk_reset_mask is not None + return bool(wp.to_torch(SimulationManager._fk_reset_mask).any().item()) + + num_cubes = 2 + with _newton_sim_context(device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, height=0.5, device=device) + + sim.reset() + assert cube_object.is_initialized + + # Step once so that _sim_timestamp > 0 and caches are primed. + sim.step() + cube_object.update(sim.cfg.dt) + + # Prime the body_link_pose_w cache with the current pose. + pre_write_pose = wp.to_torch(cube_object.data.body_link_pose_w).clone().view(num_cubes, 7) + + # Clear the dirty flag so we can observe that the write sets it. + SimulationManager.forward() + assert not _fk_reset_mask_dirty() + + # Build a target pose clearly distinct from the current one in both translation and orientation. + # Quaternion in (x, y, z, w) for 90° about z: [0, 0, sin(pi/4), cos(pi/4)] = [0, 0, sqrt(0.5), sqrt(0.5)]. + target_pose = wp.to_torch(cube_object.data.root_link_pose_w).clone() + target_pose[..., 0] += 10.0 + target_pose[..., 1] += 5.0 + target_pose[..., 2] += 2.0 + sqrt_half = 0.7071067811865476 + target_pose[..., 3] = 0.0 + target_pose[..., 4] = 0.0 + target_pose[..., 5] = sqrt_half + target_pose[..., 6] = sqrt_half + + if writer == "link_index": + cube_object.write_root_link_pose_to_sim_index(root_pose=target_pose) + elif writer == "link_mask": + cube_object.write_root_link_pose_to_sim_mask(root_pose=target_pose) + elif writer == "com_index": + cube_object.write_root_com_pose_to_sim_index(root_pose=target_pose) + elif writer == "com_mask": + cube_object.write_root_com_pose_to_sim_mask(root_pose=target_pose) + + # The simulator-side dirty flag must be set before any property read clears it via forward(). + assert _fk_reset_mask_dirty(), "pose write must call SimulationManager.invalidate_fk()" + + # Read without stepping: getter must trigger forward kinematics and return the fresh pose. + body_link = wp.to_torch(cube_object.data.body_link_pose_w).view(num_cubes, 7) + # Defeat alias accidents: the property must not still return the pre-write value. + assert not torch.allclose(body_link[..., :3], pre_write_pose[..., :3], rtol=1e-4, atol=1e-4), ( + "body_link_pose_w returned the pre-write cached pose; forward() was not invoked" + ) + # Translation must match the write. + torch.testing.assert_close(body_link[..., :3], target_pose[..., :3], rtol=1e-4, atol=1e-4) + # Orientation: compare via |q1 · q2| ≈ 1 to account for the q ≡ -q double cover. + quat_dot = torch.abs((body_link[..., 3:7] * target_pose[..., 3:7]).sum(dim=-1)) + torch.testing.assert_close(quat_dot, torch.ones_like(quat_dot), rtol=1e-4, atol=1e-4) 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 cec62a98bcd3..5ee470469548 100644 --- a/source/isaaclab_newton/test/assets/test_rigid_object_collection.py +++ b/source/isaaclab_newton/test/assets/test_rigid_object_collection.py @@ -892,3 +892,66 @@ def test_write_object_state_functions_data_consistency(num_envs, num_cubes, devi torch.testing.assert_close(body_com_vel_w, com_vel_w) torch.testing.assert_close(body_link_pose_w, link_pose_w) torch.testing.assert_close(body_com_vel_w[..., 3:], link_vel_w[..., 3:]) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("writer", ["link_index", "link_mask", "com_index", "com_mask"]) +@pytest.mark.isaacsim_ci +def test_body_pose_write_marks_fk_reset_mask(device, writer): + """Regression: ``write_body_{link,com}_pose_to_sim_{index,mask}`` must mark FK dirty. + + For a collection, ``_sim_bind_body_link_pose_w`` is bound directly to the simulator's root-transforms + buffer, so the property read is not what becomes stale — the simulator's internal ``body_q`` used by + collision detection is. The write methods must therefore call :meth:`SimulationManager.invalidate_fk` + so downstream consumers re-run forward kinematics before the next step. Without the fix, + ``_fk_reset_mask`` remains unset after an explicit pose write. The buffer-aliasing invariant is + also pinned: a refactor that decouples ``_sim_bind_body_link_pose_w`` from the write target would + silently make the property stale, so we check the post-write pose matches the written value. + """ + + def _fk_reset_mask_dirty() -> bool: + assert SimulationManager._fk_reset_mask is not None + return bool(wp.to_torch(SimulationManager._fk_reset_mask).any().item()) + + num_envs = 2 + num_cubes = 2 + with _newton_sim_context(device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, height=0.5, device=device) + + sim.reset() + assert cube_object.is_initialized + + sim.step() + cube_object.update(sim.cfg.dt) + + # Clear the dirty flag so we can observe that the write sets it. + SimulationManager.forward() + assert not _fk_reset_mask_dirty() + + pre_write_pose = wp.to_torch(cube_object.data.body_link_pose_w).clone() + + target_pose = wp.to_torch(cube_object.data.body_link_pose_w).clone() + target_pose[..., 0] += 10.0 + target_pose[..., 1] += 5.0 + target_pose[..., 2] += 2.0 + + if writer == "link_index": + cube_object.write_body_link_pose_to_sim_index(body_poses=target_pose) + elif writer == "link_mask": + cube_object.write_body_link_pose_to_sim_mask(body_poses=target_pose) + elif writer == "com_index": + cube_object.write_body_com_pose_to_sim_index(body_poses=target_pose) + elif writer == "com_mask": + cube_object.write_body_com_pose_to_sim_mask(body_poses=target_pose) + + assert _fk_reset_mask_dirty(), "pose write must call SimulationManager.invalidate_fk()" + + # body_link_pose_w must reflect the write immediately — its underlying buffer is the write + # target. A regression that moves this property to a separate cached buffer (mirroring the + # single-object case) would silently break this invariant. + body_link = wp.to_torch(cube_object.data.body_link_pose_w) + assert not torch.allclose(body_link[..., :3], pre_write_pose[..., :3], rtol=1e-4, atol=1e-4), ( + "body_link_pose_w still aliases the pre-write pose; the underlying buffer was not written" + ) + torch.testing.assert_close(body_link[..., :3], target_pose[..., :3], rtol=1e-4, atol=1e-4) From 513a017b6ad407bcb0e799ef49a4bb8cc3d55b99 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 8 May 2026 14:19:58 +0200 Subject: [PATCH 020/133] Updates task deprecation call sites (#5410) ## Summary - Migrates task/contrib camera callers from TiledCamera aliases to Camera. - Updates task state reads and in-hand write/target helper calls to explicit APIs. - Bumps task/contrib changelogs and extension versions for touched packages. ## Verification - ./isaaclab.sh -f - Scoped deprecated-call-site search: concrete task/contrib deprecated calls removed. Rebased onto develop after PR #5304 merged. --- ...-5410-task-deprecation-warning-cleanup.rst | 6 ++++++ .../tacsl_sensor/visuotactile_sensor.py | 3 ++- ...-5410-task-deprecation-warning-cleanup.rst | 8 ++++++++ .../inhand_manipulation_env.py | 20 ++++++------------- 4 files changed, 22 insertions(+), 15 deletions(-) create mode 100644 source/isaaclab_contrib/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst create mode 100644 source/isaaclab_tasks/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst diff --git a/source/isaaclab_contrib/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst b/source/isaaclab_contrib/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst new file mode 100644 index 000000000000..9a8805b42e1d --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Updated TacSL visuotactile sensor camera configuration and examples to use + :class:`~isaaclab.sensors.CameraCfg` and :class:`~isaaclab.sensors.Camera` + instead of deprecated tiled-camera aliases. diff --git a/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py b/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py index 720a85223aa6..12ff6cfd3e8d 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py +++ b/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py @@ -66,7 +66,8 @@ class VisuoTactileSensor(SensorBase): The following requirements must be satisfied for proper sensor operation: **Camera Tactile Imaging** - If ``enable_camera_tactile=True``, a valid ``camera_cfg`` (CameraCfg) must be + If ``enable_camera_tactile=True``, a valid ``camera_cfg`` + (:class:`~isaaclab.sensors.CameraCfg`) must be provided with appropriate camera parameters. **Force Field Computation** diff --git a/source/isaaclab_tasks/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst b/source/isaaclab_tasks/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst new file mode 100644 index 000000000000..6fd8e22e713f --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Updated task camera configs and environments to use + :class:`~isaaclab.sensors.CameraCfg` and :class:`~isaaclab.sensors.Camera` + instead of deprecated tiled-camera aliases. +* Updated task state and write call sites to use explicit state properties and + indexed simulation write APIs. diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/inhand_manipulation/inhand_manipulation_env.py b/source/isaaclab_tasks/isaaclab_tasks/direct/inhand_manipulation/inhand_manipulation_env.py index 5969d8c9c4be..bd178f3745ea 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/inhand_manipulation/inhand_manipulation_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/inhand_manipulation/inhand_manipulation_env.py @@ -85,20 +85,12 @@ def __init__(self, cfg: AllegroHandEnvCfg | ShadowHandEnvCfg, render_mode: str | self.y_unit_tensor = torch.tensor([0, 1, 0], dtype=torch.float, device=self.device).repeat((self.num_envs, 1)) self.z_unit_tensor = torch.tensor([0, 0, 1], dtype=torch.float, device=self.device).repeat((self.num_envs, 1)) - # bind backend-optimal write methods (Newton prefers mask-based, PhysX prefers indexed) - use_mask = "newton" in self.sim.physics_manager.__name__.lower() - if use_mask: - self._set_joint_pos_target = self.hand.set_joint_position_target - self._write_obj_root_pose = self.object.write_root_pose_to_sim - self._write_obj_root_vel = self.object.write_root_velocity_to_sim - self._write_hand_joint_pos = self.hand.write_joint_position_to_sim - self._write_hand_joint_vel = self.hand.write_joint_velocity_to_sim - else: - self._set_joint_pos_target = self.hand.set_joint_position_target_index - self._write_obj_root_pose = self.object.write_root_pose_to_sim_index - self._write_obj_root_vel = self.object.write_root_velocity_to_sim_index - self._write_hand_joint_pos = self.hand.write_joint_position_to_sim_index - self._write_hand_joint_vel = self.hand.write_joint_velocity_to_sim_index + # bind write methods + self._set_joint_pos_target = self.hand.set_joint_position_target_index + self._write_obj_root_pose = self.object.write_root_pose_to_sim_index + self._write_obj_root_vel = self.object.write_root_velocity_to_sim_index + self._write_hand_joint_pos = self.hand.write_joint_position_to_sim_index + self._write_hand_joint_vel = self.hand.write_joint_velocity_to_sim_index def _setup_scene(self): # add hand, in-hand object, and goal object From e15b1d0eaaee23d501d256347b43b32c0acc471b Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Fri, 8 May 2026 23:23:04 +0800 Subject: [PATCH 021/133] Enabled OVRTX rendering tests on CI (#5492) # Description Enabled OVRTX rendering tests on CI. `OVRTX 0.3` is not published yet, so we have to use `OVRTX 0.2` render output as golden images. Some of them are incorrect, I will update those images when we pin to `OVRTX 0.3`. 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 - [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 --- .github/workflows/build.yaml | 3 +++ .github/workflows/daily-compatibility.yml | 1 + .../installation/kitless_installation.rst | 2 -- .../huidongc-ovrtx-keep-system-alive.rst | 6 +++++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 1 + .../huidongc-enable-ovrtx-rendering.skip | 0 .../cartpole/newton-ovrtx_renderer-rgb.png | 4 +-- .../cartpole/newton-ovrtx_renderer-rgba.png | 4 +-- ...nderer-simple_shading_constant_diffuse.png | 2 +- ...tx_renderer-simple_shading_diffuse_mdl.png | 4 +-- ...ovrtx_renderer-simple_shading_full_mdl.png | 4 +-- .../newton-ovrtx_renderer-albedo.png | 4 +-- .../newton-ovrtx_renderer-rgb.png | 4 +-- .../newton-ovrtx_renderer-rgba.png | 4 +-- ...nderer-simple_shading_constant_diffuse.png | 4 +-- ...tx_renderer-simple_shading_diffuse_mdl.png | 4 +-- ...ovrtx_renderer-simple_shading_full_mdl.png | 4 +-- .../newton-ovrtx_renderer-albedo.png | 4 +-- .../shadow_hand/newton-ovrtx_renderer-rgb.png | 4 +-- .../newton-ovrtx_renderer-rgba.png | 4 +-- ...nderer-simple_shading_constant_diffuse.png | 4 +-- ...tx_renderer-simple_shading_diffuse_mdl.png | 4 +-- ...ovrtx_renderer-simple_shading_full_mdl.png | 4 +-- .../test/rendering_test_utils.py | 25 +++++++------------ tools/test_settings.py | 5 ++++ 25 files changed, 58 insertions(+), 51 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/huidongc-ovrtx-keep-system-alive.rst create mode 100644 source/isaaclab_tasks/changelog.d/huidongc-enable-ovrtx-rendering.skip diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b971940fe00b..d8e0a8e8c670 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -256,6 +256,7 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" + extra-pip-packages: "ovrtx" shard-index: "0" shard-count: "3" container-name: isaac-lab-tasks-1-test @@ -278,6 +279,7 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" + extra-pip-packages: "ovrtx" shard-index: "1" shard-count: "3" container-name: isaac-lab-tasks-2-test @@ -300,6 +302,7 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" + extra-pip-packages: "ovrtx" shard-index: "2" shard-count: "3" container-name: isaac-lab-tasks-3-test diff --git a/.github/workflows/daily-compatibility.yml b/.github/workflows/daily-compatibility.yml index 2e307bd1a4ad..b85ba3f3b49a 100644 --- a/.github/workflows/daily-compatibility.yml +++ b/.github/workflows/daily-compatibility.yml @@ -111,6 +111,7 @@ jobs: image-tag: ${{ env.DOCKER_IMAGE_TAG }} pytest-options: "" filter-pattern: "isaaclab_tasks" + extra-pip-packages: "ovrtx" - name: Copy All Test Results from IsaacLab Tasks Container run: | diff --git a/docs/source/setup/installation/kitless_installation.rst b/docs/source/setup/installation/kitless_installation.rst index 8a01caddc4d3..4c6768b3dc0a 100644 --- a/docs/source/setup/installation/kitless_installation.rst +++ b/docs/source/setup/installation/kitless_installation.rst @@ -105,8 +105,6 @@ OVRTX provides GPU-accelerated rendering for vision tasks without Kit. ./isaaclab.sh -i ov[ovrtx] - export LD_PRELOAD=$(python -c "import ovrtx, pathlib; print(pathlib.Path(ovrtx.__file__).parent / 'bin/plugins/libcarb.so')") - ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ --task Isaac-Repose-Cube-Shadow-Vision-Benchmark-Direct-v0 \ --headless --enable_cameras --num_envs 16 --max_iterations 10 \ diff --git a/source/isaaclab_ov/changelog.d/huidongc-ovrtx-keep-system-alive.rst b/source/isaaclab_ov/changelog.d/huidongc-ovrtx-keep-system-alive.rst new file mode 100644 index 000000000000..834776759402 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/huidongc-ovrtx-keep-system-alive.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Set ``keep_system_alive=True`` on the internal OVRTX ``RendererConfig`` in + :class:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer` so the renderer + system is not torn down prematurely during pytest sessions. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 5d1782373d87..00e0a1d06d3a 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -201,6 +201,7 @@ def initialize(self, spec: CameraRenderSpec): log_file_path=self.cfg.log_file_path, log_level=self.cfg.log_level, read_gpu_transforms=_IS_OVRTX_0_3_0_OR_NEWER, + keep_system_alive=True, ) self._renderer = Renderer(OVRTX_CONFIG) assert self._renderer, "Renderer should be valid after creation" diff --git a/source/isaaclab_tasks/changelog.d/huidongc-enable-ovrtx-rendering.skip b/source/isaaclab_tasks/changelog.d/huidongc-enable-ovrtx-rendering.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png index e47c06e2ca7c..f35e82ae6582 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4029eb71d2361c9fa12d255415bb9edcf1caaeb9d230ca2e6c4e67596c037dd1 -size 2580 +oid sha256:3d0e2d1f537f42cb34ed7a0616802193e3ae0bcef43fbc0dc0b015d8af8aa5c8 +size 2685 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png index 791497af827c..5a53a6f517a6 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e1fed2c618875f9f9b4520c52308b0831cf637835e7a62e7a84e96f914c1e83 -size 2882 +oid sha256:d985f4de8667d57b0ba2f44b8181541463c888becb0f38c8716c65c343658dfb +size 2999 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png index 87104cb87161..583746565afb 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47b5b15d79d0b61d00c0538caa0012172753a481ad6efb45df2888402be2f407 +oid sha256:11db8a198a6ccae0a7cdbce0e996eb74eab1a13dca65bf0e590752a95389a3dd size 391 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png index 7d05e4a7adbd..b33b4e8fd830 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2ba382c0804ea49b55fc5216c9f1e28c34d5cae95b33d9982fd55763df178cc -size 435 +oid sha256:bfce56fc89bb014ecc876c4302344085fd2ad1cd6685d2295141256c375b1fc3 +size 436 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png index 6b4f8389da06..27d490c4b23f 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d7af4ef2afca01d0bf4f9c069c5c3778fa07050bcd8091486541ab11f14e8227 -size 742 +oid sha256:f2b846bae771345dc6b5bea0c6148d3942a55cb012b079589ed7104a8357c9e2 +size 776 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png index 5199099a7587..8e51c396efae 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7cf76622f5f5cc7e7889fe6032ba4cda22516248fa7bb5e957f181c92c86b42b -size 3054 +oid sha256:04c9b2668a6e544403f00850a1d12f2cc5d661c4b2038a786607880ebc768cb9 +size 2768 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png index 544e2ffd450b..0395c5f3d11c 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3445142682c88dc5ce11c5d749c7754bf2d54bf0f1aab6420513a733bf3cf645 -size 14919 +oid sha256:4add42d2a43cad3e1bbc17d9f3e190fb6f480c4ace605de42dfdbccf5e02680d +size 14894 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png index c3b229d34871..ca7d07531895 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:179a5acba0a763fcc2317cd784f8c347ef48f0d81f8028a1a64996ade67f8706 -size 17836 +oid sha256:71375f209fb2cd8c2e0b7ea463f45ab662bb21b44aec8dcb74994e5969b6aecc +size 17747 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png index 46ce5933fb8b..7fefafde048e 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e1d94f0c6ae2e40a1b0ff9cf27a0f2f9b756ebcebd9ddf27b0da31c89e3a57f -size 1485 +oid sha256:dde0d7363cc8550dfa985178d26bf72b6a7f84157ab8503aa36889e652f7e061 +size 1509 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png index 2e2f6cb257a7..b5d197da550d 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:83ca3d8f55f971d473409c73e77582175906670f3962b56723cccd28d062a868 -size 3513 +oid sha256:7608f7f5846d6c78f9c0cf9d19b1eaaed0f79715de26243b8ae20f24ae063617 +size 1465 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png index 16b6b73ec7f4..90a2440d093b 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3afc4f214e51a1ccc1b0341448f64b82773bcc4bba3d913ad4f3052dcf497032 -size 4513 +oid sha256:1563003686040d979ddfe51acdf803f8a46bb268ca569a3fcd757f6e02befcbf +size 3810 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png index b0a81304d1b4..0a6d3e09769e 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d6ea478eb0b63ac6e9b19c66fabc9944a9cdd1d3131aa0d480ba645151765f41 -size 2150 +oid sha256:4ab0a216128aef68cfc0ebdb94c90f35c74df7283d46e331632c4f5dcc3cb586 +size 1900 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png index d6e87b16a116..e8d16133a54b 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:87b927816b29714d92113b2fcab01569c60372f017119b37ced9a12f72b01cd7 -size 19717 +oid sha256:b827b0e3fc8f009db74351a8535b4c3b2fa0be6274cbd192144dc39d2b40126f +size 20205 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png index ddffaebf0722..97cf4a8487f5 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7fb6696c895cb07a86897002e434be6c8c67a9d50f15615c2fd16f5038eee209 -size 21761 +oid sha256:901061fc36ba049999e52d742a952340ffed23105e4861f6354d4eb63523d42c +size 22380 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png index a25340e96b0f..39a18ee2dc1e 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3122340f40be0b24e9d7f2d262bc7285607536c9cf82151750d2691f05d8950d -size 6840 +oid sha256:993f13cd9fe6970af68e98f72a66d221c4bd1325256f3dd7a6ea602dbcffbceb +size 7097 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png index 572a1759a30c..5b972abb61c5 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f0ececab1b4b385c54352d02c9b07d6572f8a4f4069eea1afe2089343a164d6 -size 7429 +oid sha256:b2421be77a60117829b0043448599569e16f8c1f3dcf8ecab5c50125c6838a18 +size 7468 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png index 687917f13e3b..a5a024b8a9dc 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:88ca19937bbf54a87c40f476c395f678a39d443df3899eb3c74b4e3e854866fc -size 9192 +oid sha256:7448f0ffd54581488a9fd99a24a0f8304bc1c623122db10f07233094bb2264ff +size 9241 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index f79f55c553a5..1c80f668e749 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -66,15 +66,8 @@ # Parametrization: (physics_backend, renderer, data_type) # --------------------------------------------------------------------------- -# OVRTX kitless paths can segfault on CI runners; keep warp/Kit paths in CI. -_SKIP_ON_CI = any(os.environ.get(name) == "true" for name in ("CI", "GITHUB_ACTIONS", "GITLAB_CI")) -_SKIP_ON_CI_MARK = pytest.mark.skipif( - _SKIP_ON_CI, - reason="Skipped on CI runners until the test can run on CI runners.", -) - -# Let's just accept the fact that low-resolution camera outputs from RTX renderers are not deterministic enough to pass -# golden image testing on every CI run. +# Low-resolution camera outputs from RTX renderers are not deterministic enough to pass golden image testing +# on every CI run. (NVBUG#6152566) _FLAKY_MARK = pytest.mark.flaky(max_runs=3, min_passes=1) PHYSICS_RENDERER_AOV_COMBINATIONS = [ @@ -200,49 +193,49 @@ "ovrtx_renderer", "rgb", id="newton-ovrtx-rgb", - marks=_SKIP_ON_CI_MARK, + marks=_FLAKY_MARK, ), pytest.param( "newton", "ovrtx_renderer", "albedo", id="newton-ovrtx-albedo", - marks=_SKIP_ON_CI_MARK, + marks=_FLAKY_MARK, ), pytest.param( "newton", "ovrtx_renderer", "depth", id="newton-ovrtx-depth", - marks=_SKIP_ON_CI_MARK, + marks=_FLAKY_MARK, ), pytest.param( "newton", "ovrtx_renderer", "simple_shading_constant_diffuse", id="newton-ovrtx-simple_shading_constant_diffuse", - marks=_SKIP_ON_CI_MARK, + marks=_FLAKY_MARK, ), pytest.param( "newton", "ovrtx_renderer", "simple_shading_diffuse_mdl", id="newton-ovrtx-simple_shading_diffuse_mdl", - marks=_SKIP_ON_CI_MARK, + marks=_FLAKY_MARK, ), pytest.param( "newton", "ovrtx_renderer", "simple_shading_full_mdl", id="newton-ovrtx-simple_shading_full_mdl", - marks=_SKIP_ON_CI_MARK, + marks=_FLAKY_MARK, ), pytest.param( "newton", "ovrtx_renderer", "semantic_segmentation", id="newton-ovrtx-semantic_segmentation", - marks=_SKIP_ON_CI_MARK, + marks=_FLAKY_MARK, ), # newton + newton_renderer (warp) pytest.param( diff --git a/tools/test_settings.py b/tools/test_settings.py index 66832541e5cc..aece6deba348 100644 --- a/tools/test_settings.py +++ b/tools/test_settings.py @@ -62,6 +62,11 @@ "test_shadow_hand_vision_presets.py": 5000, "test_environments_newton.py": 5000, "test_surface_gripper.py": 3000, + # For some reason kitless rendering tests take much longer on CI than local machines. + # After we pin OVRTX to 0.3 we need to test whether it is still reproducible. + "test_rendering_cartpole_kitless.py": 2000, + "test_rendering_dexsuite_kuka_kitless.py": 2000, + "test_rendering_shadow_hand_kitless.py": 2000, } """A dictionary of tests and their timeouts in seconds. From 24f2cc005b350169896846609d36617e71e8ce94 Mon Sep 17 00:00:00 2001 From: Alesiani Marco Date: Fri, 8 May 2026 18:29:48 +0200 Subject: [PATCH 022/133] Fix OvPhysX 0.4 compatibility (#5545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary - Fixes OvPhysX backend compatibility with the upcoming ovphysx 0.4 API by using `active_cuda_gpus` and explicit DirectGPU Carbonite settings when supported, while preserving the older `gpu_index` constructor path. - Fixes CPU-only OvPhysX tensor binding reads into GPU-backed articulation buffers. - Uses raw Warp buffers for OvPhysX articulation write views instead of `ProxyArray` wrappers. - Adds the `ovphysx` physics preset to the cartpole camera presets task. Validation - `./isaaclab.sh -f` - `./isaaclab.sh -p -m pytest source/isaaclab_ovphysx/test/assets/test_articulation_data.py source/isaaclab_ovphysx/test/assets/test_articulation.py` - `./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Cartpole-Direct-v0 --num_envs 64 --max_iterations 2 --headless presets=ovphysx` - `./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Ant-Direct-v0 --num_envs 64 --max_iterations 2 --headless presets=ovphysx` - `./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Humanoid-Direct-v0 --num_envs 64 --max_iterations 2 --headless presets=ovphysx` - `./isaaclab.sh -p scripts/reinforcement_learning/rl_games/train.py --task=Isaac-Cartpole-Camera-Presets-Direct-v0 --num_envs=32 --max_iterations=2 --headless --enable_cameras presets=ovphysx,ovrtx_renderer,rgb` # Description This PR fixes several small IsaacLab-side issues needed for the OvPhysX backend to run the supported direct cartpole, ant, and humanoid tasks with the upcoming ovphysx 0.4 wheel. It also enables the cartpole camera presets task to select the `ovphysx` physics preset. The OvPhysX manager now detects the new constructor surface and passes explicit DirectGPU settings for GPU simulations. Older public wheels that still use `gpu_index` keep the previous constructor path. Fixes # (not applicable) ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) ## Screenshots Not 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 added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- CONTRIBUTORS.md | 1 + .../malesiani-ovphysx-04-fixes.rst | 5 +++++ .../isaaclab/isaaclab/sensors/sensor_base.py | 2 +- .../malesiani-ovphysx-04-fixes.rst | 6 +++++ .../assets/articulation/articulation.py | 6 ++--- .../assets/articulation/articulation_data.py | 22 +++++++++++++++---- .../physics/ovphysx_manager.py | 19 +++++++++++++++- .../test/assets/test_articulation_data.py | 16 ++++++++++++++ .../malesiani-ovphysx-camera-cartpole.rst | 4 ++++ .../cartpole_camera_presets_env_cfg.py | 2 ++ 10 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 source/isaaclab/changelog.d/malesiani-ovphysx-04-fixes.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/malesiani-ovphysx-04-fixes.rst create mode 100644 source/isaaclab_tasks/changelog.d/malesiani-ovphysx-camera-cartpole.rst diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index a13693c64171..2f0733585af1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -121,6 +121,7 @@ Guidelines for modifications: * Louis Le Lay * Lukas Fröhlich * Manuel Schweiger +* Marco Alesiani * Masoud Moghani * Mateo Guaman Castro * Maurice Rahme diff --git a/source/isaaclab/changelog.d/malesiani-ovphysx-04-fixes.rst b/source/isaaclab/changelog.d/malesiani-ovphysx-04-fixes.rst new file mode 100644 index 000000000000..8a3e0a90d796 --- /dev/null +++ b/source/isaaclab/changelog.d/malesiani-ovphysx-04-fixes.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed the sensor prim-deletion callback guard so the OvPhysX backend is not + treated as the Kit PhysX backend. diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index 728a708b4448..d52c902f9d73 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base.py @@ -296,7 +296,7 @@ def _invoke(callback_name, event): ) # Optional: prim deletion (only supported by PhysX backend) self._prim_deletion_handle = None - if "physx" in physics_mgr_cls.__name__.lower(): + if physics_mgr_cls.__name__ == "PhysxManager": from isaaclab_physx.physics import IsaacEvents # noqa: PLC0415 self._prim_deletion_handle = physics_mgr_cls.register_callback( diff --git a/source/isaaclab_ovphysx/changelog.d/malesiani-ovphysx-04-fixes.rst b/source/isaaclab_ovphysx/changelog.d/malesiani-ovphysx-04-fixes.rst new file mode 100644 index 000000000000..1708ee377f18 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/malesiani-ovphysx-04-fixes.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed OvPhysX articulation tensor reads and writes for ``ovphysx`` 0.4 + compatibility. +* Restored DirectGPU startup settings for OvPhysX GPU simulations. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py index f3919d56da73..bea4345ca5ba 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py @@ -1759,7 +1759,7 @@ def _initialize_impl(self) -> None: # (keyed on object identity) handles the fast path automatically. self._effort_binding = self._get_binding(TT.DOF_ACTUATION_FORCE) if self._effort_binding is not None: - torque = self._data.applied_torque + torque = self._data._applied_torque shape = self._effort_binding.shape self._effort_write_view = wp.array( ptr=torque.ptr, @@ -1780,10 +1780,10 @@ def _make_write_view(tt, buf): return b, v self._pos_target_binding, self._pos_target_write_view = _make_write_view( - TT.DOF_POSITION_TARGET, self._data.joint_pos_target + TT.DOF_POSITION_TARGET, self._data._joint_pos_target ) self._vel_target_binding, self._vel_target_write_view = _make_write_view( - TT.DOF_VELOCITY_TARGET, self._data.joint_vel_target + TT.DOF_VELOCITY_TARGET, self._data._joint_vel_target ) # Let the articulation data know that it is fully instantiated and ready to use. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py index 10c7e4b7ecd6..e5be6c05328b 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py @@ -1684,10 +1684,24 @@ def _read_binding_into_flat(self, tensor_type: int, wp_array: wp.array) -> None: Reads directly into the target array -- no scratch buffer, no extra copy. """ + self._read_binding_into_view(tensor_type, wp_array) + + def _read_binding_into_view(self, tensor_type: int, view: wp.array) -> None: + """Read an ovphysx binding into a float32 warp view.""" binding = self._get_binding(tensor_type) if binding is None: return - binding.read(wp_array) + + from isaaclab_ovphysx.tensor_types import _CPU_ONLY_TYPES + + if tensor_type in _CPU_ONLY_TYPES and str(view.device) != "cpu": + scratch = self._get_read_scratch(tensor_type) + if scratch is None: + return + binding.read(scratch) + wp.copy(view, scratch) + else: + binding.read(view) def _read_binding_into_buf(self, tensor_type: int, buf: TimestampedBuffer) -> None: """Read from an ovphysx binding into a TimestampedBuffer, skipping if fresh.""" @@ -1696,7 +1710,7 @@ def _read_binding_into_buf(self, tensor_type: int, buf: TimestampedBuffer) -> No view = self._get_read_view(tensor_type, buf.data) if view is None: return - self._get_binding(tensor_type).read(view) + self._read_binding_into_view(tensor_type, view) buf.timestamp = self._sim_timestamp def _read_transform_binding(self, tensor_type: int, buf: TimestampedBuffer) -> None: @@ -1706,7 +1720,7 @@ def _read_transform_binding(self, tensor_type: int, buf: TimestampedBuffer) -> N view = self._get_read_view(tensor_type, buf.data, 7) if view is None: return - self._get_binding(tensor_type).read(view) + self._read_binding_into_view(tensor_type, view) buf.timestamp = self._sim_timestamp def _read_spatial_vector_binding(self, tensor_type: int, buf: TimestampedBuffer) -> None: @@ -1716,7 +1730,7 @@ def _read_spatial_vector_binding(self, tensor_type: int, buf: TimestampedBuffer) view = self._get_read_view(tensor_type, buf.data, 6) if view is None: return - self._get_binding(tensor_type).read(view) + self._read_binding_into_view(tensor_type, view) buf.timestamp = self._sim_timestamp """ diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 9063078e45b3..6caad37ab7bf 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -13,6 +13,7 @@ from __future__ import annotations import atexit +import inspect import logging import os import tempfile @@ -202,7 +203,23 @@ def _warmup_and_load(cls) -> None: import ovphysx - cls._physx = ovphysx.PhysX(device=ovphysx_device, gpu_index=gpu_index) + physx_kwargs = {"device": ovphysx_device} + physx_signature = inspect.signature(ovphysx.PhysX) + physx_parameters = physx_signature.parameters + if "active_cuda_gpus" in physx_parameters: + if ovphysx_device == "gpu": + # ovphysx 0.4 accepts a comma-separated CUDA ordinal string; IsaacLab selects one GPU. + physx_kwargs["active_cuda_gpus"] = str(gpu_index) + physx_kwargs["config"] = ovphysx.PhysXConfig( + carbonite_overrides={ + "/physics/suppressReadback": True, + "/physics/suppressFabricUpdate": True, + } + ) + elif "gpu_index" in physx_parameters: + physx_kwargs["gpu_index"] = gpu_index + + cls._physx = ovphysx.PhysX(**physx_kwargs) # Without worker threads the stepper runs simulate()+fetchResults() # synchronously, blocking the calling thread for the full GPU step time. diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation_data.py b/source/isaaclab_ovphysx/test/assets/test_articulation_data.py index 16bb99a4d6c4..390e5defa0f2 100644 --- a/source/isaaclab_ovphysx/test/assets/test_articulation_data.py +++ b/source/isaaclab_ovphysx/test/assets/test_articulation_data.py @@ -40,3 +40,19 @@ def test_joint_acc_uses_inverse_dt(self): atol=1e-6, err_msg="Joint acceleration should be computed as delta_velocity / dt.", ) + + def test_cpu_only_binding_read_stages_to_gpu_view(self): + """CPU-only bindings should be staged before copying into GPU-backed data buffers.""" + if not wp.is_cuda_available(): + pytest.skip("CUDA is required to test CPU-to-GPU staging.") + + mock_bindings = MockOvPhysxBindingSet(num_instances=1, num_joints=2, num_bodies=1) + data = ArticulationData(mock_bindings.bindings, device="cuda") + data._create_buffers() + + expected = np.array([[[1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0]]], dtype=np.float32) + mock_bindings.bindings[TT.BODY_COM_POSE]._data[...] = expected + + data._read_transform_binding(TT.BODY_COM_POSE, data._body_com_pose_b) + + np.testing.assert_allclose(data._body_com_pose_b.data.numpy(), expected, atol=1e-6) diff --git a/source/isaaclab_tasks/changelog.d/malesiani-ovphysx-camera-cartpole.rst b/source/isaaclab_tasks/changelog.d/malesiani-ovphysx-camera-cartpole.rst new file mode 100644 index 000000000000..5a352196a0ed --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/malesiani-ovphysx-camera-cartpole.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added the ``ovphysx`` physics preset to the cartpole camera presets task. diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py index 4c27674ff7c6..00d11f233ff6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py @@ -6,6 +6,7 @@ from __future__ import annotations from isaaclab_newton.physics import NewtonCfg +from isaaclab_ovphysx.physics import OvPhysxCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.sim as sim_utils @@ -27,6 +28,7 @@ class PhysicsCfg(PresetCfg): default = PhysxCfg() physx = PhysxCfg() newton_mjwarp = NewtonCfg() + ovphysx = OvPhysxCfg() @configclass From 65e5ead4847c28b9c48504023465432e19277f9b Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 8 May 2026 20:18:02 +0200 Subject: [PATCH 023/133] Add Kamino solver tutorial (#5483) # Description Adds a dedicated Newton experimental tutorial for using the Kamino solver. The page explains that Kamino is selected through a Newton physics solver preset, shows the task changes needed to add a `kamino` preset, lists compatibility checks for assets, resets, sensors, and renderers, and documents the Kamino-specific solver parameters by category. This addresses Kellys follow-up request on #5457 for a tutorial describing what needs to change to work with Kamino and for descriptions of Kamino-specific solver parameters. Fixes # (issue) ## Type of change - Documentation update ## Screenshots Not applicable. Documentation-only RST 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` - [x] 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 extensions `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there Notes: - Ran `./isaaclab.sh -f` successfully. - Verified the `literalinclude` target and referenced labels locally. - `./isaaclab.sh -d` could not start in this checkout because there is no virtual environment and system Python is PEP 668 protected, so pip refused to install docs requirements. Because Sphinx did not run, the warning checklist item is intentionally left unchecked. - No tests or changelog fragment were added because this is a documentation-only follow-up under `docs/source`; the current repository guidance uses `source//changelog.d` fragments only for touched source packages. --- .../newton-physics-integration/index.rst | 1 + .../solver-transitioning.rst | 2 +- .../using-kamino.rst | 239 ++++++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 docs/source/experimental-features/newton-physics-integration/using-kamino.rst diff --git a/docs/source/experimental-features/newton-physics-integration/index.rst b/docs/source/experimental-features/newton-physics-integration/index.rst index f40de231834a..afe783cc8716 100644 --- a/docs/source/experimental-features/newton-physics-integration/index.rst +++ b/docs/source/experimental-features/newton-physics-integration/index.rst @@ -40,3 +40,4 @@ For an overview of how the multi-backend architecture works, including how to ad installation limitations-and-known-bugs solver-transitioning + using-kamino diff --git a/docs/source/experimental-features/newton-physics-integration/solver-transitioning.rst b/docs/source/experimental-features/newton-physics-integration/solver-transitioning.rst index 0c480bfec73d..5d78f44de503 100644 --- a/docs/source/experimental-features/newton-physics-integration/solver-transitioning.rst +++ b/docs/source/experimental-features/newton-physics-integration/solver-transitioning.rst @@ -5,7 +5,7 @@ Transitioning to the Newton physics engine introduces new physics solvers that h While Newton supports several different solvers, our initial focus for Isaac Lab is on using the MuJoCo-Warp solver from Google DeepMind. Isaac Lab also includes beta support for the Kamino solver on selected classic tasks. Kamino is selected through a physics preset rather than as a -separate backend; see :ref:`hydra-backend-solver-presets`. +separate backend; see :ref:`hydra-backend-solver-presets` and :ref:`newton-using-kamino`. .. note:: diff --git a/docs/source/experimental-features/newton-physics-integration/using-kamino.rst b/docs/source/experimental-features/newton-physics-integration/using-kamino.rst new file mode 100644 index 000000000000..7c8b6f2d564c --- /dev/null +++ b/docs/source/experimental-features/newton-physics-integration/using-kamino.rst @@ -0,0 +1,239 @@ +.. _newton-using-kamino: + +Using the Kamino Solver +======================= + +Kamino is a Newton solver, not a separate Isaac Lab physics backend. In Isaac Lab, +Kamino is enabled by selecting a :class:`~isaaclab_newton.physics.NewtonCfg` whose +``solver_cfg`` is :class:`~isaaclab_newton.physics.KaminoSolverCfg`. +This is usually exposed as a ``newton_kamino`` physics preset on the task configuration. + +Kamino support is currently beta. A task that works with PhysX or with Newton's +MuJoCo-Warp solver may still need task-specific asset, collision, reset, and solver +tuning before it works well with Kamino. + + +Start from a Supported Newton Task +---------------------------------- + +Before adding Kamino, first make sure the task runs with the Newton backend: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/environments/zero_agent.py --task Isaac-Cartpole-Direct-v0 --num_envs 128 --viz newton presets=newton_mjwarp + +Then run the same task with the Kamino preset if it is available: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/environments/zero_agent.py --task Isaac-Cartpole-Direct-v0 --num_envs 128 --viz newton presets=newton_kamino + +At the time of writing, the ``newton_kamino`` preset is defined for +``Isaac-Cartpole-Direct-v0``, ``Isaac-Ant-Direct-v0``, ``Isaac-Cartpole-v0``, +and ``Isaac-Ant-v0``. Passing ``presets=newton_kamino`` to another task does not +automatically enable Kamino; the task must define and validate its own ``newton_kamino`` +preset. + + +Add a Kamino Physics Preset +--------------------------- + +Tasks that support multiple physics options usually store ``SimulationCfg.physics`` +as a :class:`~isaaclab_tasks.utils.hydra.PresetCfg`. First import the Newton +solver config types used by the presets: + +.. code-block:: python + + from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg + +Then add a ``newton_kamino`` entry beside the existing ``default``, ``physx``, and +``newton_mjwarp`` entries: + +.. literalinclude:: ../../../../source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env_cfg.py + :language: python + :start-at: class CartpolePhysicsCfg + :end-at: ovphysx: OvPhysxCfg = OvPhysxCfg() + :emphasize-lines: 16-38 + +The important pieces are: + +* Add a ``newton_kamino`` preset whose value is :class:`~isaaclab_newton.physics.NewtonCfg`. +* Set ``solver_cfg=KaminoSolverCfg(...)`` inside that Newton config. +* Keep the preset at the same config path used by the task's + :class:`~isaaclab.sim.SimulationCfg`, for example ``env.sim.physics``. + +You can select the preset globally: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task=Isaac-Cartpole-v0 presets=newton_kamino + +or select the physics field directly: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task=Isaac-Cartpole-v0 env.sim.physics=newton_kamino + +Use the direct path override when only one task field should use the Kamino preset. +Use ``presets=newton_kamino`` when you want every matching preset field in the task config +to resolve to ``newton_kamino``. +Isaac Lab training scripts accept these Hydra overrides after the regular command +line flags; no separator is needed for the examples above. + + +Check Task and Asset Compatibility +---------------------------------- + +Kamino uses the Newton model built from the task assets. When adding Kamino to a +new task, validate the following before tuning solver parameters: + +* The task must already be compatible with the Newton backend. If ``presets=newton_mjwarp`` + fails during model construction, fix the asset or task configuration first. +* The assets should use Newton-supported rigid bodies, articulations, and collision + geometry. PhysX-only features, unsupported schemas, or missing collision shapes + can prevent Newton model creation or produce unusable contacts. +* Reset logic should write consistent root and joint state through Isaac Lab asset + APIs. Kamino uses a forward-kinematics reset path after state writes so maximal + coordinate body poses match the reduced joint state. +* Sensor, renderer, and visualizer presets remain separate from the solver preset. + Kamino can share the Newton-compatible sensors and renderers used by the task, + but each sensor and renderer combination still needs its own validation. +* Contact-heavy tasks usually need their own collision mode, substep count, and + P-ADMM iteration/tolerance settings. Start from the validated Cartpole or Ant + preset that most closely resembles the task. + +For a small articulated system with simple contacts, the Cartpole preset uses +Kamino's internal collision detector. For Ant, the preset uses Newton's collision +pipeline and two substeps. These choices are task-specific; treat them as starting +points rather than universal defaults. + + +Kamino Solver Parameters +------------------------ + +The following fields are specific to :class:`~isaaclab_newton.physics.KaminoSolverCfg`. +They are grouped by the part of the solver they affect. + +Core Integration +^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``integrator`` + - Default: ``"euler"``. Time integration scheme. ``"moreau"`` is used by the validated Kamino task presets. + * - ``use_fk_solver`` + - Default: ``True``. Enables Kamino's forward-kinematics solver for resets. Keep this enabled for Isaac Lab tasks unless you have a task-specific reset path. + * - ``rotation_correction`` + - Default: ``"twopi"``. Rotation correction mode for maximal-coordinate bodies. Valid values are ``"twopi"``, ``"continuous"``, and ``"none"``. + * - ``angular_velocity_damping`` + - Default: ``0.0``. Damps angular velocity. Higher values can suppress spin but also remove physical energy from the system. + + +Collision Handling +^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``use_collision_detector`` + - Default: ``False``. Selects Kamino's internal collision detector when ``True``. When ``False``, Isaac Lab uses Newton's collision pipeline for contact generation. + * - ``collision_detector_pipeline`` + - Default: ``None``. Internal Kamino collision detector pipeline. Common values are ``"primitive"`` and ``"unified"``. Only used when ``use_collision_detector=True``. + * - ``collision_detector_max_contacts_per_pair`` + - Default: ``None``. Maximum contacts generated per candidate geometry pair by the internal Kamino collision detector. + * - ``constraints_delta`` + - Default: ``1.0e-6``. Contact penetration margin [m] used by Kamino constraint stabilization. + + +Constraint Stabilization +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``constraints_alpha`` + - Default: ``0.01``. Baumgarte stabilization for bilateral joint constraints. Increasing it can reduce joint constraint drift but may make the solve stiffer. + * - ``constraints_beta`` + - Default: ``0.01``. Baumgarte stabilization for unilateral joint-limit constraints. + * - ``constraints_gamma`` + - Default: ``0.01``. Baumgarte stabilization for unilateral contact constraints. + + +P-ADMM Solver Controls +^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``padmm_max_iterations`` + - Default: ``200``. Maximum number of P-ADMM iterations per solver step. Higher values can improve convergence and increase runtime. + * - ``padmm_primal_tolerance`` + - Default: ``1e-6``. Primal residual convergence tolerance. + * - ``padmm_dual_tolerance`` + - Default: ``1e-6``. Dual residual convergence tolerance. + * - ``padmm_compl_tolerance`` + - Default: ``1e-6``. Complementarity residual convergence tolerance for contacts and unilateral constraints. + * - ``padmm_rho_0`` + - Default: ``1.0``. Initial P-ADMM penalty parameter. This influences how strongly constraint residuals are penalized early in the solve. + * - ``padmm_eta`` + - Default: ``1e-5``. Proximal regularization parameter. It must be greater than zero. + * - ``padmm_use_acceleration`` + - Default: ``True``. Enables acceleration in the P-ADMM iterations. This usually improves convergence but should be validated per task. + * - ``padmm_warmstart_mode`` + - Default: ``"containers"``. Warm-start source for P-ADMM. Valid values are ``"none"``, ``"internal"``, and ``"containers"``. + * - ``padmm_contact_warmstart_method`` + - Default: ``"key_and_position"``. Contact warm-start matching method. The validated presets use ``"geom_pair_net_force"``. + * - ``padmm_use_graph_conditionals`` + - Default: ``True``. Uses CUDA graph conditional nodes for the iterative solver when ``True``. Setting it to ``False`` unrolls to fixed loops over the maximum iteration count. + + +Sparsity, Dynamics, and Debugging +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``sparse_jacobian`` + - Default: ``False``. Uses sparse Jacobian computation. This is enabled in the validated Kamino task presets. + * - ``sparse_dynamics`` + - Default: ``False``. Uses sparse dynamics computation. + * - ``dynamics_preconditioning`` + - Default: ``True``. Enables preconditioning for constrained dynamics. Preconditioning can improve P-ADMM convergence. + * - ``collect_solver_info`` + - Default: ``False``. Collects solver convergence and performance information. Enable only for debugging because it significantly increases runtime. + * - ``compute_solution_metrics`` + - Default: ``False``. Computes solution metrics at each step. Enable only for debugging because it significantly increases runtime. + + +Tuning Workflow +--------------- + +Use the following sequence when bringing up a new Kamino task: + +1. Run the task with ``presets=newton_mjwarp`` and fix Newton model construction or task + compatibility issues first. +2. Add a ``newton_kamino`` preset with conservative values copied from the closest + validated task. +3. Run a small smoke test with a low environment count and a visualizer. +4. Increase ``num_envs`` and profile only after the task is stable. +5. Tune ``num_substeps``, ``padmm_max_iterations``, and the P-ADMM tolerances + together. Raising iteration count without checking tolerances can hide a + poorly scaled constraint setup. +6. Enable ``collect_solver_info`` or ``compute_solution_metrics`` only while + debugging convergence. Disable them for training and benchmarks. From 23ababb1b63c55469c4c659d25365323df8988b3 Mon Sep 17 00:00:00 2001 From: hujc Date: Fri, 8 May 2026 12:09:47 -0700 Subject: [PATCH 024/133] [Visualizers] Fix viewergl fully-black: assign PyVec3 to Newton camera.pos (#5547) --- .../jichuanh-fix-newton-cam-pos-type.rst | 15 +++++++++++++++ .../newton/newton_visualizer.py | 4 +++- .../test/test_visualizer_cartpole_integration.py | 16 ---------------- 3 files changed, 18 insertions(+), 17 deletions(-) create mode 100644 source/isaaclab_visualizers/changelog.d/jichuanh-fix-newton-cam-pos-type.rst diff --git a/source/isaaclab_visualizers/changelog.d/jichuanh-fix-newton-cam-pos-type.rst b/source/isaaclab_visualizers/changelog.d/jichuanh-fix-newton-cam-pos-type.rst new file mode 100644 index 000000000000..6c6525acadf5 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/jichuanh-fix-newton-cam-pos-type.rst @@ -0,0 +1,15 @@ +Fixed +^^^^^ + +* Fixed ``test_visualizer_cartpole_integration::test_cartpole_newton_visualizer_viewergl_rgb_motion`` + returning a fully-black ``ViewerGL.get_frame`` buffer on the Newton 1.2.0rc2 + + warp 1.13 cohort. ``NewtonVisualizer._apply_camera_pose`` was assigning + ``self._viewer.camera.pos = wp.vec3(*cam_pos)``, but Newton's + ``Camera.translate()`` adds a ``pyglet.math.Vec3`` delta with ``+=``. + warp 1.13's strict ``__add__`` rejects ``wp.vec3 + pyglet.math.Vec3`` + with ``TypeError``; the exception was silenced by the visualizer's + ``try/except``, which prevented ``renderer.render()`` from ever running + -- so the framebuffer stayed empty and read back as all zeros. The fix + assigns ``pyglet.math.Vec3`` instead, matching what Newton uses internally. +* Re-enabled ``test_cartpole_newton_visualizer_viewergl_rgb_motion`` after the + workaround skip in https://github.com/isaac-sim/IsaacLab/pull/5538. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index b548a3e5f4f3..aeafc29bd264 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -13,6 +13,7 @@ import numpy as np import warp as wp from newton.viewer import ViewerGL +from pyglet.math import Vec3 as PygletVec3 from isaaclab.visualizers.base_visualizer import BaseVisualizer @@ -463,7 +464,8 @@ def _apply_camera_pose(self, pose: tuple[tuple[float, float, float], tuple[float if self._viewer is None: return cam_pos, cam_target = pose - self._viewer.camera.pos = wp.vec3(*cam_pos) + # Match Newton's Camera native pos type: PyVec3, not wp.vec3. + self._viewer.camera.pos = PygletVec3(*cam_pos) cam_pos_np = np.array(cam_pos, dtype=np.float32) cam_target_np = np.array(cam_target, dtype=np.float32) direction = cam_target_np - cam_pos_np diff --git a/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py index 60f9921415cc..42d1368dcebf 100644 --- a/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py +++ b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py @@ -522,22 +522,6 @@ def test_cartpole_newton_visualizer_tiled_camera_rgb_non_black( @pytest.mark.isaacsim_ci -@pytest.mark.skip( - reason=( - "ViewerGL.get_frame returns a fully-black 600x600x3 buffer in CI on the current " - "Isaac Sim image + Newton 1.2.0rc2 + warp-lang 1.13 cohort. Failure is " - "deterministic across two consecutive reruns of the same SHA and reproduces on " - "every PR that touches the rendering / camera / sensor / USD stack (5 PRs hit it " - "in the last 100 build.yaml runs); zero failures on PRs outside that scope. " - "Investigation ruled out: rc1->rc2 viewer code diff (7-line image_logger.clear " - "only), wp.RegisteredGLBuffer API (byte-identical 1.12 vs 1.13), pure flakiness " - "(deterministic), and the bump cohort alone (warp-1.12 branches both pass and " - "fail). Strongest remaining hypothesis: a CUDA-OpenGL interop init-order " - "fragility in the PBO + glReadPixels + RegisteredGLBuffer.map path that gets " - "tipped by any source change perturbing GL/CUDA bring-up. Re-enable once root " - "cause is identified." - ) -) @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.""" From 1336acd89688d9afaca3e7c0abe88ef2b8f6941d Mon Sep 17 00:00:00 2001 From: Welf Rehberg <65718465+Zwoelf12@users.noreply.github.com> Date: Fri, 8 May 2026 21:20:47 +0200 Subject: [PATCH 025/133] Adds multirotor vision-based navigation task and acceleration, velocity and position controllers (#3895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR extends [3760](https://github.com/isaac-sim/IsaacLab/pull/3760) by introducing a navigation tasks for the ARL robot. The PR adds a confined cluttered environment, adds acceleration, velocity and position controllers + configs, extends the MDP and RL configs and adds a Variational Auto Encoder to generate image latents for observations. The PR depends on the `MultiMeshRayCasterCamera` introduced in PR [3298](https://github.com/isaac-sim/IsaacLab/pull/3298) (currently not merged in IsaacLab). ## Changes ### Type of Change - New feature (non-breaking change which adds functionality) - Documentation update (added docs/comments where applicable) ### Files changed (high-level summary) - New files added: - source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/* (new task code and config, obstacle scene code and config) - source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/vae_model.pt - source/isaaclab/isaaclab/controllers/lee_acceleration_control_cfg.py - source/isaaclab/isaaclab/controllers/lee_acceleration_control.py - source/isaaclab/isaaclab/controllers/lee_velocity_control_cfg.py - source/isaaclab/isaaclab/controllers/lee_velocity_control.py - source/isaaclab/isaaclab/controllers/lee_position_control_cfg.py - ource/isaaclab/isaaclab/controllers/lee_position_control.py - Modified: - source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/* (added navigation specifics) - source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py (added new action config) - source/isaaclab/isaaclab/envs/mdp/actions/thrust_actions.py (introduced new navigation action handling controller application) - Total diff (branch vs main, includes also unmerged changes of PR [3760](https://github.com/isaac-sim/IsaacLab/pull/3760) and PR [3298](https://github.com/isaac-sim/IsaacLab/pull/3298)): 74 files changed, 8029 insertions, 88 deletions ## Dependencies - The new drone task references standard repo-internal packages and Isaac Sim; no external pip packages required beyond the repo standard. ## 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 - [ ] 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 --------- Signed-off-by: Grzegorz Malczyk <44407007+grzemal@users.noreply.github.com> Signed-off-by: renezurbruegg Signed-off-by: Welf Rehberg <65718465+Zwoelf12@users.noreply.github.com> Co-authored-by: grzemal Co-authored-by: Grzegorz Malczyk <44407007+grzemal@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: René zurbrügg Co-authored-by: Pascal Roth Co-authored-by: Pascal Roth <57946385+pascal-roth@users.noreply.github.com> --- .../drone_arl/arl_robot_1_navigation.jpg | Bin 0 -> 97329 bytes docs/source/api/index.rst | 1 + .../isaaclab_contrib.controllers.rst | 88 +++++ docs/source/overview/environments.rst | 26 +- scripts/demos/arl_robot_1.py | 114 ++++++ .../isaaclab_assets/robots/arl_robot_1.py | 2 +- source/isaaclab_contrib/docs/README.md | 8 +- .../isaaclab_contrib/actuators/thruster.py | 6 +- .../isaaclab_contrib/controllers/__init__.py | 16 + .../isaaclab_contrib/controllers/__init__.pyi | 32 ++ .../controllers/lee_acceleration_control.py | 102 ++++++ .../lee_acceleration_control_cfg.py | 23 ++ .../controllers/lee_attitude_control.py | 98 +++++ .../controllers/lee_attitude_control_cfg.py | 23 ++ .../controllers/lee_controller_base.py | 120 ++++++ .../controllers/lee_controller_base_cfg.py | 73 ++++ .../controllers/lee_controller_utils.py | 127 +++++++ .../controllers/lee_position_control.py | 134 +++++++ .../controllers/lee_position_control_cfg.py | 44 +++ .../controllers/lee_velocity_control.py | 133 +++++++ .../controllers/lee_velocity_control_cfg.py | 34 ++ .../isaaclab_contrib/mdp/__init__.pyi | 4 +- .../isaaclab_contrib/mdp/actions/__init__.pyi | 6 +- .../mdp/actions/thrust_actions.py | 168 +++++++++ .../mdp/actions/thrust_actions_cfg.py | 41 ++- .../isaaclab_contrib/utils/math.py | 93 +++++ .../test_drone_geometric_controllers.py | 345 ++++++++++++++++++ .../manager_based/drone_arl/mdp/__init__.pyi | 14 - .../mdp/commands/drone_pose_command.py | 4 +- .../drone_arl/mdp/curriculums.py | 148 ++++++++ .../manager_based/drone_arl/mdp/events.py | 189 ++++++++++ .../drone_arl/mdp/observations.py | 181 ++++++++- .../manager_based/drone_arl/mdp/rewards.py | 112 +++++- .../drone_arl/navigation/__init__.py | 6 + .../drone_arl/navigation/config/__init__.py | 6 + .../navigation/config/arl_robot_1/__init__.py | 36 ++ .../config/arl_robot_1/agents/__init__.py | 4 + .../agents/rl_games_rough_ppo_cfg.yaml | 87 +++++ .../arl_robot_1/agents/rsl_rl_ppo_cfg.py | 37 ++ .../agents/skrl_rough_ppo_cfg.yaml | 95 +++++ .../arl_robot_1/floating_obstacles_env_cfg.py | 41 +++ .../config/arl_robot_1/navigation_env_cfg.py | 344 +++++++++++++++++ .../scenes/obstacle_scenes/obstacle_scene.py | 114 ++++++ .../obstacle_scenes/obstacle_scene_cfg.py | 88 +++++ .../track_position_state_based_env_cfg.py | 23 +- 45 files changed, 3345 insertions(+), 45 deletions(-) create mode 100644 docs/source/_static/tasks/drone_arl/arl_robot_1_navigation.jpg create mode 100644 docs/source/api/lab_contrib/isaaclab_contrib.controllers.rst create mode 100644 scripts/demos/arl_robot_1.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/__init__.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/__init__.pyi create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control_cfg.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control_cfg.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base_cfg.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_utils.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control_cfg.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control_cfg.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/utils/math.py create mode 100644 source/isaaclab_contrib/test/controllers/test_drone_geometric_controllers.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/curriculums.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/events.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rl_games_rough_ppo_cfg.yaml create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/skrl_rough_ppo_cfg.yaml create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/floating_obstacles_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/navigation_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene_cfg.py diff --git a/docs/source/_static/tasks/drone_arl/arl_robot_1_navigation.jpg b/docs/source/_static/tasks/drone_arl/arl_robot_1_navigation.jpg new file mode 100644 index 0000000000000000000000000000000000000000..108e9e4758678f0ed2a756d9ae55961b2731904a GIT binary patch literal 97329 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𝔑=~(e#?)4h(GQ6DV@gZ!)|pF1wyG;6^L_IAmA=(%6cdZZpp1LO{SnCiBfsq z7aVNZ>VD4ToWrHvVRvPL=d!Wdon~-paaKD+DuDzjhZGRVZ#@GUA)0Wm-}G!BaBUf7KKa%24N* zLMHiAr;#dDsmOH-*xXiH!~Xz6kH~_7nYu|Zn@>q427z_ zw*_((K|!SUOr`*naZh@g={8a>k>vnR!c#2uo?(>SKZQ$z+uy4{nN)t9bD-CJ6=&d< zYFmWMC6b<=n!v_qhx(^unXAyVKLx5J;HJ(Vs2Nw8}wJTAvhJ*l*)FbHVT0@JWiGCNd6Bak7G z^T?c$l2qL{!i5Sil`2;pvw>@C`#E3@BwS1F?3+fT{wyU@ z;Wnzf(Q{QVrs_Hz`ihqTv$j1$AyVPCIzNX`{{XdylXUdNsCVTcFL&nF_)wujjD<>+ zbxs0rgL8JsR9sXjzBv!yI7acy;*7;YP-xasXjPjJM>$^OvTJHPK}@Dt?G*?DnN)Zc zrmu1pAEv1=%}T_oJRZkLdsrX$OtaFb@t^`~H=Xhf$2*&O_}n>0^PTy^H;z%+TmJyY v{u8$-Zml0aK~y(ZMz`DT3X@v{+7nSh4Uk6jzCoDhI~&i&;D;pMbASKYajfr= literal 0 HcmV?d00001 diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index cea73de6802d..f296c74310c6 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -82,6 +82,7 @@ The following modules are available in the ``isaaclab_contrib`` extension: actuators assets + controllers mdp rl sensors diff --git a/docs/source/api/lab_contrib/isaaclab_contrib.controllers.rst b/docs/source/api/lab_contrib/isaaclab_contrib.controllers.rst new file mode 100644 index 000000000000..c76804d4b463 --- /dev/null +++ b/docs/source/api/lab_contrib/isaaclab_contrib.controllers.rst @@ -0,0 +1,88 @@ +isaaclab_contrib.controllers +============================ + +.. automodule:: isaaclab_contrib.controllers + + .. rubric:: Classes + + .. autosummary:: + + LeeControllerBase + LeeControllerBaseCfg + LeeAccController + LeeAccControllerCfg + LeeAttController + LeeAttControllerCfg + LeePosController + LeePosControllerCfg + LeeVelController + LeeVelControllerCfg + +Lee Base Controller +-------------------- + +.. autoclass:: LeeControllerBase + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: LeeControllerBaseCfg + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__, class_type + +Lee Acceleration Controller +---------------------------- + +.. autoclass:: LeeAccController + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: LeeAccControllerCfg + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__, class_type + +Lee Attitude Controller +------------------------- +.. autoclass:: LeeAttController + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: LeeAttControllerCfg + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__, class_type + +Lee Position Controller +----------------------- + +.. autoclass:: LeePosController + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: LeePosControllerCfg + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__, class_type + +Lee Velocity Controller +----------------------- + +.. autoclass:: LeeVelController + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: LeeVelControllerCfg + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__, class_type diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index c1bce52597e4..6a13f8c087d7 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -531,14 +531,20 @@ Multirotor .. |arl_robot_track_position_state_based| image:: ../_static/tasks/drone_arl/arl_robot_1_track_position_state_based.jpg +.. |arl_robot_navigation-link| replace:: `Isaac-Navigation-3DObstacles-ARL-Robot-1-v0 `__ + +.. |arl_robot_navigation| image:: ../_static/tasks/drone_arl/arl_robot_1_navigation.jpg + .. table:: :widths: 25 30 25 20 - +----------------------------------------+---------------------------------------------+----------------------------------------------------------------------------------------+------------------------------+ - | 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. | | - +----------------------------------------+---------------------------------------------+----------------------------------------------------------------------------------------+------------------------------+ + +----------------------------------------+---------------------------------------------+----------------------------------------------------------------------------------------+-----------------------+ + | 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. | | + +----------------------------------------+---------------------------------------------+----------------------------------------------------------------------------------------+-----------------------+ + | |arl_robot_navigation| | |arl_robot_navigation-link| | Navigate through 3D obstacles with the ARL robot using depth camera sensing. | | + +----------------------------------------+---------------------------------------------+----------------------------------------------------------------------------------------+-----------------------+ Others @@ -1013,6 +1019,16 @@ inferencing, including reading from an already trained checkpoint and disabling - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) - ``newton_mjwarp``, ``physx`` + * - Isaac-TrackPositionNoObstacles-ARL-Robot-1-v0 + - Isaac-TrackPositionNoObstacles-ARL-Robot-1-Play-v0 + - Manager Based + - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - ``physx`` + * - Isaac-Navigation-3DObstacles-ARL-Robot-1-v0 + - Isaac-Navigation-3DObstacles-ARL-Robot-1-Play-v0 + - Manager Based + - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - ``physx`` * - Isaac-Open-Drawer-Franka-IK-Abs-v0 - - Manager Based diff --git a/scripts/demos/arl_robot_1.py b/scripts/demos/arl_robot_1.py new file mode 100644 index 000000000000..987e6be6b2ec --- /dev/null +++ b/scripts/demos/arl_robot_1.py @@ -0,0 +1,114 @@ +# 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 + +""" +Script to view ARL Robot 1. + +Launch Isaac Sim Simulator first. +""" + +# Create argparser +import argparse + +from isaaclab.app import AppLauncher + +parser = argparse.ArgumentParser(description="View ARL Robot 1 with Lee Position Controller.") +# append AppLauncher cli args +AppLauncher.add_app_launcher_args(parser) +args_cli = parser.parse_args() + +# launch omniverse app +app_launcher = AppLauncher(args_cli) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import torch + +import omni.usd +from pxr import Gf, UsdLux + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationContext + +from isaaclab_contrib.assets import Multirotor +from isaaclab_contrib.controllers.lee_position_control import LeePosController +from isaaclab_contrib.controllers.lee_position_control_cfg import LeePosControllerCfg + +from isaaclab_assets.robots.arl_robot_1 import ARL_ROBOT_1_CFG + + +def main(): + """Main function to spawn arl_robot_1.""" + + # Create simulation context + sim_cfg = sim_utils.SimulationCfg(dt=0.01) + sim = SimulationContext(sim_cfg) + + # Create a dome light with light blue color + stage = omni.usd.get_context().get_stage() + dome_light = UsdLux.DomeLight.Define(stage, "/World/DomeLight") + dome_light.CreateColorAttr(Gf.Vec3f(0.53, 0.81, 0.92)) # Light blue + dome_light.CreateIntensityAttr(1000.0) + + # Spawn ground plane + cfg = sim_utils.GroundPlaneCfg() + cfg.func("/World/defaultGroundPlane", cfg) + + # Spawn robot + robot_cfg = ARL_ROBOT_1_CFG.replace(prim_path="/World/Robot") + robot_cfg.actuators["thrusters"].dt = sim_cfg.dt + robot = Multirotor(robot_cfg) + + # Play the simulator + sim.reset() + + # Create Lee position controller + controller_cfg = LeePosControllerCfg( + K_pos_range=((2.5, 2.5, 1.5), (3.5, 3.5, 2.0)), + K_vel_range=((2.5, 2.5, 1.5), (3.5, 3.5, 2.0)), + K_rot_range=((1.6, 1.6, 0.25), (1.85, 1.85, 0.4)), + K_angvel_range=((0.4, 0.4, 0.075), (0.5, 0.5, 0.09)), + max_inclination_angle_rad=1.0471975511965976, + max_yaw_rate=1.0471975511965976, + ) + controller = LeePosController(controller_cfg, robot, num_envs=1, device=str(sim.device)) + + # Get allocation matrix and compute pseudoinverse + allocation_matrix = torch.tensor(robot_cfg.allocation_matrix, device=sim.device, dtype=torch.float32) + # allocation_matrix is (6, num_thrusters), we need pseudoinverse for wrench -> thrust + alloc_pinv = torch.linalg.pinv(allocation_matrix) # Shape: (num_thrusters, 6) + + # Position command: hover in place (zero position, zero yaw) + pos_command = torch.zeros((1, 4), device=sim.device) # [x, y, z, yaw] + pos_command[0, 2] = 1.0 # Hover at 1 meter height + + # Simulation loop + print("[INFO] Starting demo with Lee Position Controller. Press Ctrl+C to stop.") + + while simulation_app.is_running(): + # Compute wrench from velocity controller + wrench = controller.compute(pos_command) # Shape: (1, 6) + + # Allocate wrench to thrusters: thrust = pinv(A) @ wrench + thrust_cmd = torch.matmul(wrench, alloc_pinv.T) # Shape: (1, num_thrusters) + thrust_cmd = thrust_cmd.clamp(min=0.0) # Ensure non-negative thrust + + # Apply thrust + robot.set_thrust_target(thrust_cmd) + + # Step simulation + robot.write_data_to_sim() + sim.step() + + # Update robot + robot.update(sim_cfg.dt) + + # Cleanup + simulation_app.close() + + +if __name__ == "__main__": + main() diff --git a/source/isaaclab_assets/isaaclab_assets/robots/arl_robot_1.py b/source/isaaclab_assets/isaaclab_assets/robots/arl_robot_1.py index 32e01adc93b4..4f81f8a0645c 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/arl_robot_1.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/arl_robot_1.py @@ -7,7 +7,7 @@ The following configuration parameters are available: -* :obj:`ARL_ROBOT_1_CFG`: The ARL_Robot_1 with (TODO add motor propeller combination) +* :obj:`ARL_ROBOT_1_CFG`: The ARL_Robot_1 """ import isaaclab.sim as sim_utils diff --git a/source/isaaclab_contrib/docs/README.md b/source/isaaclab_contrib/docs/README.md index 346b47e5522c..ea09129849f5 100644 --- a/source/isaaclab_contrib/docs/README.md +++ b/source/isaaclab_contrib/docs/README.md @@ -208,11 +208,11 @@ The `ThrustAction` term provides flexible preprocessing to support all modes thr ### Demo Script -A complete demonstration of quadcopter simulation is available: +A complete demonstration of multirotor simulation is available: ```bash -# Run quadcopter demo -./isaaclab.sh -p scripts/demos/quadcopter.py +# Run multirotor demo +./isaaclab.sh -p scripts/demos/arl_robot_1.py ``` ## TacSL Tactile Sensor (Detailed) @@ -478,7 +478,7 @@ The extension includes comprehensive unit tests for all contributed components: # Test multirotor components python -m pytest source/isaaclab_contrib/test/assets/test_multirotor.py python -m pytest source/isaaclab_contrib/test/actuators/test_thruster.py - +python -m pytest source/isaaclab_contrib/test/assets/test_drone_geometric_controllers.py # Run all contrib tests python -m pytest source/isaaclab_contrib/test/ ``` diff --git a/source/isaaclab_contrib/isaaclab_contrib/actuators/thruster.py b/source/isaaclab_contrib/isaaclab_contrib/actuators/thruster.py index 036a817fbfbd..da9053107b78 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/actuators/thruster.py +++ b/source/isaaclab_contrib/isaaclab_contrib/actuators/thruster.py @@ -188,9 +188,9 @@ def motor_model_rate(self, error: torch.Tensor, mixing_factor: torch.Tensor): def rk4_integration(self, error: torch.Tensor, mixing_factor: torch.Tensor): k1 = self.motor_model_rate(error, mixing_factor) - k2 = self.motor_model_rate(error + 0.5 * self.cfg.dt * k1, mixing_factor) - k3 = self.motor_model_rate(error + 0.5 * self.cfg.dt * k2, mixing_factor) - k4 = self.motor_model_rate(error + self.cfg.dt * k3, mixing_factor) + k2 = self.motor_model_rate(error - 0.5 * self.cfg.dt * k1, mixing_factor) + k3 = self.motor_model_rate(error - 0.5 * self.cfg.dt * k2, mixing_factor) + k4 = self.motor_model_rate(error - self.cfg.dt * k3, mixing_factor) return (self.cfg.dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4) def discrete_mixing_factor(self, time_constant: torch.Tensor): diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/__init__.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/__init__.py new file mode 100644 index 000000000000..15ad731d8b9d --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/__init__.py @@ -0,0 +1,16 @@ +# 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 + +"""Sub-package for different controllers and motion-generators. + +Controllers or motion generators are responsible for closed-loop tracking of a given command. The +controller can be a simple PID controller or a more complex controller such as impedance control +or inverse kinematics control. The controller is responsible for generating the desired joint-level +commands to be sent to the robot. +""" + + +from isaaclab.utils.module import lazy_export +lazy_export() diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/__init__.pyi b/source/isaaclab_contrib/isaaclab_contrib/controllers/__init__.pyi new file mode 100644 index 000000000000..648fa27731de --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/__init__.pyi @@ -0,0 +1,32 @@ +# 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__ = [ + "compute_desired_orientation", + "compute_body_torque", + "yaw_rate_to_body_angvel", + "LeeControllerBase", + "LeeControllerBaseCfg", + "LeeAttController", + "LeeAttControllerCfg", + "LeeAccController", + "LeeAccControllerCfg", + "LeePosController", + "LeePosControllerCfg", + "LeeVelController", + "LeeVelControllerCfg", +] + +from .lee_controller_utils import compute_body_torque, compute_desired_orientation, yaw_rate_to_body_angvel +from .lee_controller_base import LeeControllerBase +from .lee_controller_base_cfg import LeeControllerBaseCfg +from .lee_attitude_control import LeeAttController +from .lee_attitude_control_cfg import LeeAttControllerCfg +from .lee_acceleration_control import LeeAccController +from .lee_acceleration_control_cfg import LeeAccControllerCfg +from .lee_position_control import LeePosController +from .lee_position_control_cfg import LeePosControllerCfg +from .lee_velocity_control import LeeVelController +from .lee_velocity_control_cfg import LeeVelControllerCfg diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control.py new file mode 100644 index 000000000000..908f05745a8e --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control.py @@ -0,0 +1,102 @@ +# 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 + +from typing import TYPE_CHECKING + +import torch + +import isaaclab.utils.math as math_utils + +from .lee_controller_base import LeeControllerBase +from .lee_controller_utils import compute_body_torque, compute_desired_orientation, yaw_rate_to_body_angvel + +if TYPE_CHECKING: + from isaaclab.assets import Multirotor + + from .lee_acceleration_control_cfg import LeeAccControllerCfg + + +class LeeAccController(LeeControllerBase): + """Lee acceleration controller for multirotor tracking acceleration setpoints. + + Computes a body-frame wrench command ``[Fx, Fy, Fz, Tx, Ty, Tz]`` from an acceleration setpoint + in the world frame. Gains may be randomized per environment if enabled in the configuration. + """ + + cfg: LeeAccControllerCfg + + def __init__(self, cfg: LeeAccControllerCfg, asset: Multirotor, num_envs: int, device: str): + """Initialize controller. + + Args: + cfg: Controller configuration. + asset: Multirotor asset to control. + num_envs: Number of environments. + device: Device to run computations on. + """ + super().__init__(cfg, asset, num_envs, device) + + # Gain ranges + self.K_rot_range = torch.tensor(self.cfg.K_rot_range, device=device).repeat(num_envs, 1, 1) + self.K_angvel_range = torch.tensor(self.cfg.K_angvel_range, device=device).repeat(num_envs, 1, 1) + + # Current gains + self.K_rot_current = self.K_rot_range.mean(dim=1) + self.K_angvel_current = self.K_angvel_range.mean(dim=1) + + def compute(self, command: torch.Tensor) -> torch.Tensor: + """Compute wrench command from acceleration setpoint. + + Args: + command: (num_envs, 4) acceleration command command [ax, ay, az, yaw_rate] in body frame. + + Returns: + (num_envs, 6) wrench command [fx, fy, fz, tx, ty, tz] in body frame. + """ + self.wrench_command_b.zero_() + + root_quat_w, root_ang_vel_b, _ = self._root_state_tensors() + + # Use command directly as acceleration setpoint + forces_w = (command[:, :3] - self.gravity) * self.mass.view(-1, 1) + + # Project forces to body z-axis for thrust command + body_z_w = math_utils.matrix_from_quat(root_quat_w)[:, :, 2] + self.wrench_command_b[:, 2] = torch.sum(forces_w * body_z_w, dim=1) + + # Get current yaw and compute desired orientation + roll, pitch, yaw = math_utils.euler_xyz_from_quat(root_quat_w) + desired_quat = compute_desired_orientation(forces_w, yaw, self.rotation_matrix_buffer) + + # Compute desired angular velocity in body frame from yaw rate command + desired_angvel_b = yaw_rate_to_body_angvel(command[:, 3], roll, pitch, self.device) + + # Compute torque command + self.wrench_command_b[:, 3:6] = compute_body_torque( + desired_quat, + desired_angvel_b, + root_quat_w, + root_ang_vel_b, + self.robot_inertia, + self.K_rot_current, + self.K_angvel_current, + self.cfg.max_yaw_rate, + ) + + return self.wrench_command_b + + def _randomize_params(self, env_ids: slice | torch.Tensor): + """Randomize controller gains for the given environments if enabled.""" + self.K_rot_current[env_ids] = math_utils.sample_uniform( + self.K_rot_range[env_ids, 0], self.K_rot_range[env_ids, 1], self.K_rot_range[env_ids, 0].shape, self.device + ) + self.K_angvel_current[env_ids] = math_utils.sample_uniform( + self.K_angvel_range[env_ids, 0], + self.K_angvel_range[env_ids, 1], + self.K_angvel_range[env_ids, 0].shape, + self.device, + ) diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control_cfg.py new file mode 100644 index 000000000000..6a1f6c7db7e2 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control_cfg.py @@ -0,0 +1,23 @@ +# 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 isaaclab.utils import configclass + +from .lee_acceleration_control import LeeAccController +from .lee_controller_base_cfg import LeeControllerBaseCfg + + +@configclass +class LeeAccControllerCfg(LeeControllerBaseCfg): + """Configuration for a Lee-style geometric quadrotor acceleration controller. + + Unless otherwise noted, vectors are ordered as (x, y, z) in the simulation world/body frames. + The acceleration controller gains are sampled uniformly per environment between + their corresponding ``*_min`` and ``*_max`` bounds at reset. + """ + + class_type: type = LeeAccController + """The class type for the acceleration controller.""" diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control.py new file mode 100644 index 000000000000..2ea176bde295 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control.py @@ -0,0 +1,98 @@ +# 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 + +from typing import TYPE_CHECKING + +import torch + +import isaaclab.utils.math as math_utils + +from .lee_controller_base import LeeControllerBase +from .lee_controller_utils import compute_body_torque, yaw_rate_to_body_angvel + +if TYPE_CHECKING: + from isaaclab.assets import Multirotor + + from .lee_attitude_control_cfg import LeeAttControllerCfg + + +class LeeAttController(LeeControllerBase): + """Lee attitude controller for multirotor tracking attitude setpoints. + + Computes a body-frame wrench command ``[Fx, Fy, Fz, Tx, Ty, Tz]`` from an attitude setpoint + in the world frame. Gains may be randomized per environment if enabled in the configuration. + """ + + cfg: LeeAttControllerCfg + + def __init__(self, cfg: LeeAttControllerCfg, asset: Multirotor, num_envs: int, device: str): + """Initialize controller. + + Args: + cfg: Controller configuration. + asset: Multirotor asset to control. + num_envs: Number of environments. + device: Device to run computations on. + """ + super().__init__(cfg, asset, num_envs, device) + + # Gain ranges + self.K_rot_range = torch.tensor(self.cfg.K_rot_range, device=device).repeat(num_envs, 1, 1) + self.K_angvel_range = torch.tensor(self.cfg.K_angvel_range, device=device).repeat(num_envs, 1, 1) + + # Current gains + self.K_rot_current = self.K_rot_range.mean(dim=1) + self.K_angvel_current = self.K_angvel_range.mean(dim=1) + + def compute(self, command: torch.Tensor) -> torch.Tensor: + """Compute wrench command from attitude setpoint. + + Args: + command: (num_envs, 4) attitude command command [thrust, roll, pitch, yaw_rate] in body frame. + + Returns: + (num_envs, 6) wrench command [fx, fy, fz, tx, ty, tz] in body frame. + """ + self.wrench_command_b.zero_() + + root_quat_w, root_ang_vel_b, _ = self._root_state_tensors() + + # Use command directly as attitude setpoint + self.wrench_command_b[:, 2] = (command[:, 0] + 1.0) * self.mass * torch.norm(self.gravity, dim=1) + + # Get current yaw and compute desired orientation + roll, pitch, yaw = math_utils.euler_xyz_from_quat(root_quat_w) + desired_quat = math_utils.quat_from_euler_xyz(command[:, 1], command[:, 2], yaw) + + # Compute desired angular velocity in body frame from yaw rate command + desired_angvel_b = yaw_rate_to_body_angvel(command[:, 3], roll, pitch, self.device) + + # Compute torque command + self.wrench_command_b[:, 3:6] = compute_body_torque( + desired_quat, + desired_angvel_b, + root_quat_w, + root_ang_vel_b, + self.robot_inertia, + self.K_rot_current, + self.K_angvel_current, + self.cfg.max_yaw_rate, + ) + + return self.wrench_command_b + + def _randomize_params(self, env_ids: slice | torch.Tensor): + """Randomize controller gains for the given environments if enabled.""" + self.K_rot_current[env_ids] = math_utils.sample_uniform( + self.K_rot_range[env_ids, 0], self.K_rot_range[env_ids, 1], self.K_rot_range[env_ids, 0].shape, self.device + ) + self.K_angvel_current[env_ids] = math_utils.sample_uniform( + self.K_angvel_range[env_ids, 0], + self.K_angvel_range[env_ids, 1], + self.K_angvel_range[env_ids, 0].shape, + self.device, + ) diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control_cfg.py new file mode 100644 index 000000000000..bcf0f9f3ca13 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control_cfg.py @@ -0,0 +1,23 @@ +# 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 isaaclab.utils import configclass + +from .lee_attitude_control import LeeAttController +from .lee_controller_base_cfg import LeeControllerBaseCfg + + +@configclass +class LeeAttControllerCfg(LeeControllerBaseCfg): + """Configuration for a Lee-style geometric quadrotor attitude controller. + + Unless otherwise noted, vectors are ordered as (x, y, z) in the simulation world/body frames. + The attitude controller gains are sampled uniformly per environment between + their corresponding ``*_min`` and ``*_max`` bounds at reset. + """ + + class_type: type = LeeAttController + """The class type for the attitude controller.""" diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base.py new file mode 100644 index 000000000000..231c15710716 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base.py @@ -0,0 +1,120 @@ +# 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 + +"""Base class for Lee-style geometric controllers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch +import warp as wp + +import isaaclab.sim as sim_utils +import isaaclab.utils.math as math_utils + +from isaaclab_contrib.utils.math import aggregate_inertia_about_robot_com + +if TYPE_CHECKING: + from isaaclab.assets import Multirotor + + from .lee_controller_base_cfg import LeeControllerBaseCfg + + +class LeeControllerBase: + """Base class for Lee-style geometric controllers.""" + + cfg: LeeControllerBaseCfg + device: str + robot: Multirotor + + def __init__(self, cfg: LeeControllerBaseCfg, asset: Multirotor, num_envs: int, device: str): + """Initialize controller buffers and pre-compute aggregate inertias. + + Args: + cfg: Controller configuration. + asset: Multirotor asset to control. + num_envs: Number of environments. + device: Device to run computations on. + """ + self.cfg = cfg + self.robot = asset + self.device = device + self.num_envs = num_envs + + root_quat_w = self._to_torch(self.robot.data.root_link_quat_w) + body_link_pos_w = self._to_torch(self.robot.data.body_link_pos_w) + root_pos_w = self._to_torch(self.robot.data.root_pos_w) + body_com_pos_b = self._to_torch(self.robot.data.body_com_pos_b) + body_com_quat_b = self._to_torch(self.robot.data.body_com_quat_b) + body_link_quat_w = self._to_torch(self.robot.data.body_link_quat_w) + + # Aggregate mass and inertia about the robot COM for all bodies + root_quat_exp = root_quat_w.unsqueeze(1).expand(num_envs, self.robot.num_bodies, 4) + body_link_pos_delta = body_link_pos_w - root_pos_w.unsqueeze(1) + + body_masses = self._to_torch(self.robot.root_view.get_masses()) + body_inv_mass_local = torch.where(body_masses > 0, 1.0 / body_masses, torch.zeros_like(body_masses)) + self.mass, self.robot_inertia, _ = aggregate_inertia_about_robot_com( + self._to_torch(self.robot.root_view.get_inertias()), + body_inv_mass_local, + body_com_pos_b, + body_com_quat_b, + math_utils.quat_apply_inverse(root_quat_exp, body_link_pos_delta), + math_utils.quat_mul(math_utils.quat_inv(root_quat_exp), body_link_quat_w), + ) + # Get gravity from simulation context + sim = sim_utils.SimulationContext.instance() + gravity_vec = sim.cfg.gravity + self.gravity = torch.tensor(gravity_vec, device=device, dtype=torch.float32).expand(num_envs, -1) + + # Buffers + self.wrench_command_b = torch.zeros((num_envs, 6), device=device) # [fx, fy, fz, tx, ty, tz] + self.rotation_matrix_buffer = torch.zeros((num_envs, 3, 3), device=device) + + def _to_torch(self, x): + """Convert warp array to torch tensor on controller device; no-op for torch tensors.""" + if torch.is_tensor(x): + return x.to(self.device) + return wp.to_torch(x).to(self.device) + + def _root_state_tensors(self) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Fetch root state once per control step.""" + root_quat_w = self._to_torch(self.robot.data.root_quat_w) + root_ang_vel_b = self._to_torch(self.robot.data.root_ang_vel_b) + root_lin_vel_w = self._to_torch(self.robot.data.root_lin_vel_w) + return root_quat_w, root_ang_vel_b, root_lin_vel_w + + def reset(self): + """Reset controller state for all environments.""" + self.reset_idx(env_ids=None) + + def reset_idx(self, env_ids: torch.Tensor | None): + """Reset controller state (and optionally randomize gains) for selected environments. + + Args: + env_ids: Tensor of environment indices, or ``None`` for all. + """ + if env_ids is None: + env_ids = slice(None) + self._randomize_params(env_ids) + + def _randomize_params(self, env_ids: slice | torch.Tensor): + """Randomize controller gains for the given environments if enabled. + + Override in subclass to implement parameter randomization. + """ + pass + + def compute(self, command: torch.Tensor) -> torch.Tensor: + """Compute wrench command from input command. + + Args: + command: Input command (shape depends on controller type). + + Returns: + (num_envs, 6) wrench command [fx, fy, fz, tx, ty, tz] in body frame. + """ + raise NotImplementedError("Subclasses must implement compute()") diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base_cfg.py new file mode 100644 index 000000000000..3a279f7ddb81 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base_cfg.py @@ -0,0 +1,73 @@ +# 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 + +import math +from dataclasses import MISSING + +from isaaclab.utils import configclass + + +@configclass +class LeeControllerBaseCfg: + """Base configuration for Lee-style geometric quadrotor controllers. + + Unless otherwise noted, vectors are ordered as (x, y, z) in the simulation world/body frames. + The controller gains are sampled uniformly per environment between + their corresponding ``*_min`` and ``*_max`` bounds at reset. + + Note: + To disable randomization, set the min and max values to be identical. + For example: K_rot_range = ((1.85, 1.85, 0.4), (1.85, 1.85, 0.4)) + """ + + K_rot_range: tuple[tuple[float, float, float], tuple[float, float, float]] = MISSING + """Orientation (rotation) error proportional gain range about body axes [unitless]. + + This is a tuple of two tuples containing the minimum and maximum gains for roll, pitch, and yaw. + Format: ((min_roll, min_pitch, min_yaw), (max_roll, max_pitch, max_yaw)) + + To disable randomization, set both tuples to the same values. + + Example (with randomization): + ((1.6, 1.6, 0.25), (1.85, 1.85, 0.4)) for ARL Robot 1 + + Example (without randomization): + ((1.85, 1.85, 0.4), (1.85, 1.85, 0.4)) for fixed gains + """ + + K_angvel_range: tuple[tuple[float, float, float], tuple[float, float, float]] = MISSING + """Body angular-velocity error proportional gain range [unitless]. + + This is a tuple of two tuples containing the minimum and maximum gains for roll, pitch, and yaw rates. + Format: ((min_roll_rate, min_pitch_rate, min_yaw_rate), (max_roll_rate, max_pitch_rate, max_yaw_rate)) + + To disable randomization, set both tuples to the same values. + + Example (with randomization): + ((0.4, 0.4, 0.075), (0.5, 0.5, 0.09)) for ARL Robot 1 + + Example (without randomization): + ((0.5, 0.5, 0.09), (0.5, 0.5, 0.09)) for fixed gains + """ + + max_inclination_angle_rad: float = math.pi / 3 + """Maximum allowed roll/pitch magnitude (inclination) in radians. + + This limits the maximum tilt angle of the quadrotor during control. + Typical range: 0.5 to 1.57 radians (30° to 90°) + + Example: + 1.0471975511965976 (60° in radians) for ARL Robot 1 + """ + + max_yaw_rate: float = MISSING + """Maximum allowed yaw rate command [rad/s]. + + This limits the maximum rotational velocity about the z-axis. + Typical range: 0.5 to 2.0 rad/s + + Example: + 1.0471975511965976 (60°/s in radians) for ARL Robot 1 + """ diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_utils.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_utils.py new file mode 100644 index 000000000000..6a89ec0bdde4 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_utils.py @@ -0,0 +1,127 @@ +# 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 utilities for Lee-style geometric controllers.""" + +import torch + +import isaaclab.utils.math as math_utils + + +def compute_desired_orientation( + forces_w: torch.Tensor, yaw_setpoint: torch.Tensor, rotation_matrix_buffer: torch.Tensor +) -> torch.Tensor: + """Compute desired orientation from force direction and yaw setpoint. + + Args: + forces_w: (num_envs, 3) desired force vector in world frame. + yaw_setpoint: (num_envs,) desired yaw angle [rad]. + rotation_matrix_buffer: (num_envs, 3, 3) pre-allocated buffer for rotation matrix. + + Returns: + (num_envs, 4) desired orientation quaternion (wxyz). + """ + # Desired z-axis (thrust direction) + b3_c = forces_w / (torch.norm(forces_w, dim=1, keepdim=True) + 1e-12) + + # Intermediate direction for yaw + temp_dir = torch.zeros_like(forces_w) + temp_dir[:, 0] = torch.cos(yaw_setpoint) + temp_dir[:, 1] = torch.sin(yaw_setpoint) + + # Desired y-axis (orthogonal to thrust and yaw direction) + b2_c = torch.cross(b3_c, temp_dir, dim=1) + b2_c = b2_c / (torch.norm(b2_c, dim=1, keepdim=True) + 1e-12) + + # Desired x-axis (complete right-handed frame) + b1_c = torch.cross(b2_c, b3_c, dim=1) + + # Build rotation matrix + rotation_matrix_buffer[:, :, 0] = b1_c + rotation_matrix_buffer[:, :, 1] = b2_c + rotation_matrix_buffer[:, :, 2] = b3_c + + # Convert to quaternion + return math_utils.quat_from_matrix(rotation_matrix_buffer) + + +def compute_body_torque( + setpoint_orientation: torch.Tensor, + setpoint_angvel_b: torch.Tensor, + current_quat: torch.Tensor, + current_angvel_b: torch.Tensor, + robot_inertia: torch.Tensor, + K_rot: torch.Tensor, + K_angvel: torch.Tensor, + max_yaw_rate: float, +) -> torch.Tensor: + """PD attitude control in body frame with feedforward Coriolis term. + + Args: + setpoint_orientation: (num_envs, 4) desired orientation quaternion (wxyz) in world frame. + setpoint_angvel_b: (num_envs, 3) desired angular velocity in body frame [rad/s]. + current_quat: (num_envs, 4) current orientation quaternion (wxyz). + current_angvel_b: (num_envs, 3) current angular velocity in body frame [rad/s]. + robot_inertia: (num_envs, 3, 3) robot inertia matrix. + K_rot: (num_envs, 3) rotation gain. + K_angvel: (num_envs, 3) angular velocity gain. + max_yaw_rate: Maximum yaw rate [rad/s]. + + Returns: + (num_envs, 3) body torque command [N·m]. + """ + # Clamp yaw rate + setpoint_angvel_b[:, 2] = torch.clamp(setpoint_angvel_b[:, 2], -max_yaw_rate, max_yaw_rate) + + # Compute orientation error (R^T @ R_d) + RT_Rd_quat = math_utils.quat_mul(math_utils.quat_inv(current_quat), setpoint_orientation) + R_err = math_utils.matrix_from_quat(RT_Rd_quat) + + # Extract rotation error vector from skew-symmetric part + skew_matrix = R_err.transpose(-1, -2) - R_err + rotation_error = 0.5 * torch.stack([-skew_matrix[:, 1, 2], skew_matrix[:, 0, 2], -skew_matrix[:, 0, 1]], dim=1) + + # Angular velocity error + angvel_error = current_angvel_b - setpoint_angvel_b + + # Coriolis feedforward term: ω × (I·ω) + inertia_angvel = torch.bmm(robot_inertia, current_angvel_b.unsqueeze(2)).squeeze(2) + coriolis_term = torch.cross(current_angvel_b, inertia_angvel, dim=1) + + # PD + feedforward + torque = -K_rot * rotation_error - K_angvel * angvel_error + coriolis_term + return torque + + +def yaw_rate_to_body_angvel( + yaw_rate: torch.Tensor, roll: torch.Tensor, pitch: torch.Tensor, device: torch.device +) -> torch.Tensor: + """Convert yaw rate command to body angular velocity. + + Transformation: ω_body = T(roll, pitch) @ [0, 0, yaw_rate]^T + where T is the euler-to-body rate transformation matrix. + + Args: + yaw_rate: (num_envs,) desired yaw rate [rad/s]. + roll: (num_envs,) current roll angle [rad]. + pitch: (num_envs,) current pitch angle [rad]. + device: Device to allocate tensors on. + + Returns: + (num_envs, 3) desired angular velocity in body frame [rad/s]. + """ + s_pitch = torch.sin(pitch) + c_pitch = torch.cos(pitch) + s_roll = torch.sin(roll) + c_roll = torch.cos(roll) + + # Only yaw rate is non-zero, so only the third column matters + # ω_body = [−sin(pitch), sin(roll)*cos(pitch), cos(roll)*cos(pitch)]^T * yaw_rate + angvel_b = torch.zeros((yaw_rate.shape[0], 3), device=device) + angvel_b[:, 0] = -s_pitch * yaw_rate + angvel_b[:, 1] = s_roll * c_pitch * yaw_rate + angvel_b[:, 2] = c_roll * c_pitch * yaw_rate + + return angvel_b diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control.py new file mode 100644 index 000000000000..82fff709042a --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control.py @@ -0,0 +1,134 @@ +# 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 + +from typing import TYPE_CHECKING + +import torch + +import isaaclab.utils.math as math_utils + +from .lee_controller_base import LeeControllerBase +from .lee_controller_utils import compute_body_torque, compute_desired_orientation + +if TYPE_CHECKING: + from isaaclab.assets import Multirotor + + from .lee_position_control_cfg import LeePosControllerCfg + + +class LeePosController(LeeControllerBase): + """Lee position controller for multirotor tracking position setpoints. + + Computes a body-frame wrench command ``[Fx, Fy, Fz, Tx, Ty, Tz]`` from a position setpoint + in the world frame. Gains may be randomized per environment if enabled in the configuration. + """ + + cfg: LeePosControllerCfg + + def __init__(self, cfg: LeePosControllerCfg, asset: Multirotor, num_envs: int, device: str): + """Initialize controller. + + Args: + cfg: Controller configuration. + asset: Multirotor asset to control. + num_envs: Number of environments. + device: Device to run computations on. + """ + super().__init__(cfg, asset, num_envs, device) + + # Gain ranges + self.K_pos_range = torch.tensor(self.cfg.K_pos_range, device=device).repeat(num_envs, 1, 1) + self.K_vel_range = torch.tensor(self.cfg.K_vel_range, device=device).repeat(num_envs, 1, 1) + self.K_rot_range = torch.tensor(self.cfg.K_rot_range, device=device).repeat(num_envs, 1, 1) + self.K_angvel_range = torch.tensor(self.cfg.K_angvel_range, device=device).repeat(num_envs, 1, 1) + + # Current gains + self.K_pos_current = self.K_pos_range.mean(dim=1) + self.K_vel_current = self.K_vel_range.mean(dim=1) + self.K_rot_current = self.K_rot_range.mean(dim=1) + self.K_angvel_current = self.K_angvel_range.mean(dim=1) + + def compute(self, command: torch.Tensor) -> torch.Tensor: + """Compute wrench command from position setpoint. + + Args: + command: (num_envs, 4) [x, y, z, yaw] in body frame. + + Returns: + (num_envs, 6) wrench command [fx, fy, fz, tx, ty, tz] in body frame. + """ + self.wrench_command_b.zero_() + + root_quat_w, root_ang_vel_b, root_lin_vel_w = self._root_state_tensors() + root_pos_w = self._to_torch(self.robot.data.root_pos_w) + + # Compute acceleration from position error + acc = self._compute_acceleration( + setpoint_position=command[:, :3], + root_pos_w=root_pos_w, + root_lin_vel_w=root_lin_vel_w, + ) + forces_w = (acc - self.gravity) * self.mass.view(-1, 1) + + # Project forces to body z-axis for thrust command + body_z_w = math_utils.matrix_from_quat(root_quat_w)[:, :, 2] + self.wrench_command_b[:, 2] = torch.sum(forces_w * body_z_w, dim=1) + + # Get current yaw and compute desired orientation + desired_quat = compute_desired_orientation(forces_w, command[:, 3], self.rotation_matrix_buffer) + + # Zero angular velocity setpoint (hover) + desired_angvel_b = torch.zeros((self.num_envs, 3), device=self.device) + + # Compute torque command + self.wrench_command_b[:, 3:6] = compute_body_torque( + desired_quat, + desired_angvel_b, + root_quat_w, + root_ang_vel_b, + self.robot_inertia, + self.K_rot_current, + self.K_angvel_current, + self.cfg.max_yaw_rate, + ) + + return self.wrench_command_b + + def _randomize_params(self, env_ids: slice | torch.Tensor): + """Randomize controller gains for the given environments if enabled.""" + self.K_pos_current[env_ids] = math_utils.sample_uniform( + self.K_pos_range[env_ids, 0], self.K_pos_range[env_ids, 1], self.K_pos_range[env_ids, 0].shape, self.device + ) + self.K_vel_current[env_ids] = math_utils.sample_uniform( + self.K_vel_range[env_ids, 0], self.K_vel_range[env_ids, 1], self.K_vel_range[env_ids, 0].shape, self.device + ) + self.K_rot_current[env_ids] = math_utils.sample_uniform( + self.K_rot_range[env_ids, 0], self.K_rot_range[env_ids, 1], self.K_rot_range[env_ids, 0].shape, self.device + ) + self.K_angvel_current[env_ids] = math_utils.sample_uniform( + self.K_angvel_range[env_ids, 0], + self.K_angvel_range[env_ids, 1], + self.K_angvel_range[env_ids, 0].shape, + self.device, + ) + + def _compute_acceleration( + self, setpoint_position: torch.Tensor, root_pos_w: torch.Tensor, root_lin_vel_w: torch.Tensor + ) -> torch.Tensor: + """Compute desired acceleration from position error. + + Args: + setpoint_position: (num_envs, 3) desired position in world frame. + + Returns: + (num_envs, 3) desired acceleration in body frame. + """ + position_error = setpoint_position - root_pos_w + # Compute velocity error for position controller + velocity_error = -root_lin_vel_w + + return self.K_vel_current * velocity_error + self.K_pos_current * position_error diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control_cfg.py new file mode 100644 index 000000000000..e2df30f8aa70 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control_cfg.py @@ -0,0 +1,44 @@ +# 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 dataclasses import MISSING + +from isaaclab.utils import configclass + +from .lee_controller_base_cfg import LeeControllerBaseCfg +from .lee_position_control import LeePosController + + +@configclass +class LeePosControllerCfg(LeeControllerBaseCfg): + """Configuration for a Lee-style geometric quadrotor position controller. + + Unless otherwise noted, vectors are ordered as (x, y, z) in the simulation world/body frames. + The position controller gains are sampled uniformly per environment between + their corresponding ``*_min`` and ``*_max`` bounds at reset. + """ + + class_type: type = LeePosController + """The class type for the position controller.""" + + K_pos_range: tuple[tuple[float, float, float], tuple[float, float, float]] = MISSING + """Position error proportional gain range about body axes [unitless]. + + This is a tuple of two tuples containing the minimum and maximum gains for each axis (x, y, z). + Format: ((min_x, min_y, min_z), (max_x, max_y, max_z)) + + Example: + ((3.0, 3.0, 2.0), (4.0, 4.0, 2.5)) for ARL Robot 1 + """ + + K_vel_range: tuple[tuple[float, float, float], tuple[float, float, float]] = MISSING + """Velocity error proportional gain range about body axes [unitless]. + + This is a tuple of two tuples containing the minimum and maximum gains for each axis (x, y, z). + Format: ((min_x, min_y, min_z), (max_x, max_y, max_z)) + + Example: + ((2.5, 2.5, 1.5), (3.5, 3.5, 2.0)) for ARL Robot 1 + """ diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control.py new file mode 100644 index 000000000000..14dc1ff970c3 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control.py @@ -0,0 +1,133 @@ +# 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 + +from typing import TYPE_CHECKING + +import torch + +import isaaclab.utils.math as math_utils + +from .lee_controller_base import LeeControllerBase +from .lee_controller_utils import compute_body_torque, compute_desired_orientation, yaw_rate_to_body_angvel + +if TYPE_CHECKING: + from isaaclab.assets import Multirotor + + from .lee_velocity_control_cfg import LeeVelControllerCfg + + +class LeeVelController(LeeControllerBase): + """Lee velocity controller for multirotor tracking velocity setpoints. + + Computes a body-frame wrench command ``[Fx, Fy, Fz, Tx, Ty, Tz]`` from a velocity setpoint: + [vx, vy, vz, yaw_rate]. Gains may be randomized per environment if enabled in the configuration. + """ + + cfg: LeeVelControllerCfg + + def __init__(self, cfg: LeeVelControllerCfg, asset: Multirotor, num_envs: int, device: str): + """Initialize controller. + + Args: + cfg: Controller configuration. + asset: Multirotor asset to control. + num_envs: Number of environments. + device: Device to run computations on. + """ + super().__init__(cfg, asset, num_envs, device) + + # Gain ranges + self.K_vel_range = torch.tensor(self.cfg.K_vel_range, device=device).repeat(num_envs, 1, 1) + self.K_rot_range = torch.tensor(self.cfg.K_rot_range, device=device).repeat(num_envs, 1, 1) + self.K_angvel_range = torch.tensor(self.cfg.K_angvel_range, device=device).repeat(num_envs, 1, 1) + + # Current gains + self.K_vel_current = self.K_vel_range.mean(dim=1) + self.K_rot_current = self.K_rot_range.mean(dim=1) + self.K_angvel_current = self.K_angvel_range.mean(dim=1) + + def compute(self, command: torch.Tensor) -> torch.Tensor: + """Compute wrench command from velocity setpoint. + + Args: + command: (num_envs, 4) velocity command [vx, vy, vz, yaw_rate] in body frame. + + Returns: + (num_envs, 6) wrench command [fx, fy, fz, tx, ty, tz] in body frame. + """ + self.wrench_command_b.zero_() + + root_quat_w, root_ang_vel_b, root_lin_vel_w = self._root_state_tensors() + + # Compute acceleration from velocity tracking + acc = self._compute_acceleration( + setpoint_velocity=command[:, :3], root_quat_w=root_quat_w, root_lin_vel_w=root_lin_vel_w + ) + + forces_w = (acc - self.gravity) * self.mass.view(-1, 1) + + # Project forces to body z-axis for thrust command + body_z_w = math_utils.matrix_from_quat(root_quat_w)[:, :, 2] + self.wrench_command_b[:, 2] = torch.sum(forces_w * body_z_w, dim=1) + + # Compute desired orientation from force direction and yaw setpoint + roll, pitch, yaw = math_utils.euler_xyz_from_quat(root_quat_w) + desired_quat = compute_desired_orientation(forces_w, yaw, self.rotation_matrix_buffer) + + # Compute desired angular velocity in body frame from yaw rate command + desired_angvel_b = yaw_rate_to_body_angvel(command[:, 3], roll, pitch, self.device) + + # Compute torque command + self.wrench_command_b[:, 3:6] = compute_body_torque( + desired_quat, + desired_angvel_b, + root_quat_w, + root_ang_vel_b, + self.robot_inertia, + self.K_rot_current, + self.K_angvel_current, + self.cfg.max_yaw_rate, + ) + + return self.wrench_command_b + + def _randomize_params(self, env_ids: slice | torch.Tensor): + """Randomize controller gains for the given environments if enabled.""" + self.K_vel_current[env_ids] = math_utils.sample_uniform( + self.K_vel_range[env_ids, 0], self.K_vel_range[env_ids, 1], self.K_vel_range[env_ids, 0].shape, self.device + ) + self.K_rot_current[env_ids] = math_utils.sample_uniform( + self.K_rot_range[env_ids, 0], self.K_rot_range[env_ids, 1], self.K_rot_range[env_ids, 0].shape, self.device + ) + self.K_angvel_current[env_ids] = math_utils.sample_uniform( + self.K_angvel_range[env_ids, 0], + self.K_angvel_range[env_ids, 1], + self.K_angvel_range[env_ids, 0].shape, + self.device, + ) + + def _compute_acceleration( + self, setpoint_velocity: torch.Tensor, root_quat_w: torch.Tensor, root_lin_vel_w: torch.Tensor + ) -> torch.Tensor: + """Compute desired acceleration from velocity tracking error. + + Args: + setpoint_velocity: (num_envs, 3) desired velocity in body frame. + + Returns: + (num_envs, 3) desired acceleration in body frame. + """ + # Get yaw-only orientation (vehicle frame) + _, _, yaw = math_utils.euler_xyz_from_quat(root_quat_w) + vehicle_quat = math_utils.quat_from_euler_xyz(torch.zeros_like(yaw), torch.zeros_like(yaw), yaw) + + # Transform setpoint from body to world frame + setpoint_velocity_w = math_utils.quat_apply(vehicle_quat, setpoint_velocity) + + # Compute velocity error and acceleration command + velocity_error = setpoint_velocity_w - root_lin_vel_w + return self.K_vel_current * velocity_error diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control_cfg.py new file mode 100644 index 000000000000..13ef9814d268 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control_cfg.py @@ -0,0 +1,34 @@ +# 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 dataclasses import MISSING + +from isaaclab.utils import configclass + +from .lee_controller_base_cfg import LeeControllerBaseCfg +from .lee_velocity_control import LeeVelController + + +@configclass +class LeeVelControllerCfg(LeeControllerBaseCfg): + """Configuration for a Lee-style geometric quadrotor velocity controller. + + Unless otherwise noted, vectors are ordered as (x, y, z) in the simulation world/body frames. + The velocity controller gains are sampled uniformly per environment between + their corresponding ``*_min`` and ``*_max`` bounds at reset. + """ + + class_type: type = LeeVelController + """The class type for the velocity controller.""" + + K_vel_range: tuple[tuple[float, float, float], tuple[float, float, float]] = MISSING + """Velocity error proportional gain range about body axes [unitless]. + + This is a tuple of two tuples containing the minimum and maximum gains for each axis (x, y, z). + Format: ((min_x, min_y, min_z), (max_x, max_y, max_z)) + + Example: + ((2.5, 2.5, 1.5), (3.5, 3.5, 2.0)) for ARL Robot 1 + """ diff --git a/source/isaaclab_contrib/isaaclab_contrib/mdp/__init__.pyi b/source/isaaclab_contrib/isaaclab_contrib/mdp/__init__.pyi index 412db0ce4d8f..497bd981d0dd 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/mdp/__init__.pyi +++ b/source/isaaclab_contrib/isaaclab_contrib/mdp/__init__.pyi @@ -5,7 +5,9 @@ __all__ = [ "ThrustAction", + "NavigationAction", "ThrustActionCfg", + "NavigationActionCfg", ] -from .actions import ThrustAction, ThrustActionCfg +from .actions import NavigationAction, NavigationActionCfg, ThrustAction, ThrustActionCfg diff --git a/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/__init__.pyi b/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/__init__.pyi index 8203016432d4..ede3ae69e2d0 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/__init__.pyi +++ b/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/__init__.pyi @@ -5,8 +5,10 @@ __all__ = [ "ThrustAction", + "NavigationAction", "ThrustActionCfg", + "NavigationActionCfg", ] -from .thrust_actions import ThrustAction -from .thrust_actions_cfg import ThrustActionCfg +from .thrust_actions import NavigationAction, ThrustAction +from .thrust_actions_cfg import NavigationActionCfg, ThrustActionCfg diff --git a/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions.py b/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions.py index 5ed60f190c4f..d529692a8485 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions.py +++ b/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions.py @@ -244,3 +244,171 @@ def apply_actions(self): """ # Set thrust targets using thruster IDs self._asset.set_thrust_target(self.processed_actions, thruster_ids=self._thruster_ids) + + +class NavigationAction(ThrustAction): + """Navigation action term that converts high-level navigation commands to thrust commands + using a geometric tracking controller. + + This action term extends `ThrustAction` by adding a controller layer that computes wrench + (force and torque) commands from navigation setpoints, then allocates those wrenches to + individual thruster commands using the multirotor's allocation matrix. + + The controller type is automatically determined based on the `controller_cfg` type: + - LeeVelControllerCfg: Velocity tracking controller + - LeePosControllerCfg: Position tracking controller + - LeeAccControllerCfg: Acceleration tracking controller + + The control pipeline: + 1. Process raw actions (scale, offset, clip) using parent `ThrustAction` + 2. Transform processed actions into setpoints constrained within camera FOV + 3. Compute 6-DOF wrench command using the selected Lee controller + 4. Solve thrust allocation: thrust_cmd = pinv(allocation_matrix) @ wrench_cmd + 5. Apply thrust commands to thrusters + + Attributes: + cfg: Configuration for the navigation action term, including controller config. + _lc: Lee controller instance (LeeVelController, LeePosController, or LeeAccController). + + Action Space: + The action dimension is always 3D: (forward_magnitude, pitch_angle, yaw_rate) + + Actions are clipped in range [-1, 1] and are transformed to controller commands: + - Forward position/velocity/acceleration: + [0, max_magnitude] via (action[0] + 1) * cos(pitch) * max_magnitude / 2 + - Lateral position/velocity/acceleration: + Always 0.0 (constrained to camera FOV) + - Vertical position/velocity/acceleration: + [0, max_magnitude] via (action[0] + 1) * sin(pitch) * max_magnitude / 2 + - Yaw command: [-max_yaw_command, max_yaw_command] via action[2] * max_yaw_command (yaw command is yawrate + [rad/s] for velocity and acceleration control and relative yaw change [rad] for position control) + + Where: + - pitch angle is computed as: action[1] * max_inclination_angle + + Parameters (from cfg): + max_magnitude: Maximum translational magnitude for position/velocity/acceleration commands. + max_yaw_command: Maximum yaw command in rad/s for velocity and acceleration + control and relative yaw change [rad] for position control. + max_inclination_angle: Maximum pitch angle in rad. + + Notes: + - The controller's internal states (e.g., integral terms) are reset when `reset()` is called. + - Lateral term is constrained to 0.0 to keep commands within camera FOV. + - The x and z components are derived from magnitude and inclination angle. + - Requires the multirotor asset to have a valid `allocation_matrix` attribute. + + Example: + ```python + cfg = NavigationActionCfg( + controller_cfg=LeeVelControllerCfg(...), + asset_name="robot", + max_magnitude=2.0, + max_yaw_command=1.047, + max_inclination_angle=0.785, # pi/4 + ) + nav_action = NavigationAction(cfg, env) + ``` + """ + + cfg: thrust_actions_cfg.NavigationActionCfg + """The configuration of the action term.""" + + def __init__(self, cfg: thrust_actions_cfg.NavigationActionCfg, env: ManagerBasedEnv) -> None: + # Initialize parent class (this handles all the thruster setup) + super().__init__(cfg, env) + + # Initialize controller using class_type from config + self._lc = self.cfg.controller_cfg.class_type( + cfg=self.cfg.controller_cfg, asset=self._asset, num_envs=self.num_envs, device=self.device + ) + + # Log warning if not using velocity controller + from isaaclab_contrib.controllers import LeeVelControllerCfg + + if not isinstance(self.cfg.controller_cfg, LeeVelControllerCfg): + logger.warning( + "Navigation task tuned for velocity control. " + "Consider using velocity controller for better performance or retune reward function." + ) + + # Cache allocation matrix and its pseudo-inverse (static for this asset/config) + self._allocation_matrix = self._asset.allocation_matrix + self._allocation_pinv = torch.linalg.pinv(self._allocation_matrix) + + # Add buffer to store velocity commands for observations) + self._commands = torch.zeros(self.num_envs, 4, device=self.device) + self._prev_commands = torch.zeros(self.num_envs, 4, device=self.device) + + @property + def action_dim(self) -> int: + return 3 + + @property + def prev_commands(self) -> torch.Tensor: + return self._prev_commands + + @property + def IO_descriptor(self) -> GenericActionIODescriptor: + """The IO descriptor of the action term.""" + # Get parent IO descriptor + descriptor = super().IO_descriptor + # Override action type for navigation + descriptor.action_type = "NavigationAction" + return descriptor + + def process_actions(self, actions: torch.Tensor): + """Process actions by applying scaling, offset, and clipping.""" + # Call parent to handle basic processing + super().process_actions(actions) + + self._has_actions_updated = False + + def apply_actions(self): + """Apply the processed actions as velocity commands.""" + # process the actions to be in the correct range + clamped_action = torch.clamp(self.processed_actions, min=-1.0, max=1.0) + processed_actions = torch.zeros(self.num_envs, 4, device=self.device) + + clamped_action[:, 0] += 1.0 # only allow positive thrust commands [0, 2] + processed_actions[:, 0] = ( + clamped_action[:, 0] + * torch.cos(self.cfg.max_inclination_angle * clamped_action[:, 1]) + * self.cfg.max_magnitude + / 2.0 + ) + processed_actions[:, 1] = 0.0 # set lateral thrust command to 0 + processed_actions[:, 2] = ( + clamped_action[:, 0] + * torch.sin(self.cfg.max_inclination_angle * clamped_action[:, 1]) + * self.cfg.max_magnitude + / 2.0 + ) + processed_actions[:, 3] = clamped_action[:, 2] * self.cfg.max_yaw_command + + # Store velocity commands for observations + if not self._has_actions_updated: + self._prev_commands[:] = self._commands + self._commands[:] = processed_actions + self._has_actions_updated = True + + # Compute wrench command using controller + wrench_command = self._lc.compute(processed_actions) + + # Convert wrench to thrust commands using allocation matrix + thrust_commands = wrench_command @ self._allocation_pinv.T + + # Apply thrust commands using thruster IDs + self._asset.set_thrust_target(thrust_commands, thruster_ids=self._thruster_ids) + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + # Call parent reset + super().reset(env_ids) + # Reset controller internal states + self._lc.reset_idx(env_ids) + + if env_ids is None: + env_ids = slice(None) + + self._commands[env_ids] = 0.0 + self._prev_commands[env_ids] = 0.0 diff --git a/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions_cfg.py index 3a464b8fce84..d06242f80ab0 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions_cfg.py @@ -2,6 +2,7 @@ # All rights reserved. # # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations from dataclasses import MISSING from typing import TYPE_CHECKING @@ -10,7 +11,9 @@ from isaaclab.utils import configclass if TYPE_CHECKING: - from .thrust_actions import ThrustAction + from isaaclab_contrib.controllers import LeeAccControllerCfg, LeePosControllerCfg, LeeVelControllerCfg + + from .thrust_actions import NavigationAction, ThrustAction @configclass @@ -72,7 +75,7 @@ class ThrustActionCfg(ActionTermCfg): - :class:`~isaaclab.managers.ActionTermCfg`: Base action term configuration """ - class_type: type["ThrustAction"] | str = "{DIR}.thrust_actions:ThrustAction" + class_type: type[ThrustAction] | str = "{DIR}.thrust_actions:ThrustAction" asset_name: str = MISSING """Name or regex expression of the asset that the action will be mapped to. @@ -168,3 +171,37 @@ class ThrustActionCfg(ActionTermCfg): If ``False``, the manually specified :attr:`offset` value is used. """ + + +@configclass +class NavigationActionCfg(ThrustActionCfg): + """Configuration for the navigation action term. + + This action term constrains the controller action to be within the field of view (FOV) + of the camera sensor. Specifically: + + - **y-component**: Always 0, as the camera FOV constraint restricts lateral movement + - **x and z components**: Derived from the action max_magnitude and max_inclination_angle, + ensuring the desired acceleration/velocity/position vector remains aligned with the camera's + viewing direction + + This constraint ensures that navigation commands respect the sensor's field of view + limitations, preventing commands that would be out of the camera's visual range. + + See :class:`NavigationAction` for more details. + """ + + class_type: type[NavigationAction] | str = "{DIR}.thrust_actions:NavigationAction" + + controller_cfg: LeeVelControllerCfg | LeePosControllerCfg | LeeAccControllerCfg = MISSING + """The configuration for the Lee velocity controller.""" + + max_magnitude: float = MISSING + """Maximum magnitude for position [m], velocity [m/s], or acceleration [m/s²] commands.""" + + max_yaw_command: float = MISSING + """Maximum yaw command. Yaw rate [rad/s] for velocity and acceleration lee geometric controller and relative + yaw change [rad] for position lee geometric controller.""" + + max_inclination_angle: float = MISSING + """Maximum inclination angle [rad] for position, velocity and acceleration lee geometric controller.""" diff --git a/source/isaaclab_contrib/isaaclab_contrib/utils/math.py b/source/isaaclab_contrib/isaaclab_contrib/utils/math.py new file mode 100644 index 000000000000..840cc62440b5 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/utils/math.py @@ -0,0 +1,93 @@ +# 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 + +"""Sub-module containing utilities for various math operations.""" + +# needed to import for allowing type-hinting: torch.Tensor | np.ndarray +from __future__ import annotations + +import logging + +import torch +import torch.nn.functional + +from isaaclab.utils.math import matrix_from_quat + +# import logger +logger = logging.getLogger(__name__) + + +def aggregate_inertia_about_robot_com( + body_inertias_local: torch.Tensor, + body_inv_mass_local: torch.Tensor, + body_com_pos_b: torch.Tensor, + body_com_quat_b: torch.Tensor, + body_pos_b: torch.Tensor, + body_quat_b: torch.Tensor, + eps=1e-12, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Aggregate per-link inertias into a single inertia about the robot COM, + expressed in the base (root link) frame. + + Shapes: + num_envs=N, num_bodies=B + + Args: + body_inertias_local (N,B,9|3,3): Link inertias in the mass/COM frame. + body_inv_mass_local (N,B): Inverse link masses (<=0 treated as padding). + body_com_pos_b (N,B,3): Link COM position relative to the link frame + (massLocalPose translation); used as body_pos_b + R_link_base @ body_com_pos_b. + body_com_quat_b (N,B,4 xyzw): Mass→link rotation (massLocalPose rotation). + body_pos_b (N,B,3): Link origins in base frame. + body_quat_b (N,B,4 xyzw): Link→base orientation. + eps (float): Small value to guard division by zero. + + Returns: + total_mass (N,): Sum of link masses. + I_total (N,3,3): Inertia about robot COM in base frame (symmetrized). + com_robot_b (N,3): Robot COM in base frame. + + Method (base frame throughout): + 1) COM of each link: com_link_b = body_pos_b + R_link_base @ body_com_pos_b + 2) Robot COM: mass-weighted average of com_link_b + 3) Transform each link inertia via R: I_b = R I_local R^T + 4) Parallel-axis: I_pa = m (‖r‖² I - r rᵀ), r = com_link_b - com_robot_b + 5) Sum over links and symmetrize + """ + # Inertia in mass frame (local to COM) + num_envs, num_bodies, _ = body_inertias_local.shape + I_local = body_inertias_local.view(num_envs, num_bodies, 3, 3) + + # Masses + m = torch.where(body_inv_mass_local > 0, 1.0 / body_inv_mass_local, torch.zeros_like(body_inv_mass_local)) + m_sum = m.sum(dim=1, keepdim=True) + valid = (m > 0).float().unsqueeze(-1) + + # Link COM positions in base frame + R_link_base = matrix_from_quat(body_quat_b) + com_link_b = body_pos_b + (R_link_base @ body_com_pos_b[..., :, None]).squeeze(-1) + + # Robot COM base frame (mass-weighted) + com_robot_b = (m.unsqueeze(-1) * com_link_b).sum(dim=1) / (m_sum + eps) + + # Rotate inertia from mass frame to world: R = R_link_base * R_mass + R_mass = matrix_from_quat(body_com_quat_b) + R = R_link_base @ R_mass + I_world = R @ I_local @ R.transpose(-1, -2) + + # Parallel-axis to robot COM + r = com_link_b - com_robot_b[:, None, :] + rrT = r[..., :, None] @ r[..., None, :] + r2 = (r * r).sum(dim=-1, keepdim=True) + I3 = torch.eye(3, device=body_pos_b.device).reshape(1, 1, 3, 3).expand(num_envs, num_bodies, 3, 3) + I_pa = m[..., None, None] * (r2[..., None] * I3 - rrT) + + # Sum over links (ignore zero-mass pads) + I_total = ((I_world + I_pa) * valid[..., None]).sum(dim=1) + I_total = 0.5 * (I_total + I_total.transpose(-1, -2)) + total_mass = m.sum(dim=1) + + return total_mass, I_total, com_robot_b diff --git a/source/isaaclab_contrib/test/controllers/test_drone_geometric_controllers.py b/source/isaaclab_contrib/test/controllers/test_drone_geometric_controllers.py new file mode 100644 index 000000000000..c24c8e5dc870 --- /dev/null +++ b/source/isaaclab_contrib/test/controllers/test_drone_geometric_controllers.py @@ -0,0 +1,345 @@ +# 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 + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +import types + +import pytest +import torch + +from isaaclab_contrib.controllers import ( + lee_acceleration_control as acc_mod, +) +from isaaclab_contrib.controllers import ( + lee_attitude_control as att_mod, +) +from isaaclab_contrib.controllers import lee_controller_base as base_mod +from isaaclab_contrib.controllers import ( + lee_position_control as pos_mod, +) +from isaaclab_contrib.controllers import ( + lee_velocity_control as vel_mod, +) +from isaaclab_contrib.controllers.lee_acceleration_control_cfg import LeeAccControllerCfg +from isaaclab_contrib.controllers.lee_attitude_control_cfg import LeeAttControllerCfg +from isaaclab_contrib.controllers.lee_position_control_cfg import LeePosControllerCfg +from isaaclab_contrib.controllers.lee_velocity_control_cfg import LeeVelControllerCfg + + +class _DummyRootView: + """Stub articulation view with ``get_masses`` and ``get_inertias`` for controller tests.""" + + def __init__(self, num_envs: int, num_bodies: int, device: torch.device): + inertia_flat = torch.eye(3, device=device).reshape(9) + self._inertias = inertia_flat.unsqueeze(0).unsqueeze(0).expand(num_envs, num_bodies, 9).clone() + self._masses = torch.ones((num_envs, num_bodies), device=device) + + def get_inertias(self) -> torch.Tensor: + return self._inertias + + def get_masses(self) -> torch.Tensor: + return self._masses + + +class _DummyRobot: + """Minimal multirotor stub exposing the attributes used by the controllers.""" + + def __init__(self, num_envs: int, num_bodies: int, device: torch.device): + self.num_bodies = num_bodies + quat_id = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) + self.data = types.SimpleNamespace( + root_link_quat_w=quat_id.repeat(num_envs, 1), + root_quat_w=quat_id.repeat(num_envs, 1), + root_pos_w=torch.zeros((num_envs, 3), device=device), + root_lin_vel_w=torch.zeros((num_envs, 3), device=device), + root_ang_vel_b=torch.zeros((num_envs, 3), device=device), + body_link_pos_w=torch.zeros((num_envs, num_bodies, 3), device=device), + body_link_quat_w=quat_id.repeat(num_envs, num_bodies, 1), + body_com_pos_b=torch.zeros((num_envs, num_bodies, 3), device=device), + body_com_quat_b=quat_id.repeat(num_envs, num_bodies, 1), + ) + self.root_view = _DummyRootView(num_envs, num_bodies, device) + + +class _DummySimCfg: + """Mock simulation config.""" + + def __init__(self): + self.gravity = (0.0, 0.0, -9.81) + + +class _DummySimContext: + """Mock simulation context.""" + + def __init__(self): + self.cfg = _DummySimCfg() + + +def _patch_aggregate(monkeypatch, _module, num_envs, device): + def _agg(*_args, **_kwargs): + return ( + torch.ones(num_envs, device=device), + torch.eye(3, device=device).repeat(num_envs, 1, 1), + torch.zeros((num_envs, 3, 3), device=device), + ) + + monkeypatch.setattr(base_mod, "aggregate_inertia_about_robot_com", _agg) + + +def _patch_sim_context(monkeypatch: pytest.MonkeyPatch, module) -> None: + """Monkeypatch SimulationContext.instance() to return a mock.""" + import isaaclab.sim as sim_utils + + def _mock_instance(): + return _DummySimContext() + + monkeypatch.setattr(sim_utils.SimulationContext, "instance", _mock_instance) + + +def _device_param(device_str: str) -> torch.device: + """Return the torch.device or skip when CUDA is unavailable.""" + if device_str == "cuda" and not torch.cuda.is_available(): + pytest.skip("CUDA not available on this system") + return torch.device(device_str) + + +def _create_vel_cfg() -> LeeVelControllerCfg: + """Create velocity controller config with required parameters.""" + cfg = LeeVelControllerCfg() + cfg.K_vel_range = ((2.7, 2.7, 1.3), (3.3, 3.3, 1.7)) + cfg.K_rot_range = ((1.6, 1.6, 0.25), (1.85, 1.85, 0.4)) + cfg.K_angvel_range = ((0.4, 0.4, 0.075), (0.5, 0.5, 0.09)) + cfg.max_inclination_angle_rad = 1.0471975511965976 + cfg.max_yaw_rate = 1.0471975511965976 + return cfg + + +def _create_pos_cfg() -> LeePosControllerCfg: + """Create position controller config with required parameters.""" + cfg = LeePosControllerCfg() + cfg.K_pos_range = ((3.0, 3.0, 2.0), (4.0, 4.0, 2.5)) + cfg.K_vel_range = ((2.5, 2.5, 1.5), (3.5, 3.5, 2.0)) + cfg.K_rot_range = ((1.6, 1.6, 0.25), (1.85, 1.85, 0.4)) + cfg.K_angvel_range = ((0.4, 0.4, 0.075), (0.5, 0.5, 0.09)) + cfg.max_inclination_angle_rad = 1.0471975511965976 + cfg.max_yaw_rate = 1.0471975511965976 + return cfg + + +def _create_acc_cfg() -> LeeAccControllerCfg: + """Create acceleration controller config with required parameters.""" + cfg = LeeAccControllerCfg() + cfg.K_rot_range = ((1.6, 1.6, 0.25), (1.85, 1.85, 0.4)) + cfg.K_angvel_range = ((0.4, 0.4, 0.075), (0.5, 0.5, 0.09)) + cfg.max_inclination_angle_rad = 1.0471975511965976 + cfg.max_yaw_rate = 1.0471975511965976 + return cfg + + +def _create_att_cfg() -> LeeAttControllerCfg: + """Create attitude controller config with required parameters.""" + cfg = LeeAttControllerCfg() + cfg.K_rot_range = ((1.6, 1.6, 0.25), (1.85, 1.85, 0.4)) + cfg.K_angvel_range = ((0.4, 0.4, 0.075), (0.5, 0.5, 0.09)) + cfg.max_yaw_rate = 1.0471975511965976 + return cfg + + +@pytest.mark.parametrize("device_str", ["cpu", "cuda"]) +@pytest.mark.parametrize("num_envs", [1, 2, 8]) +@pytest.mark.parametrize("num_bodies", [1, 4]) +@pytest.mark.parametrize( + "controller_cls,cfg_factory,mod_name", + [ + ("LeeVelController", _create_vel_cfg, vel_mod), + ("LeePosController", _create_pos_cfg, pos_mod), + ("LeeAccController", _create_acc_cfg, acc_mod), + ("LeeAttController", _create_att_cfg, att_mod), + ], +) +def test_lee_controllers_basic( + monkeypatch: pytest.MonkeyPatch, + device_str: str, + num_envs: int, + num_bodies: int, + controller_cls: str, + cfg_factory, + mod_name, +): + """Controllers return finite (N, 6) wrench on zero state and counter gravity on +Z. + + Tests various configurations of number of environments and bodies to catch edge cases. + """ + device = _device_param(device_str) + _patch_aggregate(monkeypatch, mod_name, num_envs, device) + _patch_sim_context(monkeypatch, mod_name) + robot = _DummyRobot(num_envs, num_bodies, device) + + cfg = cfg_factory() + controller = getattr(mod_name, controller_cls)(cfg, robot, num_envs=num_envs, device=str(device)) + + command = torch.zeros((num_envs, 4), device=device) + + wrench = controller.compute(command) + + assert wrench.shape == (num_envs, 6), f"Expected shape ({num_envs}, 6), got {wrench.shape}" + assert torch.isfinite(wrench).all(), "Wrench contains non-finite values" + assert torch.all(wrench[:, 2] > 0.0), "Body-z force should oppose gravity" + + +@pytest.mark.parametrize("device_str", ["cpu", "cuda"]) +@pytest.mark.parametrize("num_envs", [1, 2, 8]) +@pytest.mark.parametrize("num_bodies", [1, 4]) +def test_lee_vel_randomize_params_within_bounds( + monkeypatch: pytest.MonkeyPatch, device_str: str, num_envs: int, num_bodies: int +): + """Randomized gains stay within configured ranges for velocity controller. + + Tests edge cases with single and multiple environments and bodies. + """ + device = _device_param(device_str) + _patch_aggregate(monkeypatch, vel_mod, num_envs, device) + _patch_sim_context(monkeypatch, vel_mod) + robot = _DummyRobot(num_envs, num_bodies, device) + + cfg = _create_vel_cfg() + controller = vel_mod.LeeVelController(cfg, robot, num_envs=num_envs, device=str(device)) + + controller.reset_idx(env_ids=None) + + # Ensure tensors are on the correct device + K_vel_min = torch.tensor(cfg.K_vel_range[0], device=device, dtype=torch.float32) + K_vel_max = torch.tensor(cfg.K_vel_range[1], device=device, dtype=torch.float32) + + # Move controller gains to same device if needed + K_vel_current = controller.K_vel_current.to(device) + + assert K_vel_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_vel_current.shape}" + assert torch.all(K_vel_current >= K_vel_min), f"K_vel below minimum: {K_vel_current.min()} < {K_vel_min.min()}" + assert torch.all(K_vel_current <= K_vel_max), f"K_vel above maximum: {K_vel_current.max()} > {K_vel_max.max()}" + + +@pytest.mark.parametrize("device_str", ["cpu", "cuda"]) +@pytest.mark.parametrize("num_envs", [1, 2, 8]) +@pytest.mark.parametrize("num_bodies", [1, 4]) +def test_lee_pos_randomize_params_within_bounds( + monkeypatch: pytest.MonkeyPatch, device_str: str, num_envs: int, num_bodies: int +): + """Randomized gains stay within configured ranges for position controller. + + Tests edge cases with single and multiple environments and bodies. + """ + device = _device_param(device_str) + _patch_aggregate(monkeypatch, pos_mod, num_envs, device) + _patch_sim_context(monkeypatch, pos_mod) + robot = _DummyRobot(num_envs, num_bodies, device) + + cfg = _create_pos_cfg() + controller = pos_mod.LeePosController(cfg, robot, num_envs=num_envs, device=str(device)) + + controller.reset_idx(env_ids=None) + + # Check K_pos gains + K_pos_min = torch.tensor(cfg.K_pos_range[0], device=device, dtype=torch.float32) + K_pos_max = torch.tensor(cfg.K_pos_range[1], device=device, dtype=torch.float32) + K_pos_current = controller.K_pos_current.to(device) + + assert K_pos_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_pos_current.shape}" + assert torch.all(K_pos_current >= K_pos_min), f"K_pos below minimum: {K_pos_current.min()} < {K_pos_min.min()}" + assert torch.all(K_pos_current <= K_pos_max), f"K_pos above maximum: {K_pos_current.max()} > {K_pos_max.max()}" + + +@pytest.mark.parametrize("device_str", ["cpu", "cuda"]) +@pytest.mark.parametrize("num_envs", [1, 2, 8]) +@pytest.mark.parametrize("num_bodies", [1, 4]) +def test_lee_acc_randomize_params_within_bounds( + monkeypatch: pytest.MonkeyPatch, device_str: str, num_envs: int, num_bodies: int +): + """Randomized gains stay within configured ranges for acceleration controller. + + Tests edge cases with single and multiple environments and bodies. + """ + device = _device_param(device_str) + _patch_aggregate(monkeypatch, acc_mod, num_envs, device) + _patch_sim_context(monkeypatch, acc_mod) + robot = _DummyRobot(num_envs, num_bodies, device) + + cfg = _create_acc_cfg() + controller = acc_mod.LeeAccController(cfg, robot, num_envs=num_envs, device=str(device)) + + controller.reset_idx(env_ids=None) + + # Check K_rot gains + K_rot_min = torch.tensor(cfg.K_rot_range[0], device=device, dtype=torch.float32) + K_rot_max = torch.tensor(cfg.K_rot_range[1], device=device, dtype=torch.float32) + K_rot_current = controller.K_rot_current.to(device) + + assert K_rot_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_rot_current.shape}" + assert torch.all(K_rot_current >= K_rot_min), f"K_rot below minimum: {K_rot_current.min()} < {K_rot_min.min()}" + assert torch.all(K_rot_current <= K_rot_max), f"K_rot above maximum: {K_rot_current.max()} > {K_rot_max.max()}" + + # Check K_angvel gains + K_angvel_min = torch.tensor(cfg.K_angvel_range[0], device=device, dtype=torch.float32) + K_angvel_max = torch.tensor(cfg.K_angvel_range[1], device=device, dtype=torch.float32) + K_angvel_current = controller.K_angvel_current.to(device) + + assert K_angvel_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_angvel_current.shape}" + assert torch.all(K_angvel_current >= K_angvel_min), ( + f"K_angvel below minimum: {K_angvel_current.min()} < {K_angvel_min.min()}" + ) + assert torch.all(K_angvel_current <= K_angvel_max), ( + f"K_angvel above maximum: {K_angvel_current.max()} > {K_angvel_max.max()}" + ) + + +@pytest.mark.parametrize("device_str", ["cpu", "cuda"]) +@pytest.mark.parametrize("num_envs", [1, 2, 8]) +@pytest.mark.parametrize("num_bodies", [1, 4]) +def test_lee_att_randomize_params_within_bounds( + monkeypatch: pytest.MonkeyPatch, device_str: str, num_envs: int, num_bodies: int +): + """Randomized gains stay within configured ranges for attitude controller. + + Tests edge cases with single and multiple environments and bodies. + """ + device = _device_param(device_str) + _patch_aggregate(monkeypatch, att_mod, num_envs, device) + _patch_sim_context(monkeypatch, att_mod) + robot = _DummyRobot(num_envs, num_bodies, device) + + cfg = _create_att_cfg() + controller = att_mod.LeeAttController(cfg, robot, num_envs=num_envs, device=str(device)) + + controller.reset_idx(env_ids=None) + + # Check K_rot gains + K_rot_min = torch.tensor(cfg.K_rot_range[0], device=device, dtype=torch.float32) + K_rot_max = torch.tensor(cfg.K_rot_range[1], device=device, dtype=torch.float32) + K_rot_current = controller.K_rot_current.to(device) + + assert K_rot_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_rot_current.shape}" + assert torch.all(K_rot_current >= K_rot_min), f"K_rot below minimum: {K_rot_current.min()} < {K_rot_min.min()}" + assert torch.all(K_rot_current <= K_rot_max), f"K_rot above maximum: {K_rot_current.max()} > {K_rot_max.max()}" + + # Check K_angvel gains + K_angvel_min = torch.tensor(cfg.K_angvel_range[0], device=device, dtype=torch.float32) + K_angvel_max = torch.tensor(cfg.K_angvel_range[1], device=device, dtype=torch.float32) + K_angvel_current = controller.K_angvel_current.to(device) + + assert K_angvel_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_angvel_current.shape}" + assert torch.all(K_angvel_current >= K_angvel_min), ( + f"K_angvel below minimum: {K_angvel_current.min()} < {K_angvel_min.min()}" + ) + assert torch.all(K_angvel_current <= K_angvel_max), ( + f"K_angvel above maximum: {K_angvel_current.max()} > {K_angvel_max.max()}" + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/__init__.pyi index d54142b0609d..3085421b05d1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/__init__.pyi +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/__init__.pyi @@ -3,19 +3,5 @@ # # SPDX-License-Identifier: BSD-3-Clause -__all__ = [ - "DroneUniformPoseCommand", - "DroneUniformPoseCommandCfg", - "base_roll_pitch", - "generated_drone_commands", - "ang_vel_xyz_exp", - "distance_to_goal_exp", - "lin_vel_xyz_exp", - "yaw_aligned", -] - -from .commands import DroneUniformPoseCommand, DroneUniformPoseCommandCfg -from .observations import base_roll_pitch, generated_drone_commands -from .rewards import ang_vel_xyz_exp, distance_to_goal_exp, lin_vel_xyz_exp, yaw_aligned from isaaclab.envs.mdp import * from isaaclab_contrib.mdp import * diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/commands/drone_pose_command.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/commands/drone_pose_command.py index a9072367c83b..5765177a4659 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/commands/drone_pose_command.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/commands/drone_pose_command.py @@ -42,7 +42,7 @@ def _update_metrics(self): ) # compute the error pos_error, rot_error = compute_pose_error( - # Sub-terrain shift for correct position error calculation @grzemal + # Sub-terrain shift for correct position error calculation self.pose_command_b[:, :3] + self._env.scene.env_origins, self.pose_command_w[:, 3:], self.robot.data.body_pos_w.torch[:, self.body_idx], @@ -58,7 +58,7 @@ def _debug_vis_callback(self, event): return # update the markers # -- goal pose - # Sub-terrain shift for visualization purposes @grzemal + # Sub-terrain shift for visualization purposes self.goal_pose_visualizer.visualize( self.pose_command_b[:, :3] + self._env.scene.env_origins, self.pose_command_b[:, 3:] ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/curriculums.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/curriculums.py new file mode 100644 index 000000000000..3fc33a9328b5 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/curriculums.py @@ -0,0 +1,148 @@ +# 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 + +"""Common curriculum classes for the drone navigation environment. + +The curriculum classes can be passed to the :class:`isaaclab.managers.CurriculumTermCfg` object to enable +the curriculum introduced by the class. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch +import warp as wp + +from isaaclab.managers import ManagerTermBase, SceneEntityCfg +from isaaclab.managers.manager_term_cfg import CurriculumTermCfg + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedRLEnv + + +class ObstacleDensityCurriculum(ManagerTermBase): + """Curriculum that adjusts obstacle density based on performance. + + The difficulty state is stored internally in the class instance, avoiding + the need to store state on the environment object. + + The curriculum tracks per-environment difficulty levels used to control + the number of obstacles spawned in each environment. Difficulty progresses + based on agent performance (successful goal reaching vs. collisions). + + Attributes: + cfg: The configuration of the curriculum term. + _min_difficulty: Minimum difficulty level for obstacle density. + _max_difficulty: Maximum difficulty level for obstacle density. + _difficulty_levels: Tensor of shape (num_envs,) tracking difficulty per environment. + _asset_cfg: Scene entity configuration for the robot. + _command_name: Name of the command to track. + """ + + cfg: CurriculumTermCfg + """The configuration of the curriculum term.""" + + def __init__(self, cfg: CurriculumTermCfg, env: ManagerBasedRLEnv): + """Initialize the curriculum term. + + Args: + cfg: Configuration for the curriculum term. + env: The manager-based RL environment instance. + """ + super().__init__(cfg, env) + + # Extract parameters from config + self._min_difficulty = cfg.params["min_difficulty"] + self._max_difficulty = cfg.params["max_difficulty"] + self._asset_cfg = cfg.params.get("asset_cfg", SceneEntityCfg("robot")) + self._command_name = cfg.params.get("command_name", "target_pose") + + # Initialize difficulty levels for all environments + self._difficulty_levels = torch.ones(env.num_envs, device=env.device) * self._min_difficulty + + def __call__( + self, + env: ManagerBasedRLEnv, + env_ids: Sequence[int], + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + command_name: str = "target_pose", + min_difficulty: int | None = None, + max_difficulty: int | None = None, + ) -> float: + """Update obstacle density curriculum based on performance. + + Args: + env: The manager-based RL environment instance. + env_ids: Environment indices to update. + asset_cfg: Scene entity configuration for the robot. Defaults to SceneEntityCfg("robot"). + command_name: Name of the command to track. Defaults to "target_pose". + max_difficulty: Maximum difficulty level. Defaults to 10. + min_difficulty: Minimum difficulty level. Defaults to 2. + + Returns: + Mean difficulty level across all environments (for logging). + """ + # Extract robot and command + asset: Articulation = env.scene[asset_cfg.name] + command = env.command_manager.get_command(command_name) + + target_position_w = command[:, :3].clone() + current_position = wp.to_torch(asset.data.root_pos_w) - env.scene.env_origins + position_error = torch.norm(target_position_w[env_ids] - current_position[env_ids], dim=1) + + # Decide difficulty changes + crashed = env.termination_manager.terminated[env_ids] + move_up = position_error < 1.5 # Success + move_down = crashed & ~move_up + + # Update difficulty levels + self._difficulty_levels[env_ids] += move_up.long() - move_down.long() + self._difficulty_levels[env_ids] = torch.clamp( + self._difficulty_levels[env_ids], min=self._min_difficulty, max=self._max_difficulty - 1 + ) + + return self._difficulty_levels.float().mean().item() + + @property + def difficulty_levels(self) -> torch.Tensor: + """Get the current difficulty levels for all environments. + + Returns: + Tensor of shape (num_envs,) with difficulty levels. + """ + return self._difficulty_levels + + @property + def min_difficulty(self) -> int: + """Get the minimum difficulty level.""" + return self._min_difficulty + + @property + def max_difficulty(self) -> int: + """Get the maximum difficulty level.""" + return self._max_difficulty + + +def get_obstacle_curriculum_term(env: ManagerBasedRLEnv) -> ObstacleDensityCurriculum | None: + """Get the ObstacleDensityCurriculum instance from the curriculum manager. + + This helper function searches the curriculum manager for an active + ObstacleDensityCurriculum term and returns it if found. This allows + other MDP components (rewards, events) to access the curriculum state. + + Args: + env: The manager-based RL environment instance. + + Returns: + The ObstacleDensityCurriculum instance if found, None otherwise. + """ + curriculum_manager = env.curriculum_manager + for term_cfg in curriculum_manager._term_cfgs: + if isinstance(term_cfg.func, ObstacleDensityCurriculum): + return term_cfg.func + return None diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/events.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/events.py new file mode 100644 index 000000000000..992521fdd47e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/events.py @@ -0,0 +1,189 @@ +# 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 + +"""Event functions specific to the drone ARL environments.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +import isaaclab.utils.math as math_utils +from isaaclab.managers import SceneEntityCfg + +from .curriculums import get_obstacle_curriculum_term + +if TYPE_CHECKING: + from isaaclab.assets import RigidObjectCollection + from isaaclab.envs import ManagerBasedRLEnv + + +def reset_obstacles_with_individual_ranges( + env: ManagerBasedRLEnv, + env_ids: torch.Tensor, + asset_cfg: SceneEntityCfg, + obstacle_configs: dict, + wall_configs: dict, + env_size: tuple[float, float, float], + use_curriculum: bool = True, + min_num_obstacles: int = 1, + max_num_obstacles: int = 10, + ground_offset: float = 0.1, +) -> None: + """Reset obstacle and wall positions for specified environments without collision checking. + + This function repositions all walls and a curriculum-determined subset of obstacles + within the specified environment bounds. + + Walls are positioned at fixed locations based on their configuration ratios. Obstacles + are randomly placed within their designated zones, with the number of active obstacles + determined by the curriculum difficulty level. Inactive obstacles are moved far below + the scene (-1000m in Z) to effectively remove them from the environment. + + The curriculum scaling works as: + num_obstacles = min + (difficulty / max_difficulty) * (max - min) + + Args: + env: The manager-based RL environment instance. + env_ids: Tensor of environment indices to reset. + asset_cfg: Scene entity configuration identifying the obstacle collection. + obstacle_configs: Dictionary mapping obstacle type names to their BoxCfg + configurations, specifying size and placement ranges. + wall_configs: Dictionary mapping wall names to their BoxCfg configurations. + env_size: Tuple of (length, width, height) defining the environment bounds in meters. + use_curriculum: If True, number of obstacles scales with curriculum difficulty. + If False, spawns max_num_obstacles in every environment. Defaults to True. + min_num_obstacles: Minimum number of obstacles to spawn per environment. + Defaults to 1. + max_num_obstacles: Maximum number of obstacles to spawn per environment. + Defaults to 10. + ground_offset: Z-axis offset to prevent obstacles from spawning at z=0. + Defaults to 0.1 meters. + + Note: + This function expects the environment to have `_obstacle_difficulty_levels` and + `_max_obstacle_difficulty` attributes when `use_curriculum=True`. These are + typically set by :func:`obstacle_density_curriculum`. + """ + obstacles: RigidObjectCollection = env.scene[asset_cfg.name] + + num_objects = obstacles.num_objects + num_envs = len(env_ids) + object_names = obstacles.object_names + + # Get difficulty levels per environment + if use_curriculum: + curriculum_term = get_obstacle_curriculum_term(env) + if curriculum_term is not None: + # Get difficulty levels for the specific environments being reset + difficulty_levels = curriculum_term.difficulty_levels[env_ids] + max_difficulty = curriculum_term.max_difficulty + else: + # Fallback: use max obstacles if curriculum not found + difficulty_levels = torch.ones(num_envs, device=env.device) * max_num_obstacles + max_difficulty = max_num_obstacles + else: + difficulty_levels = torch.ones(num_envs, device=env.device) * max_num_obstacles + max_difficulty = max_num_obstacles + + # Calculate active obstacles per env based on difficulty + obstacles_per_env = ( + min_num_obstacles + (difficulty_levels / max_difficulty) * (max_num_obstacles - min_num_obstacles) + ).long() + + # Prepare tensors + all_poses = torch.zeros(num_envs, num_objects, 7, device=env.device) + all_velocities = torch.zeros(num_envs, num_objects, 6, device=env.device) + + wall_names = list(wall_configs.keys()) + obstacle_types = list(obstacle_configs.values()) + env_size_t = torch.tensor(env_size, device=env.device) + + # place walls + for wall_name, wall_cfg in wall_configs.items(): + if wall_name in object_names: + wall_idx = object_names.index(wall_name) + + min_ratio = torch.tensor(wall_cfg.center_ratio_min, device=env.device) + max_ratio = torch.tensor(wall_cfg.center_ratio_max, device=env.device) + + if torch.allclose(min_ratio, max_ratio): + center_ratios = min_ratio.unsqueeze(0).repeat(num_envs, 1) + else: + ratios = torch.rand(num_envs, 3, device=env.device) + center_ratios = ratios * (max_ratio - min_ratio) + min_ratio + + positions = (center_ratios - 0.5) * env_size_t + positions[:, 2] += ground_offset + positions += env.scene.env_origins[env_ids] + + all_poses[:, wall_idx, 0:3] = positions + all_poses[:, wall_idx, 3:7] = torch.tensor([1.0, 0.0, 0.0, 0.0], device=env.device).repeat(num_envs, 1) + + # Get obstacle indices + obstacle_indices = [idx for idx, name in enumerate(object_names) if name not in wall_names] + + if len(obstacle_indices) == 0: + obstacles.write_object_pose_to_sim(all_poses, env_ids=env_ids) + obstacles.write_object_velocity_to_sim(all_velocities, env_ids=env_ids) + return + + # Determine which obstacles are active per env + active_masks = torch.zeros(num_envs, len(obstacle_indices), dtype=torch.bool, device=env.device) + for env_idx in range(num_envs): + num_active = obstacles_per_env[env_idx].item() + perm = torch.randperm(len(obstacle_indices), device=env.device)[:num_active] + active_masks[env_idx, perm] = True + + # place obstacles + for obj_list_idx in range(len(obstacle_indices)): + obj_idx = obstacle_indices[obj_list_idx] + + # Which envs need this obstacle? + envs_need_obstacle = active_masks[:, obj_list_idx] + + if not envs_need_obstacle.any(): + # Move all to -1000 + all_poses[:, obj_idx, 0:3] = env.scene.env_origins[env_ids] + torch.tensor( + [0.0, 0.0, -1000.0], device=env.device + ) + all_poses[:, obj_idx, 3:7] = torch.tensor([1.0, 0.0, 0.0, 0.0], device=env.device) + continue + + # Get obstacle config + config_idx = obj_list_idx % len(obstacle_types) + obs_cfg = obstacle_types[config_idx] + + min_ratio = torch.tensor(obs_cfg.center_ratio_min, device=env.device) + max_ratio = torch.tensor(obs_cfg.center_ratio_max, device=env.device) + + # sample object positions + num_active_envs = envs_need_obstacle.sum().item() + ratios = torch.rand(num_active_envs, 3, device=env.device) + positions = (ratios * (max_ratio - min_ratio) + min_ratio - 0.5) * env_size_t + positions[:, 2] += ground_offset + + # Add env origins + active_env_indices = torch.where(envs_need_obstacle)[0] + positions += env.scene.env_origins[env_ids[active_env_indices]] + + # Generate quaternions + quats = math_utils.random_orientation(num_envs, device=env.device) + + # Write poses + all_poses[envs_need_obstacle, obj_idx, 0:3] = positions + all_poses[envs_need_obstacle, obj_idx, 3:7] = quats[envs_need_obstacle] + + # Move inactive obstacles far away + inactive = ~envs_need_obstacle + all_poses[inactive, obj_idx, 0:3] = env.scene.env_origins[env_ids[inactive]] + torch.tensor( + [0.0, 0.0, -1000.0], device=env.device + ) + all_poses[inactive, obj_idx, 3:7] = torch.tensor([1.0, 0.0, 0.0, 0.0], device=env.device) + + # Write to sim + obstacles.write_object_pose_to_sim(all_poses, env_ids=env_ids) + obstacles.write_object_velocity_to_sim(all_velocities, env_ids=env_ids) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/observations.py index c8b8048bd68b..e084eabcd013 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/observations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/observations.py @@ -11,20 +11,27 @@ from __future__ import annotations +import os from typing import TYPE_CHECKING import torch import isaaclab.utils.math as math_utils -from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import ManagerTermBase, SceneEntityCfg if TYPE_CHECKING: from isaaclab.assets import Articulation from isaaclab.envs import ManagerBasedEnv, ManagerBasedRLEnv + from isaaclab.managers import ObservationTermCfg + from isaaclab.sensors.camera.camera import Camera + from isaaclab.sensors.camera.tiled_camera import TiledCamera + from isaaclab.sensors.ray_caster.multi_mesh_ray_caster_camera import MultiMeshRayCasterCamera + from isaaclab.sensors.ray_caster.ray_caster_camera import RayCasterCamera from isaaclab_contrib.assets import Multirotor from isaaclab.envs.utils.io_descriptors import generic_io_descriptor, record_shape +from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, retrieve_file_path """ State. @@ -57,6 +64,178 @@ def base_roll_pitch(env: ManagerBasedEnv, asset_cfg: SceneEntityCfg = SceneEntit return torch.cat((roll.unsqueeze(-1), pitch.unsqueeze(-1)), dim=-1) +""" +Sensors +""" + + +class ImageLatentObservation(ManagerTermBase): + """Callable observation term that returns VAE latents from camera images. + + This observation term extracts images from a configured camera sensor, normalizes them + based on the data type, and passes them through a pre-trained VAE model to obtain + latent representations. The VAE model is loaded once and cached on the class to avoid + repeated disk loads across all instances. + + The term is designed to work with the Isaac Lab observation manager and integrates + seamlessly with other observation terms in multi-modal observation spaces. + + Attributes: + camera_sensor: The camera sensor to extract images from (TiledCamera, Camera, + RayCasterCamera, or MultiMeshRayCasterCamera). + data_type: Type of data to extract from the sensor (e.g., "distance_to_image_plane"). + convert_perspective_to_orthogonal: Whether to convert perspective depth to orthogonal. + normalize: Whether to normalize images before passing to VAE. + + Example: + To use this in an environment configuration: + + .. code-block:: python + + depth_latent = ObsTerm( + func=mdp.ImageLatentObservation, + params={ + "sensor_cfg": SceneEntityCfg("depth_camera"), + "data_type": "distance_to_image_plane", + "normalize": True, + }, + ) + """ + + _model: torch.jit.ScriptModule | None = None + + def __init__(self, cfg: ObservationTermCfg, env: ManagerBasedRLEnv): + """Initialize the image latent observation term. + + Extracts configuration from cfg.params and caches a reference to the camera sensor + for efficient repeated access during observation collection. + + Args: + cfg: Configuration object containing the observation term configuration, + including params dict with: + - sensor_cfg (SceneEntityCfg): Scene entity config for the camera sensor. + - data_type (str): Data type to extract from the sensor. + - convert_perspective_to_orthogonal (bool, optional): Whether to convert + perspective to orthogonal depth. Defaults to False. + - normalize (bool, optional): Whether to normalize images. Defaults to True. + env: The manager-based RL environment instance. + + Raises: + KeyError: If required params ("sensor_cfg", "data_type") are missing. + RuntimeError: If the specified camera sensor is not found in the scene. + """ + super().__init__(cfg, env) + self.camera_sensor: TiledCamera | Camera | RayCasterCamera | MultiMeshRayCasterCamera = env.scene.sensors[ + cfg.params["sensor_cfg"].name + ] + self.data_type: str = cfg.params["data_type"] + self.convert_perspective_to_orthogonal = bool(cfg.params.get("convert_perspective_to_orthogonal", False)) + self.normalize = bool(cfg.params.get("normalize", True)) + + @classmethod + def _get_model(cls, device): + """Load or retrieve the cached VAE model. + + The model is loaded from disk only once per process and cached on the class. + Subsequent calls return the cached instance, avoiding repeated I/O and model + initialization overhead. + + Args: + device: PyTorch device to load the model onto (e.g., "cpu", "cuda:0"). + + Returns: + Loaded VAE model as a TorchScript ScriptModule, set to evaluation mode. + + Raises: + FileNotFoundError: If the VAE model file cannot be found at the expected path. + RuntimeError: If the model cannot be loaded (e.g., corrupted file). + """ + if cls._model is None: + model_path = os.path.join(ISAACLAB_NUCLEUS_DIR, "Contrib/Drone/vae_model.pt") + download_dir = os.path.join(".pretrained_checkpoints", "drone_arl", "vae_model.pt") + resume_path = retrieve_file_path(model_path, download_dir) + cls._model = torch.jit.load(resume_path, map_location=device) + cls._model.eval() + return cls._model + + def __call__(self, env: ManagerBasedEnv, sensor_cfg: SceneEntityCfg, data_type: str) -> torch.Tensor: + """Compute VAE latents for the current camera frame. + + Extracts images from the camera sensor, applies normalization if configured, + and passes them through the VAE model to obtain latent representations. + + Args: + env: The manager-based environment providing scene and device information. + sensor_cfg: Scene entity config for the camera sensor (unused, already set in __init__). + data_type: Data type to extract from the sensor (unused, already set in __init__). + convert_perspective_to_orthogonal: Whether to convert perspective to orthogonal depth + (unused, already set in __init__). + normalize: Whether to normalize images (unused, already set in __init__). + + Returns: + torch.Tensor: Latent representations from the VAE model. + Shape is determined by the VAE architecture (typically (num_envs, latent_dim)). + + Raises: + ValueError: If data_type is "distance_to_image_plane" but normalize is False, + or if an unsupported data_type is encountered with normalize=True. + RuntimeError: If the VAE model inference fails or tensors have incompatible shapes. + + Notes: + - Images are converted to float16 before passing to the VAE for efficiency. + - Infinity values in depth images are clamped to 10.0 during normalization. + - Very small depth values (< 0.02) are set to -1.0 to indicate invalid regions. + - The parameters (sensor_cfg, data_type, etc.) are ignored here as they are + already stored during initialization. They are included in the signature only + to satisfy the observation manager's parameter validation. + """ + images = self.camera_sensor.data.output[self.data_type].clone() + + if (self.data_type == "distance_to_camera") and self.convert_perspective_to_orthogonal: + images = math_utils.orthogonalize_perspective_depth(images, self.camera_sensor.data.intrinsic_matrices) + + if self.normalize: + if self.data_type == "distance_to_image_plane": + images[images == float("inf")] = 10.0 + images[images == -float("inf")] = 10.0 + images[images > 10.0] = 10.0 + images = images / 10.0 + images[images < 0.02] = -1.0 + else: + raise ValueError(f"Image data type: {self.data_type} not supported") + + vae_model = self._get_model(env.device) + with torch.no_grad(): + latents = vae_model(images.squeeze(-1).half()) + + return latents + + +""" +Actions. +""" + + +@generic_io_descriptor(dtype=torch.float32, observation_type="Action", on_inspect=[record_shape]) +def last_action_navigation(env: ManagerBasedEnv, action_name: str = "velocity_commands") -> torch.Tensor: + """The last processed position/velocity/acceleration commands from the navigation action term. + + This function accesses the position/velocity/acceleration commands (vx, vy, vz, yaw_rate) that + were computed by the NavigationAction term. This avoids duplicating the + action processing logic. + + Args: + env: Manager-based environment providing the action manager. + action_name: Name of the navigation action term. Defaults to "velocity_commands". + + Returns: + torch.Tensor: Shape (num_envs, 4) containing position/velocity/acceleration commands. + """ + action_term = env.action_manager.get_term(action_name) + # Access the velocity_commands property from NavigationAction + return action_term.prev_commands + + """ Commands. """ diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/rewards.py index ce635cc544d8..a691df9e2d1e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/rewards.py @@ -8,10 +8,13 @@ from typing import TYPE_CHECKING import torch +import warp as wp import isaaclab.utils.math as math_utils from isaaclab.managers import SceneEntityCfg +from .curriculums import get_obstacle_curriculum_term + if TYPE_CHECKING: from isaaclab.assets import RigidObject from isaaclab.envs import ManagerBasedRLEnv @@ -49,14 +52,65 @@ def distance_to_goal_exp( asset: RigidObject = env.scene[asset_cfg.name] command = env.command_manager.get_command(command_name) - target_position_w = command[:, :3].clone() current_position = asset.data.root_pos_w.torch - env.scene.env_origins # compute the error - position_error_square = torch.sum(torch.square(target_position_w - current_position), dim=1) + position_error_square = torch.sum(torch.square(command[:, :3] - current_position), dim=1) return torch.exp(-position_error_square / std**2) +def distance_to_goal_exp_curriculum( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + std: float = 1.0, + command_name: str = "target_pose", +) -> torch.Tensor: + """Reward the distance to a goal position using an exponential kernel with curriculum-based scaling. + + This reward extends the basic exponential distance reward by applying a scaling factor + that increases with the obstacle difficulty level. As the curriculum progresses and + obstacle density increases, the reward weight grows to compensate for the added difficulty. + + The scaling weight is computed as: 1.0 + (difficulty_level / max_difficulty), meaning + the reward can scale from 1.0x (at minimum difficulty) to 2.0x (at maximum difficulty). + + Args: + env: The manager-based RL environment instance. + asset_cfg: SceneEntityCfg identifying the asset (defaults to "robot"). + std: Standard deviation used in the exponential kernel; larger values + produce a gentler falloff. Defaults to 1.0. + command_name: Name of the command to read the target pose from the + environment's command manager. The function expects the command + tensor to contain positions in its first three columns. + + Returns: + A 1-D tensor of shape (num_envs,) containing the per-environment weighted + reward values. Values are in [0, weight], where weight varies based on the + current curriculum difficulty level. + + Note: + If no curriculum is active (i.e., ObstacleDensityCurriculum is not found), + the function behaves identically to :func:`distance_to_goal_exp` with weight=1.0. + """ + # extract the used quantities (to enable type-hinting) + asset: RigidObject = env.scene[asset_cfg.name] + command = env.command_manager.get_command(command_name) + + current_position = wp.to_torch(asset.data.root_pos_w) - env.scene.env_origins + + # compute the error + position_error_square = torch.sum(torch.square(command[:, :3] - current_position), dim=1) + + # Get curriculum term and compute weight + curriculum_term = get_obstacle_curriculum_term(env) + if curriculum_term is not None: + weight = 1.0 + curriculum_term.difficulty_levels.float() / float(curriculum_term.max_difficulty) + else: + weight = 1.0 + + return weight * torch.exp(-position_error_square / std**2) + + def ang_vel_xyz_exp( env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), std: float = 1.0 ) -> torch.Tensor: @@ -86,6 +140,60 @@ def ang_vel_xyz_exp( return torch.exp(-ang_vel_squared / std**2) +def velocity_to_goal_reward_curriculum( + env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), command_name: str = "target_pose" +) -> torch.Tensor: + """Reward velocity alignment toward the goal with curriculum-based scaling. + + This reward encourages the agent to move in the direction of the goal by computing + the dot product between the asset's velocity vector and the normalized direction + vector to the goal. A curriculum-based scaling factor is applied that increases + with obstacle difficulty. + + The reward is positive when moving toward the goal, negative when moving away, + and zero when moving perpendicular to the goal direction. The magnitude scales + linearly with speed in the goal direction. + + The scaling weight is computed as: 1.0 + (difficulty_level / max_difficulty), + allowing the reward to scale from 1.0x to 2.0x as difficulty increases. + + Args: + env: The manager-based RL environment instance. + asset_cfg: SceneEntityCfg identifying the asset (defaults to "robot"). + command_name: Name of the command to read the target pose from the + environment's command manager. The function expects the command + tensor to contain positions in its first three columns. + + Returns: + A 1-D tensor of shape (num_envs,) containing the per-environment weighted + reward values. Values can be positive (moving toward goal), negative + (moving away), or zero (perpendicular motion), scaled by the curriculum weight. + + Note: + If no curriculum is active (i.e., ObstacleDensityCurriculum is not found), + the function uses weight=1.0 without curriculum scaling. + """ + # extract the used quantities (to enable type-hinting) + asset: RigidObject = env.scene[asset_cfg.name] + # get the center of the environment + command = env.command_manager.get_command(command_name) + + current_position = wp.to_torch(asset.data.root_pos_w) - env.scene.env_origins + direction_to_goal = command[:, :3] - current_position + direction_to_goal = direction_to_goal / (torch.norm(direction_to_goal, dim=1, keepdim=True) + 1e-8) + # compute the reward as the dot product between the velocity and the direction to the goal + velocity_towards_goal = torch.sum(wp.to_torch(asset.data.root_lin_vel_w) * direction_to_goal, dim=1) + + # Get curriculum term and compute weight + curriculum_term = get_obstacle_curriculum_term(env) + if curriculum_term is not None: + weight = 1.0 + curriculum_term.difficulty_levels.float() / float(curriculum_term.max_difficulty) + else: + weight = 1.0 + + return weight * velocity_towards_goal + + def lin_vel_xyz_exp( env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), std: float = 1.0 ) -> torch.Tensor: diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/__init__.py new file mode 100644 index 000000000000..a2a3a87e6232 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/__init__.py @@ -0,0 +1,6 @@ +# 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 + +"""Drone NTNU navigation environments.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/__init__.py new file mode 100644 index 000000000000..61d66a54093c --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/__init__.py @@ -0,0 +1,6 @@ +# 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 + +"""Configurations for drone NTNU navigation environments.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/__init__.py new file mode 100644 index 000000000000..7e7984260403 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/__init__.py @@ -0,0 +1,36 @@ +# 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 + +import gymnasium as gym + +from . import agents + +## +# Register Gym environments. +## + +gym.register( + id="Isaac-Navigation-3DObstacles-ARL-Robot-1-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.floating_obstacles_env_cfg:FloatingObstacleEnvCfg", + "rl_games_cfg_entry_point": f"{agents.__name__}:rl_games_rough_ppo_cfg.yaml", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:NavigationEnvPPORunnerCfg", + "skrl_cfg_entry_point": f"{agents.__name__}:skrl_rough_ppo_cfg.yaml", + }, +) + +gym.register( + id="Isaac-Navigation-3DObstacles-ARL-Robot-1-Play-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.floating_obstacles_env_cfg:FloatingObstacleEnvCfg_PLAY", + "rl_games_cfg_entry_point": f"{agents.__name__}:rl_games_rough_ppo_cfg.yaml", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:NavigationEnvPPORunnerCfg", + "skrl_cfg_entry_point": f"{agents.__name__}:skrl_rough_ppo_cfg.yaml", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/__init__.py new file mode 100644 index 000000000000..460a30569089 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/__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_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rl_games_rough_ppo_cfg.yaml b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rl_games_rough_ppo_cfg.yaml new file mode 100644 index 000000000000..078d89875f77 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rl_games_rough_ppo_cfg.yaml @@ -0,0 +1,87 @@ +# 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 + +params: + seed: 42 + + # environment wrapper clipping + env: + clip_actions: 1.0 + algo: + name: a2c_continuous + + model: + name: continuous_a2c_logstd + + network: + name: actor_critic + separate: False + space: + continuous: + mu_activation: None + sigma_activation: None + mu_init: + name: default + sigma_init: + name: const_initializer + val: 0 + fixed_sigma: True + mlp: + units: [256,128,64] + d2rl: False + activation: elu + initializer: + name: default + scale: 2 + rnn: + name: gru + units: 64 + layers: 1 + # before_mlp: False + # layer_norm: True + config: + name: arl_robot_1_navigation + env_name: rlgpu + device: 'cuda:0' + device_name: 'cuda:0' + env_config: + num_envs: 8192 + + reward_shaper: + # min_val: -1 + scale_value: 0.1 + + normalize_advantage: True + gamma: 0.98 + tau: 0.95 + ppo: True + learning_rate: 1e-4 + lr_schedule: adaptive + kl_threshold: 0.016 + save_best_after: 10 + score_to_win: 100000 + grad_norm: 1.0 + entropy_coef: 0 + truncate_grads: True + e_clip: 0.2 + clip_value: False + num_actors: 1024 + horizon_length: 32 + minibatch_size: 2048 + mini_epochs: 4 + critic_coef: 2 + normalize_input: True + bounds_loss_coef: 0.0001 + max_epochs: 1500 + normalize_value: True + use_diagnostics: True + value_bootstrap: True + #weight_decay: 0.0001 + use_smooth_clamp: False + + player: + render: True + deterministic: True + games_num: 100000 diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py new file mode 100644 index 000000000000..aeddab56e5f8 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py @@ -0,0 +1,37 @@ +# 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 isaaclab.utils import configclass + +from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg + + +@configclass +class NavigationEnvPPORunnerCfg(RslRlOnPolicyRunnerCfg): + num_steps_per_env = 24 + max_iterations = 1500 + save_interval = 50 + experiment_name = "arl_robot_1_navigation" + empirical_normalization = False + policy = RslRlPpoActorCriticCfg( + init_noise_std=0.5, + actor_hidden_dims=[256, 128, 64], + critic_hidden_dims=[256, 128, 64], + activation="elu", + ) + algorithm = RslRlPpoAlgorithmCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.001, + num_learning_epochs=4, + num_mini_batches=4, + learning_rate=4.0e-4, + schedule="adaptive", + gamma=0.98, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/skrl_rough_ppo_cfg.yaml b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/skrl_rough_ppo_cfg.yaml new file mode 100644 index 000000000000..c1465d64bef5 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/skrl_rough_ppo_cfg.yaml @@ -0,0 +1,95 @@ +# 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 + +seed: 42 + + +# Models are instantiated using skrl's model instantiator utility +# https://skrl.readthedocs.io/en/latest/api/utils/model_instantiators.html +models: + separate: False + policy: + class: GaussianMixin + clip_actions: False + clip_log_std: True + min_log_std: -20.0 + max_log_std: 2.0 + initial_log_std: 0.0 + network: + - name: mlp + input: STATES + layers: [256, 128, 64] + activations: elu + - name: gru + input: mlp + type: GRU + layers: [64] + num_layers: 1 + output: ACTIONS + value: + class: DeterministicMixin + clip_actions: False + network: + - name: mlp + input: STATES + layers: [256, 128, 64] + activations: elu + - name: gru + input: mlp + type: GRU + layers: [64] + num_layers: 1 + output: ONE + + +# Rollout memory +# https://skrl.readthedocs.io/en/latest/api/memories/random.html +memory: + class: RandomMemory + memory_size: -1 # automatically determined (same as agent:rollouts) + + +# PPO agent configuration (field names are from PPO_DEFAULT_CONFIG) +# https://skrl.readthedocs.io/en/latest/api/agents/ppo.html +agent: + class: PPO + rollouts: 24 + learning_epochs: 5 + mini_batches: 4 + discount_factor: 0.99 + lambda: 0.95 + learning_rate: 1.0e-03 + learning_rate_scheduler: KLAdaptiveLR + learning_rate_scheduler_kwargs: + kl_threshold: 0.01 + state_preprocessor: null + state_preprocessor_kwargs: null + value_preprocessor: RunningStandardScaler + value_preprocessor_kwargs: null + random_timesteps: 0 + learning_starts: 0 + grad_norm_clip: 1.0 + ratio_clip: 0.2 + value_clip: 0.2 + clip_predicted_values: True + entropy_loss_scale: 0.005 + value_loss_scale: 1.0 + kl_threshold: 0.0 + rewards_shaper_scale: 0.6 + time_limit_bootstrap: False + # logging and checkpoint + experiment: + directory: "arl_robot_1_navigation" + experiment_name: "" + write_interval: auto + checkpoint_interval: auto + + +# Sequential trainer +# https://skrl.readthedocs.io/en/latest/api/trainers/sequential.html +trainer: + class: SequentialTrainer + timesteps: 36000 + environment_info: log diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/floating_obstacles_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/floating_obstacles_env_cfg.py new file mode 100644 index 000000000000..1990750eefc0 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/floating_obstacles_env_cfg.py @@ -0,0 +1,41 @@ +# 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 + +## +# Pre-defined configs +## +from isaaclab.utils import configclass + +from isaaclab_tasks.manager_based.drone_arl.navigation.config.arl_robot_1.navigation_env_cfg import ( + NavigationVelocityFloatingObstacleEnvCfg, +) + +from isaaclab_assets.robots.arl_robot_1 import ARL_ROBOT_1_CFG + + +@configclass +class FloatingObstacleEnvCfg(NavigationVelocityFloatingObstacleEnvCfg): + def __post_init__(self): + # post init of parent + super().__post_init__() + # switch robot to arl_robot_1 + self.scene.robot = ARL_ROBOT_1_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + self.scene.robot.actuators["thrusters"].dt = self.sim.dt + + +@configclass +class FloatingObstacleEnvCfg_PLAY(FloatingObstacleEnvCfg): + def __post_init__(self): + # post init of parent + super().__post_init__() + + self.curriculum.obstacle_levels.params["max_difficulty"] = 40 + self.curriculum.obstacle_levels.params["min_difficulty"] = 39 + + # disable randomization for play + self.observations.policy.enable_corruption = False + # remove random pushing event + self.events.base_external_force_torque = None + self.events.push_robot = None diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/navigation_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/navigation_env_cfg.py new file mode 100644 index 000000000000..6b4039e254a0 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/navigation_env_cfg.py @@ -0,0 +1,344 @@ +# 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 + +import logging +import math +from dataclasses import MISSING + +from isaaclab_physx.physics import PhysxCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +from isaaclab.managers import CurriculumTermCfg as CurrTerm +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors import ContactSensorCfg +from isaaclab.sensors.ray_caster.multi_mesh_ray_caster_camera_cfg import MultiMeshRayCasterCameraCfg +from isaaclab.sensors.ray_caster.patterns import PinholeCameraPatternCfg +from isaaclab.utils import configclass +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.noise import UniformNoiseCfg as Unoise + +from isaaclab_contrib.assets import MultirotorCfg +from isaaclab_contrib.controllers import LeeVelControllerCfg + +import isaaclab_tasks.manager_based.drone_arl.mdp as mdp +from isaaclab_tasks.manager_based.drone_arl.mdp.commands import DroneUniformPoseCommandCfg +from isaaclab_tasks.manager_based.drone_arl.mdp.curriculums import ObstacleDensityCurriculum +from isaaclab_tasks.manager_based.drone_arl.mdp.events import reset_obstacles_with_individual_ranges +from isaaclab_tasks.manager_based.drone_arl.mdp.observations import ( + ImageLatentObservation, + base_roll_pitch, + generated_drone_commands, + last_action_navigation, +) +from isaaclab_tasks.manager_based.drone_arl.mdp.rewards import ( + distance_to_goal_exp_curriculum, + velocity_to_goal_reward_curriculum, +) + +logging.getLogger("isaaclab.sensors.ray_caster.multi_mesh_ray_caster").setLevel(logging.WARNING) + +## +# Pre-defined configs +## +from .scenes.obstacle_scenes.obstacle_scene import ( + OBSTACLE_SCENE_CFG, + generate_obstacle_collection, +) + + +## +# Scene definition +## +@configclass +class ArlNavigationSceneCfg(InteractiveSceneCfg): + """Scene configuration for drone navigation with obstacles.""" + + # obstacles + object_collection = generate_obstacle_collection(OBSTACLE_SCENE_CFG) + + # robots + robot: MultirotorCfg = MISSING + + # sensors + depth_camera = MultiMeshRayCasterCameraCfg( + prim_path="{ENV_REGEX_NS}/Robot/base_link", + mesh_prim_paths=[ + MultiMeshRayCasterCameraCfg.RaycastTargetCfg( + prim_expr=f"{{ENV_REGEX_NS}}/obstacle_{wall_name}", is_shared=False, track_mesh_transforms=True + ) + for wall_name, _ in OBSTACLE_SCENE_CFG.wall_cfgs.items() + ] + + [ + MultiMeshRayCasterCameraCfg.RaycastTargetCfg( + prim_expr=f"{{ENV_REGEX_NS}}/obstacle_{i}", is_shared=False, track_mesh_transforms=True + ) + for i in range(OBSTACLE_SCENE_CFG.max_num_obstacles) + ], + offset=MultiMeshRayCasterCameraCfg.OffsetCfg( + pos=(0.15, 0.0, 0.04), rot=(1.0, 0.0, 0.0, 0.0), convention="world" + ), + update_period=0.1, + pattern_cfg=PinholeCameraPatternCfg( + width=480, height=270, focal_length=0.193, horizontal_aperture=0.36, vertical_aperture=0.21 + ), + data_types=["distance_to_image_plane"], + max_distance=10.0, + depth_clipping_behavior="max", + ) + + contact_forces = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Robot/.*", + update_period=0.0, + history_length=10, + debug_vis=False, + ) + # lights + sky_light = AssetBaseCfg( + prim_path="/World/skyLight", + spawn=sim_utils.DomeLightCfg( + intensity=750.0, + texture_file=f"{ISAAC_NUCLEUS_DIR}/Materials/Textures/Skies/PolyHaven/kloofendal_43d_clear_puresky_4k.hdr", + ), + ) + + +## +# MDP settings +## + + +@configclass +class CommandsCfg: + """Command specifications for the MDP.""" + + target_pose = DroneUniformPoseCommandCfg( + asset_name="robot", + body_name="base_link", + resampling_time_range=(10.0, 10.0), + debug_vis=True, + ranges=DroneUniformPoseCommandCfg.Ranges( + pos_x=(4.0, 5.0), + pos_y=(-3.0, 3.0), + pos_z=(1.0, 5.0), + roll=(-0.0, 0.0), + pitch=(-0.0, 0.0), + yaw=(-0.0, 0.0), + ), + ) + + +@configclass +class ActionsCfg: + """Action specifications for the MDP.""" + + velocity_commands = mdp.NavigationActionCfg( + asset_name="robot", + scale=1.0, + offset=0.0, + preserve_order=False, + use_default_offset=False, + controller_cfg=LeeVelControllerCfg( + K_vel_range=((2.5, 2.5, 1.5), (3.5, 3.5, 2.0)), + K_rot_range=((1.6, 1.6, 0.25), (1.85, 1.85, 0.4)), + K_angvel_range=((0.4, 0.4, 0.075), (0.5, 0.5, 0.09)), + max_inclination_angle_rad=1.0471975511965976, + max_yaw_rate=1.0471975511965976, + ), + max_magnitude=2.0, + max_yaw_command=3.14 / 3.0, + max_inclination_angle=3.14 / 4.0, + ) + + +@configclass +class ObservationsCfg: + """Observation specifications for the MDP.""" + + @configclass + class PolicyCfg(ObsGroup): + """Observations for policy group.""" + + # observation terms (order preserved) + base_link_position = ObsTerm( + func=generated_drone_commands, + params={"command_name": "target_pose", "asset_cfg": SceneEntityCfg("robot")}, + noise=Unoise(n_min=-0.1, n_max=0.1), + ) + base_roll_pitch = ObsTerm(func=base_roll_pitch, noise=Unoise(n_min=-0.1, n_max=0.1)) + base_lin_vel = ObsTerm(func=mdp.base_lin_vel, noise=Unoise(n_min=-0.1, n_max=0.1)) + base_ang_vel = ObsTerm(func=mdp.base_ang_vel, noise=Unoise(n_min=-0.1, n_max=0.1)) + last_action = ObsTerm( + func=last_action_navigation, + params={"action_name": "velocity_commands"}, + ) + depth_latent = ObsTerm( + func=ImageLatentObservation, + params={"sensor_cfg": SceneEntityCfg("depth_camera"), "data_type": "distance_to_image_plane"}, + ) + + def __post_init__(self): + self.enable_corruption = False + self.concatenate_terms = True + + # observation groups + policy: PolicyCfg = PolicyCfg() + + +@configclass +class EventCfg: + """Configuration for events.""" + + # reset + + reset_base = EventTerm( + func=mdp.reset_root_state_uniform, + mode="reset", + params={ + "pose_range": { + "x": (-5.0, -4.5), + "y": (-3.0, 3.0), + "z": (1.0, 5.0), + "yaw": (-math.pi / 6.0, math.pi / 6.0), + }, + "velocity_range": { + "x": (-0.2, 0.2), + "y": (-0.2, 0.2), + "z": (-0.2, 0.2), + "roll": (-0.2, 0.2), + "pitch": (-0.2, 0.2), + "yaw": (-0.2, 0.2), + }, + }, + ) + + reset_obstacles = EventTerm( + func=reset_obstacles_with_individual_ranges, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("object_collection"), + "obstacle_configs": OBSTACLE_SCENE_CFG.obstacle_cfgs, + "wall_configs": OBSTACLE_SCENE_CFG.wall_cfgs, + "env_size": OBSTACLE_SCENE_CFG.env_size, + "use_curriculum": True, + "min_num_obstacles": OBSTACLE_SCENE_CFG.min_num_obstacles, + "max_num_obstacles": OBSTACLE_SCENE_CFG.max_num_obstacles, + "ground_offset": OBSTACLE_SCENE_CFG.ground_offset, + }, + ) + + +@configclass +class RewardsCfg: + """Reward terms for the MDP.""" + + goal_dist_exp1 = RewTerm( + func=distance_to_goal_exp_curriculum, + weight=2.0, + params={ + "asset_cfg": SceneEntityCfg("robot"), + "std": 7.0, + "command_name": "target_pose", + }, + ) + goal_dist_exp2 = RewTerm( + func=distance_to_goal_exp_curriculum, + weight=4.0, + params={ + "asset_cfg": SceneEntityCfg("robot"), + "std": 0.5, + "command_name": "target_pose", + }, + ) + velocity_reward = RewTerm( + func=velocity_to_goal_reward_curriculum, + weight=0.5, + params={ + "asset_cfg": SceneEntityCfg("robot"), + "command_name": "target_pose", + }, + ) + action_rate_l2 = RewTerm(func=mdp.action_rate_l2, weight=-0.05) + action_magnitude_l2 = RewTerm(func=mdp.action_l2, weight=-0.05) + + termination_penalty = RewTerm( + func=mdp.is_terminated, + weight=-100.0, + ) + + +@configclass +class TerminationsCfg: + """Termination terms for the MDP.""" + + time_out = DoneTerm(func=mdp.time_out, time_out=True) + collision = DoneTerm( + func=mdp.illegal_contact, + params={"sensor_cfg": SceneEntityCfg("contact_forces", body_names=".*"), "threshold": 1.0}, + time_out=False, + ) + + +@configclass +class CurriculumCfg: + """Curriculum terms for the MDP.""" + + obstacle_levels = CurrTerm( + func=ObstacleDensityCurriculum, + params={ + "asset_cfg": SceneEntityCfg("robot"), + "max_difficulty": 10, + "min_difficulty": 0, + }, + ) + + +## +# Environment configuration +## + + +@configclass +class NavigationVelocityFloatingObstacleEnvCfg(ManagerBasedRLEnvCfg): + """Configuration for the locomotion velocity-tracking environment.""" + + # Scene settings + scene: ArlNavigationSceneCfg = ArlNavigationSceneCfg(num_envs=4096, env_spacing=20.5) + # Basic settings + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + commands: CommandsCfg = CommandsCfg() + # MDP settings + rewards: RewardsCfg = RewardsCfg() + terminations: TerminationsCfg = TerminationsCfg() + events: EventCfg = EventCfg() + curriculum: CurriculumCfg = CurriculumCfg() + + def __post_init__(self): + """Post initialization.""" + # general settings + self.decimation = 10 + self.episode_length_s = 10.0 + # simulation settings + self.sim.dt = 0.01 + self.sim.render_interval = self.decimation + self.sim.physics_material = sim_utils.RigidBodyMaterialCfg( + friction_combine_mode="multiply", + restitution_combine_mode="multiply", + static_friction=1.0, + dynamic_friction=1.0, + ) + self.sim.physics = PhysxCfg(gpu_max_rigid_patch_count=2**21) + # update sensor update periods + # we tick all the sensors based on the smallest update period (physics update period) + if self.scene.contact_forces is not None: + self.scene.contact_forces.update_period = self.sim.dt diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene.py new file mode 100644 index 000000000000..711a9e990742 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene.py @@ -0,0 +1,114 @@ +# 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 + +import numpy as np + +import isaaclab.sim as sim_utils +from isaaclab.assets import RigidObjectCfg, RigidObjectCollectionCfg + +from .obstacle_scene_cfg import ObstaclesSceneCfg + +"""Obstacle scene generation and reset functionality for drone navigation environments. + +This module provides utilities for generating dynamic 3D obstacle courses with walls and +floating obstacles. The obstacle configurations support curriculum learning where difficulty +can be progressively increased by adjusting the number of active obstacles. +""" + +OBSTACLE_SCENE_CFG = ObstaclesSceneCfg( + env_size=(12.0, 8.0, 6.0), + min_num_obstacles=20, + max_num_obstacles=40, + ground_offset=3.0, +) + + +def generate_obstacle_collection(cfg: ObstaclesSceneCfg) -> RigidObjectCollectionCfg: + """Generate a rigid object collection configuration for walls and obstacles. + + Creates a complete scene with boundary walls and a variety of floating obstacles + (panels, cubes, rods, etc.) based on the provided configuration. Each obstacle is + assigned random colors and configured with appropriate physics properties. + + Wall objects are configured with very high mass (10^7 kg) and high damping to remain + stationary during collisions. Obstacle objects have moderate mass (100 kg) to move in the right position if reset + in collision. + + Args: + cfg: Configuration object specifying obstacle types, sizes, quantities, and + positioning constraints. + + Returns: + A RigidObjectCollectionCfg containing all wall and obstacle configurations, + ready to be added to a scene. + + Note: + All obstacles are initially placed at origin [0, 0, 0]. Actual positions are + set during environment reset via :func:`reset_obstacles_with_individual_ranges`. + """ + max_num_obstacles = cfg.max_num_obstacles + + rigid_objects = {} + + for wall_name, wall_cfg in cfg.wall_cfgs.items(): + # Walls get their specific size and default center + default_center = [0.0, 0.0, 0.0] # Will be set properly at reset + color = float(np.random.randint(0, 256, size=1, dtype=np.uint8)) / 255.0 + + rigid_objects[wall_name] = RigidObjectCfg( + prim_path=f"{{ENV_REGEX_NS}}/obstacle_{wall_name}", + spawn=sim_utils.CuboidCfg( + size=wall_cfg.size, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.5, 0.5, color), metallic=0.2), + rigid_props=sim_utils.RigidBodyPropertiesCfg( + solver_position_iteration_count=4, + solver_velocity_iteration_count=0, + disable_gravity=True, + kinematic_enabled=False, + linear_damping=9999.0, + angular_damping=9999.0, + max_linear_velocity=0.0, + max_angular_velocity=0.0, + ), + # mass of walls needs to be way larger than weight of obstacles to make them not move during reset + mass_props=sim_utils.MassPropertiesCfg(mass=10000000.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=tuple(default_center)), + collision_group=0, + ) + + obstacle_types = list(cfg.obstacle_cfgs.values()) + for i in range(max_num_obstacles): + obj_name = f"obstacle_{i}" + obs_cfg = obstacle_types[i % len(obstacle_types)] + + default_center = [0.0, 0.0, 0.0] + color = np.random.randint(0, 256, size=3, dtype=np.uint8) + color_normalized = tuple(float(c) / 255.0 for c in color) + + rigid_objects[obj_name] = RigidObjectCfg( + prim_path=f"{{ENV_REGEX_NS}}/{obj_name}", + spawn=sim_utils.CuboidCfg( + size=obs_cfg.size, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=color_normalized, metallic=0.2), + rigid_props=sim_utils.RigidBodyPropertiesCfg( + solver_position_iteration_count=4, + solver_velocity_iteration_count=0, + disable_gravity=True, + kinematic_enabled=False, + linear_damping=1.0, + angular_damping=1.0, + max_linear_velocity=0.0, + max_angular_velocity=0.0, + ), + mass_props=sim_utils.MassPropertiesCfg(mass=100.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=tuple(default_center)), + collision_group=0, + ) + + return RigidObjectCollectionCfg(rigid_objects=rigid_objects) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene_cfg.py new file mode 100644 index 000000000000..c42610dc10b7 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene_cfg.py @@ -0,0 +1,88 @@ +# 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 dataclasses import MISSING + +from isaaclab.utils import configclass + + +@configclass +class ObstaclesSceneCfg: + """Configuration for a terrain with floating obstacles.""" + + min_num_obstacles: int = 1 + max_num_obstacles: int = 40 + ground_offset: float = 3.0 + + env_size: tuple[float, float, float] = MISSING + + @configclass + class BoxCfg: + """Configuration for a box-shaped obstacle or wall. + + Defines the size and placement constraints for rectangular obstacles within + the environment. The center position is specified as ratios of the environment + size, allowing for flexible scaling. + + Attributes: + size: Tuple of (length, width, height) in meters. + center_ratio_min: Minimum position as ratio of env_size (0.0 to 1.0) for + each axis. Used for random placement bounds. + center_ratio_max: Maximum position as ratio of env_size (0.0 to 1.0) for + each axis. For fixed positions, set equal to center_ratio_min. + """ + + size: tuple[float, float, float] = MISSING + center_ratio_min: tuple[float, float, float] = MISSING + center_ratio_max: tuple[float, float, float] = MISSING + + # Obstacle configurations + panel_obs_cfg = BoxCfg( + size=(0.1, 1.2, 3.0), center_ratio_min=(0.3, 0.05, 0.05), center_ratio_max=(0.85, 0.95, 0.95) + ) + + small_wall_obs_cfg = BoxCfg( + size=(0.1, 0.5, 0.5), center_ratio_min=(0.3, 0.05, 0.05), center_ratio_max=(0.85, 0.9, 0.9) + ) + + big_wall_obs_cfg = BoxCfg( + size=(0.1, 1.0, 1.0), center_ratio_min=(0.3, 0.05, 0.05), center_ratio_max=(0.85, 0.9, 0.9) + ) + + small_cube_obs_cfg = BoxCfg( + size=(0.4, 0.4, 0.4), center_ratio_min=(0.3, 0.05, 0.05), center_ratio_max=(0.85, 0.9, 0.9) + ) + + rod_obs_cfg = BoxCfg(size=(0.1, 0.1, 2.0), center_ratio_min=(0.3, 0.05, 0.05), center_ratio_max=(0.85, 0.9, 0.9)) + + # Wall configurations + left_wall_cfg = BoxCfg(size=(12.0, 0.2, 6.0), center_ratio_min=(0.5, 1.0, 0.5), center_ratio_max=(0.5, 1.0, 0.5)) + + right_wall_cfg = BoxCfg(size=(12.0, 0.2, 6.0), center_ratio_min=(0.5, 0.0, 0.5), center_ratio_max=(0.5, 0.0, 0.5)) + + back_wall_cfg = BoxCfg(size=(0.2, 8.0, 6.0), center_ratio_min=(0.0, 0.5, 0.5), center_ratio_max=(0.0, 0.5, 0.5)) + + front_wall_cfg = BoxCfg(size=(0.2, 8.0, 6.0), center_ratio_min=(1.0, 0.5, 0.5), center_ratio_max=(1.0, 0.5, 0.5)) + + top_wall_cfg = BoxCfg(size=(12.0, 8.0, 0.2), center_ratio_min=(0.5, 0.5, 1.0), center_ratio_max=(0.5, 0.5, 1.0)) + + bottom_wall_cfg = BoxCfg(size=(12.0, 8.0, 0.2), center_ratio_min=(0.5, 0.5, 0.0), center_ratio_max=(0.5, 0.5, 0.0)) + + wall_cfgs = { + "left_wall": left_wall_cfg, + "right_wall": right_wall_cfg, + "back_wall": back_wall_cfg, + "front_wall": front_wall_cfg, + "bottom_wall": bottom_wall_cfg, + "top_wall": top_wall_cfg, + } + + obstacle_cfgs = { + "panel": panel_obs_cfg, + "small_wall": small_wall_obs_cfg, + "big_wall": big_wall_obs_cfg, + "small_cube": small_cube_obs_cfg, + "rod": rod_obs_cfg, + } diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/track_position_state_based_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/track_position_state_based_env_cfg.py index a78e4a151162..de61f13b8a9a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/track_position_state_based_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/track_position_state_based_env_cfg.py @@ -25,13 +25,20 @@ from isaaclab_contrib.assets import MultirotorCfg import isaaclab_tasks.manager_based.drone_arl.mdp as mdp +from isaaclab_tasks.manager_based.drone_arl.mdp.commands import DroneUniformPoseCommandCfg +from isaaclab_tasks.manager_based.drone_arl.mdp.rewards import ( + ang_vel_xyz_exp, + distance_to_goal_exp, + lin_vel_xyz_exp, + yaw_aligned, +) ## # Scene definition ## @configclass -class MySceneCfg(InteractiveSceneCfg): +class ArlTrackPositionStateBasedSceneCfg(InteractiveSceneCfg): """Configuration for the terrain scene with a flying robot.""" # robots @@ -56,12 +63,12 @@ class MySceneCfg(InteractiveSceneCfg): class CommandsCfg: """Command specifications for the MDP.""" - target_pose = mdp.DroneUniformPoseCommandCfg( + target_pose = DroneUniformPoseCommandCfg( asset_name="robot", body_name="base_link", resampling_time_range=(10.0, 10.0), debug_vis=True, - ranges=mdp.DroneUniformPoseCommandCfg.Ranges( + ranges=DroneUniformPoseCommandCfg.Ranges( pos_x=(-0.0, 0.0), pos_y=(-0.0, 0.0), pos_z=(-0.0, 0.0), @@ -149,7 +156,7 @@ class RewardsCfg: """Reward terms for the MDP.""" distance_to_goal_exp = RewTerm( - func=mdp.distance_to_goal_exp, + func=distance_to_goal_exp, weight=25.0, params={ "asset_cfg": SceneEntityCfg("robot"), @@ -163,17 +170,17 @@ class RewardsCfg: params={"asset_cfg": SceneEntityCfg("robot")}, ) yaw_aligned = RewTerm( - func=mdp.yaw_aligned, + func=yaw_aligned, weight=2.0, params={"asset_cfg": SceneEntityCfg("robot"), "std": 1.0}, ) lin_vel_xyz_exp = RewTerm( - func=mdp.lin_vel_xyz_exp, + func=lin_vel_xyz_exp, weight=2.5, params={"asset_cfg": SceneEntityCfg("robot"), "std": 2.0}, ) ang_vel_xyz_exp = RewTerm( - func=mdp.ang_vel_xyz_exp, + func=ang_vel_xyz_exp, weight=10.0, params={"asset_cfg": SceneEntityCfg("robot"), "std": 10.0}, ) @@ -204,7 +211,7 @@ class TrackPositionNoObstaclesEnvCfg(ManagerBasedRLEnvCfg): """Configuration for the state-based drone pose-control environment.""" # Scene settings - scene: MySceneCfg = MySceneCfg(num_envs=4096, env_spacing=2.5) + scene: ArlTrackPositionStateBasedSceneCfg = ArlTrackPositionStateBasedSceneCfg(num_envs=4096, env_spacing=2.5) # Basic settings observations: ObservationsCfg = ObservationsCfg() actions: ActionsCfg = ActionsCfg() From 8d9af88f0041ad53a5188e9036ee7d15795ae62c Mon Sep 17 00:00:00 2001 From: myurasov-nv <168484206+myurasov-nv@users.noreply.github.com> Date: Fri, 8 May 2026 12:21:07 -0700 Subject: [PATCH 026/133] Fixes various installation bugs (#5530) # Description Fixed bugs: - [NVBug 6122918] - Grammar in installation docs (``these dependency`` -> ``these dependencies``; drop redundant ``guides`` after ``:ref:`how-to```). - [NVBug 6125106] - Add an ``Environment setup`` section to the kit-less install page so it does not jump from ``git clone`` straight to ``./isaaclab.sh --install``. - [NVBug 6125054] - Relax ``starlette==0.49.1`` -> ``>=0.46.0,<0.50`` so ``isaaclab[isaacsim,all]==3.0.0`` resolves alongside ``isaacsim==6.0.0.0`` (transitively requires ``starlette<0.49.0``). - [NVBug 6122885] - Bump recommended driver versions on the installation index to the Beta2 POR (Linux ``580.95.05``, Spark ``580.142``, Windows ``581.42.00``). ## Type of change - Bug fix (non-breaking change which fixes an issue) - 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 - [x] I have added a changelog fragment under ``source//changelog.d/`` for every touched package (do **not** edit ``CHANGELOG.rst`` or bump ``extension.toml`` -- CI handles that) - [x] I have added my name to the ``CONTRIBUTORS.md`` or my name already exists there --------- Co-authored-by: Antoine RICHARD --- .../setup/installation/include/src_build_isaaclab.rst | 2 +- .../setup/installation/include/src_verify_isaaclab.rst | 2 +- docs/source/setup/installation/index.rst | 7 +++---- docs/source/setup/installation/kitless_installation.rst | 7 +++++++ source/isaaclab/changelog.d/my-nvbugs-2.rst | 8 ++++++++ source/isaaclab/setup.py | 3 ++- tools/wheel_builder/res/python_packages.toml | 3 ++- 7 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 source/isaaclab/changelog.d/my-nvbugs-2.rst diff --git a/docs/source/setup/installation/include/src_build_isaaclab.rst b/docs/source/setup/installation/include/src_build_isaaclab.rst index ffec4f6cb0b7..3d93e8f3452d 100644 --- a/docs/source/setup/installation/include/src_build_isaaclab.rst +++ b/docs/source/setup/installation/include/src_build_isaaclab.rst @@ -5,7 +5,7 @@ Installation .. code:: bash - # these dependency are needed by robomimic which is not available on Windows + # these dependencies are needed by robomimic which is not available on Windows sudo apt install cmake build-essential On **aarch64** systems (e.g., DGX Spark), Python, OpenGL and X11 development packages are also required. diff --git a/docs/source/setup/installation/include/src_verify_isaaclab.rst b/docs/source/setup/installation/include/src_verify_isaaclab.rst index 38f51100ec5f..4488e30cb7ab 100644 --- a/docs/source/setup/installation/include/src_verify_isaaclab.rst +++ b/docs/source/setup/installation/include/src_verify_isaaclab.rst @@ -94,7 +94,7 @@ We recommend adding ``--headless`` for faster training. isaaclab.bat -p scripts/reinforcement_learning/rsl_rl/train.py --task=Isaac-Velocity-Rough-Anymal-C-v0 --headless Isaac Lab provides the tools you'll need to create your own **Tasks** and **Workflows** for whatever your project needs may be. -Take a look at our :ref:`how-to` guides like :ref:`Adding your own learning Library ` or :ref:`Wrapping Environments ` for details. +Take a look at our :ref:`how-to` like :ref:`Adding your own learning Library ` or :ref:`Wrapping Environments ` for details. .. figure:: /source/_static/setup/isaac_ants_example.jpg :align: center diff --git a/docs/source/setup/installation/index.rst b/docs/source/setup/installation/index.rst index 6d00776ee754..c3c450fc530b 100644 --- a/docs/source/setup/installation/index.rst +++ b/docs/source/setup/installation/index.rst @@ -84,10 +84,9 @@ Drivers other than those recommended on `Omniverse Technical Requirements `_ using the ``.run`` installer. diff --git a/docs/source/setup/installation/kitless_installation.rst b/docs/source/setup/installation/kitless_installation.rst index 4c6768b3dc0a..506a70eeb0bd 100644 --- a/docs/source/setup/installation/kitless_installation.rst +++ b/docs/source/setup/installation/kitless_installation.rst @@ -6,6 +6,13 @@ Kit-less Installation Isaac Lab can be installed and used **without Isaac Sim** using the kit-less mode. This is the fastest way to get started and is ideal for users who only need the Newton physics backend. +.. include:: include/pip_python_virtual_env.rst + +Cloning and installing Isaac Lab +-------------------------------- + +With the virtual environment activated, clone the repository and run the kit-less installer: + .. code-block:: bash # Clone Isaac Lab diff --git a/source/isaaclab/changelog.d/my-nvbugs-2.rst b/source/isaaclab/changelog.d/my-nvbugs-2.rst new file mode 100644 index 000000000000..29bc762d964d --- /dev/null +++ b/source/isaaclab/changelog.d/my-nvbugs-2.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Relaxed the ``starlette`` pin in :mod:`isaaclab` from ``==0.49.1`` to + ``>=0.46.0,<0.50`` so installs of ``isaaclab[isaacsim,all]==3.0.0`` + alongside ``isaacsim==6.0.0.0`` resolve cleanly. The transitive pin + from ``isaacsim-kernel`` -> ``fastapi==0.117.1`` requires + ``starlette<0.49.0``; the previous exact pin was mutually exclusive. diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index 4f54032f6d14..ac556cc7434e 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -42,7 +42,8 @@ # required by omni.replicator.core S3 backend "botocore", # livestream - "starlette==0.49.1", + # range chosen to coexist with isaacsim 6.0 (isaacsim-kernel pulls fastapi==0.117.1 -> starlette<0.49.0) + "starlette>=0.46.0,<0.50", "omniverseclient==2.71.1.7015", # testing "pytest", diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index 285d1cddbc66..9944580034e0 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -28,7 +28,8 @@ pyproject.dependencies.all = [ "pillow==12.1.1", "botocore", # livestream - "starlette==0.49.1", # TODO: update starlette once Isaac Lab be released with Isaac Sim 6.0.0 + # range chosen to coexist with isaacsim 6.0 (isaacsim-kernel pulls fastapi==0.117.1 -> starlette<0.49.0) + "starlette>=0.46.0,<0.50", "omniverseclient==2.71.1.7015", # testing "pytest", From 4687c34ff481481b7aa94575226ca01bdf520840 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 8 May 2026 21:22:55 +0200 Subject: [PATCH 027/133] Replaces fcntl with filelock to avoid windows import issues (#5544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description The `isaaclab.sim.spawners.from_files` module imports `fcntl` unconditionally at module load. `fcntl` is Unix-only, so on Windows the import fails with: ``` ModuleNotFoundError: No module named 'fcntl' ``` This breaks **any** Windows usage of the spawner — including single-GPU runs that never take the lock path. Reproducer reported by a user (IsaacLab `develop` @ `b258e87`, IsaacSim `6.0.0rc41`): ``` python scripts/reinforcement_learning/rl_games/train.py --task=Isaac-Cartpole-v0 --headless --video --video_length 100 --video_interval 500 --max_iterations 5 ``` `fcntl` was introduced in #5032 to serialize USD download/stage composition across distributed ranks (preventing segfaults in `Sdf_CrateFile::_MmapStream::Read` on shared cached USD files). The intent is correct — only the implementation is Unix-only. ## Change - Replace `fcntl.flock` with [`filelock.FileLock`](https://pypi.org/project/filelock/), which uses `fcntl` on POSIX and `msvcrt` on Windows. - Use `contextlib.nullcontext` for the single-rank path so the lock file is only created when actually needed (i.e., `LOCAL_WORLD_SIZE > 1`). - Drop the manual `try/finally` and the `# noqa: SIM115` on the bare `open(...)` — the `with FileLock(...)` form is exception-safe. - Declare `filelock` in `source/isaaclab/setup.py::INSTALL_REQUIRES`. It was previously only transitively available via `transformers` → `huggingface_hub`; making it a direct dep so we don't depend on a transitive chain that could change. The lock semantics are unchanged: an exclusive advisory lock on `/isaaclab_usd_spawn.lock`, held only while `LOCAL_WORLD_SIZE > 1`. Original lock implementation: @ooctipus (#5032) — tagging for review since this changes the locking primitive. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots N/A — import-time failure on Windows; no UI surface. ## 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 (N/A — internal change) - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works — the failure is `import fcntl` at module load on Windows; a regression test would require a Windows CI runner, which IsaacLab does not currently exercise. Locally verified the module imports and `FileLock` resolves to `filelock._unix.UnixFileLock` on Linux (and would resolve to `WindowsFileLock` on Windows). - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/conf.py | 1 + .../antoiner-fix-from-files-windows-filelock.rst | 12 ++++++++++++ .../sim/spawners/from_files/from_files.py | 16 +++++++--------- source/isaaclab/setup.py | 2 ++ 4 files changed, 22 insertions(+), 9 deletions(-) create mode 100644 source/isaaclab/changelog.d/antoiner-fix-from-files-windows-filelock.rst diff --git a/docs/conf.py b/docs/conf.py index 65ad5468d34e..d75475634e30 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -205,6 +205,7 @@ "pinocchio", "nvidia.srl", "flatdict", + "filelock", "IPython", "cv2", "imageio", diff --git a/source/isaaclab/changelog.d/antoiner-fix-from-files-windows-filelock.rst b/source/isaaclab/changelog.d/antoiner-fix-from-files-windows-filelock.rst new file mode 100644 index 000000000000..3ceaeab12231 --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-fix-from-files-windows-filelock.rst @@ -0,0 +1,12 @@ +Fixed +^^^^^ + +* Fixed :mod:`isaaclab.sim.spawners.from_files` failing to import on Windows + due to an unconditional ``import fcntl`` (Unix-only). The distributed-rank + USD spawn lock now uses :class:`filelock.FileLock`, which works on both + Windows and POSIX. + +Changed +^^^^^^^ + +* Added :mod:`filelock` to ``isaaclab`` install requirements. diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index be7bd6e7074e..892d2e78b387 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -5,12 +5,14 @@ from __future__ import annotations -import fcntl import logging import os import tempfile +from contextlib import nullcontext from typing import TYPE_CHECKING +from filelock import FileLock + # deformables only supported on PhysX backend from isaaclab_physx.sim import schemas as schemas_physx from isaaclab_physx.sim.spawners.materials import SurfaceDeformableBodyMaterialCfg @@ -316,10 +318,10 @@ def _spawn_from_usd_file( raise FileNotFoundError(f"USD file not found at path: '{usd_path}'.") if _world_size > 1: - lock_path = os.path.join(tempfile.gettempdir(), "isaaclab_usd_spawn.lock") - lock_fd = open(lock_path, "w") # noqa: SIM115 - fcntl.flock(lock_fd, fcntl.LOCK_EX) - try: + lock = FileLock(os.path.join(tempfile.gettempdir(), "isaaclab_usd_spawn.lock")) + else: + lock = nullcontext() + with lock: if file_status == 2: usd_path = retrieve_file_path(usd_path, force_download=False) stage = get_current_stage() @@ -334,10 +336,6 @@ def _spawn_from_usd_file( ) else: logger.warning(f"A prim already exists at prim path: '{prim_path}'.") - finally: - if _world_size > 1: - fcntl.flock(lock_fd, fcntl.LOCK_UN) - lock_fd.close() # modify variants if hasattr(cfg, "variants") and cfg.variants is not None: diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index ac556cc7434e..d56f73a34225 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -55,6 +55,8 @@ "flaky", "packaging", "psutil", + # cross-platform file locking (used to serialize USD spawn across distributed ranks) + "filelock", # Required by pydantic-core/imgui_bundle on Python 3.12 (Sentinel symbol). "typing_extensions>=4.14.0", "lazy_loader>=0.4", From 1e0e37924cc3c13b75fdc9580ae33ea85dd0383e Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Fri, 8 May 2026 21:20:08 -0700 Subject: [PATCH 028/133] Disables articulation tests in Isaac Sim CI (#5557) # Description articulation tests have been timing out in CI. disabling it to unblock isaac sim MR merging until we figure out why the timeouts are happening. ## 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 - [ ] 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 --- .../disable-articulation-sim-ci.skip | 0 .../test/assets/test_articulation.py | 30 ------------------- .../disable-articulation-sim-ci.skip | 0 .../test/assets/test_articulation.py | 30 ------------------- 4 files changed, 60 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/disable-articulation-sim-ci.skip create mode 100644 source/isaaclab_physx/changelog.d/disable-articulation-sim-ci.skip diff --git a/source/isaaclab_newton/changelog.d/disable-articulation-sim-ci.skip b/source/isaaclab_newton/changelog.d/disable-articulation-sim-ci.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index fdb335291fa5..cd0f2dcb03b9 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -379,7 +379,6 @@ def sim(request): yield sim -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @@ -429,7 +428,6 @@ def test_initialization_floating_base_non_root(sim, num_articulations, device, a articulation.update(sim.cfg.dt) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @@ -480,7 +478,6 @@ def test_initialization_floating_base(sim, num_articulations, device, add_ground articulation.update(sim.cfg.dt) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["panda"]) @@ -538,7 +535,6 @@ def test_initialization_fixed_base(sim, num_articulations, device, articulation_ torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @@ -597,7 +593,6 @@ def test_initialization_fixed_base_single_joint(sim, num_articulations, device, torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["shadow_hand"]) @@ -647,7 +642,6 @@ def test_initialization_hand_with_tendons(sim, num_articulations, device, articu articulation.update(sim.cfg.dt) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @@ -702,7 +696,6 @@ def test_initialization_floating_base_made_fixed_base( torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @@ -749,7 +742,6 @@ def test_initialization_fixed_base_made_floating_base( articulation.update(sim.cfg.dt) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @@ -782,7 +774,6 @@ def test_out_of_range_default_joint_pos(sim, num_articulations, device, add_grou sim.reset() -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["panda"]) def test_out_of_range_default_joint_vel(sim, device, articulation_type): @@ -807,7 +798,6 @@ def test_out_of_range_default_joint_vel(sim, device, articulation_type): sim.reset() -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @@ -918,7 +908,6 @@ def __init__(self, art): assert torch.all(out) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["anymal"]) @@ -1004,7 +993,6 @@ def test_external_force_buffer(sim, num_articulations, device, articulation_type articulation.update(sim.cfg.dt) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["anymal"]) @@ -1063,7 +1051,6 @@ def test_external_force_on_single_body(sim, num_articulations, device, articulat assert articulation.data.root_pos_w.torch[i, 2].item() < 0.2 -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["anymal"]) @@ -1159,7 +1146,6 @@ def test_external_force_on_single_body_at_position(sim, num_articulations, devic assert articulation.data.root_pos_w.torch[i, 2].item() < 0.2 -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["anymal"]) @@ -1220,7 +1206,6 @@ def test_external_force_on_multiple_bodies(sim, num_articulations, device, artic assert articulation.data.root_ang_vel_w.torch[i, 2].item() > 0.1 -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["anymal"]) @@ -1315,7 +1300,6 @@ def test_external_force_on_multiple_bodies_at_position(sim, num_articulations, d assert torch.abs(articulation.data.root_ang_vel_w.torch[i, 2]).item() > 0.1 -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["humanoid"]) @@ -1378,7 +1362,6 @@ def test_loading_gains_from_usd(sim, num_articulations, device, articulation_typ torch.testing.assert_close(articulation.actuators["body"].damping, expected_damping) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @@ -1414,7 +1397,6 @@ def test_setting_gains_from_cfg(sim, num_articulations, device, add_ground_plane torch.testing.assert_close(articulation.actuators["body"].damping, expected_damping) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["humanoid"]) @@ -1448,7 +1430,6 @@ def test_setting_gains_from_cfg_dict(sim, num_articulations, device, articulatio torch.testing.assert_close(articulation.actuators["body"].damping, expected_damping) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("vel_limit_sim", [1e5, None]) @@ -1521,7 +1502,6 @@ def test_setting_velocity_limit_implicit( torch.testing.assert_close(newton_vel_limit, expected_velocity_limit) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("vel_limit_sim", [1e5, None]) @@ -1578,7 +1558,6 @@ def test_setting_velocity_limit_explicit(sim, num_articulations, device, vel_lim torch.testing.assert_close(newton_vel_limit, expected_vel_limit) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("effort_limit_sim", [1e5, None]) @@ -1636,7 +1615,6 @@ def test_setting_effort_limit_implicit( torch.testing.assert_close(newton_effort_limit, expected_effort_limit) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("effort_limit_sim", [1e5, None]) @@ -1703,7 +1681,6 @@ def test_setting_effort_limit_explicit( torch.testing.assert_close(newton_effort_limit, expected_effort_limit) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["humanoid"]) @@ -1748,7 +1725,6 @@ def test_reset(sim, num_articulations, device, articulation_type): assert torch.count_nonzero(articulation._permanent_wrench_composer.out_torque_b.torch) == num_bodies * 3 -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @@ -1789,7 +1765,6 @@ def test_apply_joint_command(sim, num_articulations, device, add_ground_plane, a assert not torch.allclose(articulation.data.joint_pos.torch, joint_pos) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("with_offset", [True, False]) @@ -1915,7 +1890,6 @@ def test_body_root_state(sim, num_articulations, device, with_offset, articulati torch.testing.assert_close(body_com_vel_w, body_link_vel_w) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("with_offset", [True, False]) @@ -2006,7 +1980,6 @@ def test_write_root_state( torch.testing.assert_close(rand_state[..., 7:], articulation.data.root_link_vel_w.torch) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["humanoid"]) def test_setting_articulation_root_prim_path(sim, device, articulation_type): @@ -2026,7 +1999,6 @@ def test_setting_articulation_root_prim_path(sim, device, articulation_type): assert articulation._is_initialized -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["humanoid"]) def test_setting_invalid_articulation_root_prim_path(sim, device, articulation_type): @@ -2045,7 +2017,6 @@ def test_setting_invalid_articulation_root_prim_path(sim, device, articulation_t sim.reset() -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("gravity_enabled", [False]) @@ -2357,7 +2328,6 @@ def _patched_simulate(cls): ) -@pytest.mark.isaacsim_ci @pytest.mark.parametrize("add_ground_plane", [True]) @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) diff --git a/source/isaaclab_physx/changelog.d/disable-articulation-sim-ci.skip b/source/isaaclab_physx/changelog.d/disable-articulation-sim-ci.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index bd015562c4df..227c091a1652 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -204,7 +204,6 @@ def sim(request): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.isaacsim_ci def test_initialization_floating_base_non_root(sim, num_articulations, device, add_ground_plane): """Test initialization for a floating-base with articulation root on a rigid body. @@ -261,7 +260,6 @@ def test_initialization_floating_base_non_root(sim, num_articulations, device, a @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.isaacsim_ci def test_initialization_floating_base(sim, num_articulations, device, add_ground_plane): """Test initialization for a floating-base with articulation root on provided prim path. @@ -318,7 +316,6 @@ def test_initialization_floating_base(sim, num_articulations, device, add_ground @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_initialization_fixed_base(sim, num_articulations, device): """Test initialization for fixed base. @@ -384,7 +381,6 @@ def test_initialization_fixed_base(sim, num_articulations, device): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.isaacsim_ci def test_initialization_fixed_base_single_joint(sim, num_articulations, device, add_ground_plane): """Test initialization for fixed base articulation with a single joint. @@ -449,7 +445,6 @@ def test_initialization_fixed_base_single_joint(sim, num_articulations, device, @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_initialization_hand_with_tendons(sim, num_articulations, device): """Test initialization for fixed base articulated hand with tendons. @@ -504,7 +499,6 @@ def test_initialization_hand_with_tendons(sim, num_articulations, device): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.isaacsim_ci def test_initialization_floating_base_made_fixed_base(sim, num_articulations, device, add_ground_plane): """Test initialization for a floating-base articulation made fixed-base using schema properties. @@ -565,7 +559,6 @@ def test_initialization_floating_base_made_fixed_base(sim, num_articulations, de @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.isaacsim_ci def test_initialization_fixed_base_made_floating_base(sim, num_articulations, device, add_ground_plane): """Test initialization for fixed base made floating-base using schema properties. @@ -618,7 +611,6 @@ def test_initialization_fixed_base_made_floating_base(sim, num_articulations, de @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.isaacsim_ci def test_out_of_range_default_joint_pos(sim, num_articulations, device, add_ground_plane): """Test that the default joint position from configuration is out of range. @@ -648,7 +640,6 @@ def test_out_of_range_default_joint_pos(sim, num_articulations, device, add_grou @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_out_of_range_default_joint_vel(sim, device): """Test that the default joint velocity from configuration is out of range. @@ -674,7 +665,6 @@ def test_out_of_range_default_joint_vel(sim, device): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.isaacsim_ci def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane): """Test write_joint_limits_to_sim API and when default pos falls outside of the new limits. @@ -782,7 +772,6 @@ def __init__(self, art): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_external_force_buffer(sim, num_articulations, device): """Test if external force buffer correctly updates in the force value is zero case. @@ -867,7 +856,6 @@ def test_external_force_buffer(sim, num_articulations, device): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_external_force_on_single_body(sim, num_articulations, device): """Test application of external force on the base of the articulation. @@ -925,7 +913,6 @@ def test_external_force_on_single_body(sim, num_articulations, device): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_external_force_on_single_body_at_position(sim, num_articulations, device): """Test application of external force on the base of the articulation at a given position. @@ -1020,7 +1007,6 @@ def test_external_force_on_single_body_at_position(sim, num_articulations, devic @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_external_force_on_multiple_bodies(sim, num_articulations, device): """Test application of external force on the legs of the articulation. @@ -1080,7 +1066,6 @@ def test_external_force_on_multiple_bodies(sim, num_articulations, device): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_external_force_on_multiple_bodies_at_position(sim, num_articulations, device): """Test application of external force on the legs of the articulation at a given position. @@ -1174,7 +1159,6 @@ def test_external_force_on_multiple_bodies_at_position(sim, num_articulations, d @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_loading_gains_from_usd(sim, num_articulations, device): """Test that gains are loaded from USD file if actuator model has them as None. @@ -1237,7 +1221,6 @@ def test_loading_gains_from_usd(sim, num_articulations, device): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.isaacsim_ci def test_setting_gains_from_cfg(sim, num_articulations, device, add_ground_plane): """Test that gains are loaded from the configuration correctly. @@ -1271,7 +1254,6 @@ def test_setting_gains_from_cfg(sim, num_articulations, device, add_ground_plane @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_setting_gains_from_cfg_dict(sim, num_articulations, device): """Test that gains are loaded from the configuration dictionary correctly. @@ -1307,7 +1289,6 @@ def test_setting_gains_from_cfg_dict(sim, num_articulations, device): @pytest.mark.parametrize("vel_limit_sim", [1e5, None]) @pytest.mark.parametrize("vel_limit", [1e2, None]) @pytest.mark.parametrize("add_ground_plane", [False]) -@pytest.mark.isaacsim_ci def test_setting_velocity_limit_implicit(sim, num_articulations, device, vel_limit_sim, vel_limit, add_ground_plane): """Test setting of velocity limit for implicit actuators. @@ -1374,7 +1355,6 @@ def test_setting_velocity_limit_implicit(sim, num_articulations, device, vel_lim @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("vel_limit_sim", [1e5, None]) @pytest.mark.parametrize("vel_limit", [1e2, None]) -@pytest.mark.isaacsim_ci def test_setting_velocity_limit_explicit(sim, num_articulations, device, vel_limit_sim, vel_limit): """Test setting of velocity limit for explicit actuators.""" articulation_cfg = generate_articulation_cfg( @@ -1428,7 +1408,6 @@ def test_setting_velocity_limit_explicit(sim, num_articulations, device, vel_lim @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("effort_limit_sim", [1e5, None]) @pytest.mark.parametrize("effort_limit", [1e2, 80.0, None]) -@pytest.mark.isaacsim_ci def test_setting_effort_limit_implicit(sim, num_articulations, device, effort_limit_sim, effort_limit): """Test setting of effort limit for implicit actuators. @@ -1481,7 +1460,6 @@ def test_setting_effort_limit_implicit(sim, num_articulations, device, effort_li @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("effort_limit_sim", [1e5, None]) @pytest.mark.parametrize("effort_limit", [80.0, 1e2, None]) -@pytest.mark.isaacsim_ci def test_setting_effort_limit_explicit(sim, num_articulations, device, effort_limit_sim, effort_limit): """Test setting of effort limit for explicit actuators. @@ -1541,7 +1519,6 @@ def test_setting_effort_limit_explicit(sim, num_articulations, device, effort_li @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_reset(sim, num_articulations, device): """Test that reset method works properly.""" articulation_cfg = generate_articulation_cfg(articulation_type="humanoid") @@ -1586,7 +1563,6 @@ def test_reset(sim, num_articulations, device): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.isaacsim_ci def test_apply_joint_command(sim, num_articulations, device, add_ground_plane): """Test applying of joint position target functions correctly for a robotic arm.""" articulation_cfg = generate_articulation_cfg(articulation_type="panda") @@ -1626,7 +1602,6 @@ def test_apply_joint_command(sim, num_articulations, device, add_ground_plane): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("with_offset", [True, False]) -@pytest.mark.isaacsim_ci def test_body_root_state(sim, num_articulations, device, with_offset): """Test for reading the `body_state_w` property. @@ -1752,7 +1727,6 @@ def test_body_root_state(sim, num_articulations, device, with_offset): @pytest.mark.parametrize("with_offset", [True, False]) @pytest.mark.parametrize("state_location", ["com", "link"]) @pytest.mark.parametrize("gravity_enabled", [False]) -@pytest.mark.isaacsim_ci def test_write_root_state(sim, num_articulations, device, with_offset, state_location, gravity_enabled): """Test the setters for root_state using both the link frame and center of mass as reference frame. @@ -1831,7 +1805,6 @@ def test_write_root_state(sim, num_articulations, device, with_offset, state_loc @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_setting_articulation_root_prim_path(sim, device): """Test that the articulation root prim path can be set explicitly.""" sim._app_control_on_stop_handle = None @@ -1850,7 +1823,6 @@ def test_setting_articulation_root_prim_path(sim, device): @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci def test_setting_invalid_articulation_root_prim_path(sim, device): """Test that the articulation root prim path can be set explicitly.""" sim._app_control_on_stop_handle = None @@ -1870,7 +1842,6 @@ def test_setting_invalid_articulation_root_prim_path(sim, device): @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("gravity_enabled", [False]) -@pytest.mark.isaacsim_ci def test_write_joint_state_data_consistency(sim, num_articulations, device, gravity_enabled): """Test the setters for root_state using both the link frame and center of mass as reference frame. @@ -2121,7 +2092,6 @@ def test_write_joint_frictions_to_sim(sim, num_articulations, device, add_ground @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["panda"]) -@pytest.mark.isaacsim_ci def test_set_material_properties(sim, num_articulations, device, add_ground_plane, articulation_type): """Test getting and setting material properties (friction/restitution) of articulation shapes.""" articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) From 39a9aecfdb20395d06d23daa75effce3e05a7094 Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Fri, 8 May 2026 21:23:48 -0700 Subject: [PATCH 029/133] Initialize Warp runtime (#5548) # Description OVRTX renderer relies on Warp. In some instances, i.e. unit tests, the ovrtx integration layer can fail with following error: ```python else: try: if torch_device.type == "cuda": > return warp._src.context.runtime.cuda_devices[torch_device.index] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E AttributeError: 'NoneType' object has no attribute 'cuda_devices' ovphysx-ovrtx/lib/python3.12/site-packages/warp/_src/torch.py:41: AttributeError ``` This problem is because warp runtime isn't initialized, by importing `import isaaclab.utils.warp` module we ensure warp runtime is initialized. 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 --- docs/source/overview/environments.rst | 2 +- .../changelog.d/pbarejko-add-seed-to-math-module.rst | 4 ++++ source/isaaclab/test/utils/test_math.py | 5 ++++- source/isaaclab_ov/changelog.d/pbarejko-init-warp.rst | 4 ++++ source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py | 2 ++ 5 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab/changelog.d/pbarejko-add-seed-to-math-module.rst create mode 100644 source/isaaclab_ov/changelog.d/pbarejko-init-warp.rst diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index 6a13f8c087d7..a28d129f7027 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -531,7 +531,7 @@ Multirotor .. |arl_robot_track_position_state_based| image:: ../_static/tasks/drone_arl/arl_robot_1_track_position_state_based.jpg -.. |arl_robot_navigation-link| replace:: `Isaac-Navigation-3DObstacles-ARL-Robot-1-v0 `__ +.. |arl_robot_navigation-link| replace:: `Isaac-Navigation-3DObstacles-ARL-Robot-1-v0 `__ .. |arl_robot_navigation| image:: ../_static/tasks/drone_arl/arl_robot_1_navigation.jpg diff --git a/source/isaaclab/changelog.d/pbarejko-add-seed-to-math-module.rst b/source/isaaclab/changelog.d/pbarejko-add-seed-to-math-module.rst new file mode 100644 index 000000000000..885c962d90b2 --- /dev/null +++ b/source/isaaclab/changelog.d/pbarejko-add-seed-to-math-module.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Certain functions in test_math were failing non deterministically. This was caused by not setting seed values. diff --git a/source/isaaclab/test/utils/test_math.py b/source/isaaclab/test/utils/test_math.py index e0d85b14eb25..6bff0b31e267 100644 --- a/source/isaaclab/test/utils/test_math.py +++ b/source/isaaclab/test/utils/test_math.py @@ -554,12 +554,15 @@ def test_combine_frame_transform(device): @pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_interpolate_poses(device): +@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4]) +def test_interpolate_poses(device, seed): """Test interpolate_poses function. This test checks the output from the :meth:`~isaaclab.utils.math_utils.interpolate_poses` function against the output from :func:`scipy.spatial.transform.Slerp` and :func:`np.linspace`. """ + torch.manual_seed(seed) + np.random.seed(seed) for _ in range(100): mat1 = math_utils.generate_random_transformation_matrix() mat2 = math_utils.generate_random_transformation_matrix() diff --git a/source/isaaclab_ov/changelog.d/pbarejko-init-warp.rst b/source/isaaclab_ov/changelog.d/pbarejko-init-warp.rst new file mode 100644 index 000000000000..413bfe794f05 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/pbarejko-init-warp.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Initialize Warp runtime for OvRTX renderer. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 00e0a1d06d3a..3c05a72dc30f 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -30,6 +30,8 @@ import torch import warp as wp +import isaaclab.utils.warp # noqa: F401 # initializes Warp runtime + # The ovrtx C library links to its own version of the USD libraries. Having # the pxr Python package available can cause the C library to load an # incompatible version of libusd, potentially leading to undefined behavior. From b59b3ae844d1490eddf5e288b7cefd381f41c46a Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 05:57:31 +0000 Subject: [PATCH 030/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 4.8.0 → 4.8.1 - isaaclab_contrib: 0.3.0 → 0.3.1 - isaaclab_newton: 0.7.0 → 0.7.1 - isaaclab_ov: 0.1.5 → 0.1.6 - isaaclab_ovphysx: 0.1.3 → 0.1.4 - isaaclab_physx: 0.6.1 → 0.6.2 - isaaclab_tasks: 1.5.35 → 1.5.36 --- ...oiner-core-deprecation-warning-cleanup.rst | 5 --- .../antoiner-docs-joint-friction.rst | 4 -- .../antoiner-docs-sensor-updates.rst | 8 ---- ...toiner-fix-from-files-windows-filelock.rst | 12 ------ .../jichuanh-fix-warp-intersphinx.rst | 5 --- .../malesiani-ovphysx-04-fixes.rst | 5 --- source/isaaclab/changelog.d/my-nvbugs-2.rst | 8 ---- .../pbarejko-add-seed-to-math-module.rst | 4 -- .../pr-5423-state-observation-mdp.rst | 6 --- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 38 +++++++++++++++++++ ...-5410-task-deprecation-warning-cleanup.rst | 6 --- source/isaaclab_contrib/config/extension.toml | 2 +- source/isaaclab_contrib/docs/CHANGELOG.rst | 11 ++++++ ...er-backend-deprecation-warning-cleanup.rst | 6 --- .../antoiner-docs-joint-friction.rst | 5 --- .../antoiner-fix-fk-invalidation.rst | 6 --- .../disable-articulation-sim-ci.skip | 0 source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 16 ++++++++ .../huidongc-ovrtx-keep-system-alive.rst | 6 --- .../changelog.d/pbarejko-init-warp.rst | 4 -- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 12 ++++++ .../malesiani-ovphysx-04-fixes.rst | 6 --- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 11 ++++++ ...er-backend-deprecation-warning-cleanup.rst | 5 --- .../antoiner-docs-joint-friction.rst | 5 --- .../disable-articulation-sim-ci.skip | 0 source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 12 ++++++ .../huidongc-enable-ovrtx-rendering.skip | 0 .../malesiani-ovphysx-camera-cartpole.rst | 4 -- ...-5410-task-deprecation-warning-cleanup.rst | 8 ---- .../pr-5423-state-observation-mdp.rst | 21 ---------- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 32 ++++++++++++++++ 38 files changed, 139 insertions(+), 146 deletions(-) delete mode 100644 source/isaaclab/changelog.d/antoiner-core-deprecation-warning-cleanup.rst delete mode 100644 source/isaaclab/changelog.d/antoiner-docs-joint-friction.rst delete mode 100644 source/isaaclab/changelog.d/antoiner-docs-sensor-updates.rst delete mode 100644 source/isaaclab/changelog.d/antoiner-fix-from-files-windows-filelock.rst delete mode 100644 source/isaaclab/changelog.d/jichuanh-fix-warp-intersphinx.rst delete mode 100644 source/isaaclab/changelog.d/malesiani-ovphysx-04-fixes.rst delete mode 100644 source/isaaclab/changelog.d/my-nvbugs-2.rst delete mode 100644 source/isaaclab/changelog.d/pbarejko-add-seed-to-math-module.rst delete mode 100644 source/isaaclab/changelog.d/pr-5423-state-observation-mdp.rst delete mode 100644 source/isaaclab_contrib/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst delete mode 100644 source/isaaclab_newton/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst delete mode 100644 source/isaaclab_newton/changelog.d/antoiner-docs-joint-friction.rst delete mode 100644 source/isaaclab_newton/changelog.d/antoiner-fix-fk-invalidation.rst delete mode 100644 source/isaaclab_newton/changelog.d/disable-articulation-sim-ci.skip delete mode 100644 source/isaaclab_ov/changelog.d/huidongc-ovrtx-keep-system-alive.rst delete mode 100644 source/isaaclab_ov/changelog.d/pbarejko-init-warp.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/malesiani-ovphysx-04-fixes.rst delete mode 100644 source/isaaclab_physx/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst delete mode 100644 source/isaaclab_physx/changelog.d/antoiner-docs-joint-friction.rst delete mode 100644 source/isaaclab_physx/changelog.d/disable-articulation-sim-ci.skip delete mode 100644 source/isaaclab_tasks/changelog.d/huidongc-enable-ovrtx-rendering.skip delete mode 100644 source/isaaclab_tasks/changelog.d/malesiani-ovphysx-camera-cartpole.rst delete mode 100644 source/isaaclab_tasks/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst delete mode 100644 source/isaaclab_tasks/changelog.d/pr-5423-state-observation-mdp.rst diff --git a/source/isaaclab/changelog.d/antoiner-core-deprecation-warning-cleanup.rst b/source/isaaclab/changelog.d/antoiner-core-deprecation-warning-cleanup.rst deleted file mode 100644 index a3a3e51f8b3a..000000000000 --- a/source/isaaclab/changelog.d/antoiner-core-deprecation-warning-cleanup.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed :class:`~isaaclab.envs.mdp.actions.PinkInverseKinematicsAction` - base link pose reads to avoid deprecated body link state access. diff --git a/source/isaaclab/changelog.d/antoiner-docs-joint-friction.rst b/source/isaaclab/changelog.d/antoiner-docs-joint-friction.rst deleted file mode 100644 index 4b7e63eeb995..000000000000 --- a/source/isaaclab/changelog.d/antoiner-docs-joint-friction.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Fixed :class:`~isaaclab.assets.Articulation` joint friction API docs to clarify backend-specific semantics. diff --git a/source/isaaclab/changelog.d/antoiner-docs-sensor-updates.rst b/source/isaaclab/changelog.d/antoiner-docs-sensor-updates.rst deleted file mode 100644 index fd8e62a260cb..000000000000 --- a/source/isaaclab/changelog.d/antoiner-docs-sensor-updates.rst +++ /dev/null @@ -1,8 +0,0 @@ -Fixed -^^^^^ - -* Fixed the sensor overview documentation to include - :class:`~isaaclab.sensors.Pva` and - :class:`~isaaclab.sensors.JointWrenchSensor`. -* Fixed the PVA sensor demo to align front-foot sensor names with their prim - paths. diff --git a/source/isaaclab/changelog.d/antoiner-fix-from-files-windows-filelock.rst b/source/isaaclab/changelog.d/antoiner-fix-from-files-windows-filelock.rst deleted file mode 100644 index 3ceaeab12231..000000000000 --- a/source/isaaclab/changelog.d/antoiner-fix-from-files-windows-filelock.rst +++ /dev/null @@ -1,12 +0,0 @@ -Fixed -^^^^^ - -* Fixed :mod:`isaaclab.sim.spawners.from_files` failing to import on Windows - due to an unconditional ``import fcntl`` (Unix-only). The distributed-rank - USD spawn lock now uses :class:`filelock.FileLock`, which works on both - Windows and POSIX. - -Changed -^^^^^^^ - -* Added :mod:`filelock` to ``isaaclab`` install requirements. diff --git a/source/isaaclab/changelog.d/jichuanh-fix-warp-intersphinx.rst b/source/isaaclab/changelog.d/jichuanh-fix-warp-intersphinx.rst deleted file mode 100644 index 7aa72335f9e1..000000000000 --- a/source/isaaclab/changelog.d/jichuanh-fix-warp-intersphinx.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed Sphinx docs build failing due to ``https://nvidia.github.io/warp/objects.inv`` returning 404. - Pinned the ``warp`` intersphinx mapping to ``/stable/``, which is where the inventory now lives. diff --git a/source/isaaclab/changelog.d/malesiani-ovphysx-04-fixes.rst b/source/isaaclab/changelog.d/malesiani-ovphysx-04-fixes.rst deleted file mode 100644 index 8a3e0a90d796..000000000000 --- a/source/isaaclab/changelog.d/malesiani-ovphysx-04-fixes.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed the sensor prim-deletion callback guard so the OvPhysX backend is not - treated as the Kit PhysX backend. diff --git a/source/isaaclab/changelog.d/my-nvbugs-2.rst b/source/isaaclab/changelog.d/my-nvbugs-2.rst deleted file mode 100644 index 29bc762d964d..000000000000 --- a/source/isaaclab/changelog.d/my-nvbugs-2.rst +++ /dev/null @@ -1,8 +0,0 @@ -Fixed -^^^^^ - -* Relaxed the ``starlette`` pin in :mod:`isaaclab` from ``==0.49.1`` to - ``>=0.46.0,<0.50`` so installs of ``isaaclab[isaacsim,all]==3.0.0`` - alongside ``isaacsim==6.0.0.0`` resolve cleanly. The transitive pin - from ``isaacsim-kernel`` -> ``fastapi==0.117.1`` requires - ``starlette<0.49.0``; the previous exact pin was mutually exclusive. diff --git a/source/isaaclab/changelog.d/pbarejko-add-seed-to-math-module.rst b/source/isaaclab/changelog.d/pbarejko-add-seed-to-math-module.rst deleted file mode 100644 index 885c962d90b2..000000000000 --- a/source/isaaclab/changelog.d/pbarejko-add-seed-to-math-module.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Certain functions in test_math were failing non deterministically. This was caused by not setting seed values. diff --git a/source/isaaclab/changelog.d/pr-5423-state-observation-mdp.rst b/source/isaaclab/changelog.d/pr-5423-state-observation-mdp.rst deleted file mode 100644 index 62193bc0796f..000000000000 --- a/source/isaaclab/changelog.d/pr-5423-state-observation-mdp.rst +++ /dev/null @@ -1,6 +0,0 @@ -Changed -^^^^^^^ - -* Changed the Pink IK task-space action base link frame lookup to read direct - body link pose data instead of slicing packed body link state. No user - migration is required. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index ce77d89f4c45..98d55ac40369 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.8.0" +version = "4.8.1" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 152979d60df4..dda10e77f1f4 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,44 @@ Changelog --------- +4.8.1 (2026-05-09) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed the Pink IK task-space action base link frame lookup to read direct + body link pose data instead of slicing packed body link state. No user + migration is required. +* Added :mod:`filelock` to ``isaaclab`` install requirements. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab.assets.Articulation` joint friction API docs to clarify backend-specific semantics. +* Fixed :class:`~isaaclab.envs.mdp.actions.PinkInverseKinematicsAction` + base link pose reads to avoid deprecated body link state access. +* Fixed the sensor overview documentation to include + :class:`~isaaclab.sensors.Pva` and + :class:`~isaaclab.sensors.JointWrenchSensor`. +* Fixed the PVA sensor demo to align front-foot sensor names with their prim + paths. +* Fixed Sphinx docs build failing due to ``https://nvidia.github.io/warp/objects.inv`` returning 404. + Pinned the ``warp`` intersphinx mapping to ``/stable/``, which is where the inventory now lives. +* Fixed the sensor prim-deletion callback guard so the OvPhysX backend is not + treated as the Kit PhysX backend. +* Relaxed the ``starlette`` pin in :mod:`isaaclab` from ``==0.49.1`` to + ``>=0.46.0,<0.50`` so installs of ``isaaclab[isaacsim,all]==3.0.0`` + alongside ``isaacsim==6.0.0.0`` resolve cleanly. The transitive pin + from ``isaacsim-kernel`` -> ``fastapi==0.117.1`` requires + ``starlette<0.49.0``; the previous exact pin was mutually exclusive. +* Fixed :mod:`isaaclab.sim.spawners.from_files` failing to import on Windows + due to an unconditional ``import fcntl`` (Unix-only). The distributed-rank + USD spawn lock now uses :class:`filelock.FileLock`, which works on both + Windows and POSIX. +* Certain functions in test_math were failing non deterministically. This was caused by not setting seed values. + + 4.8.0 (2026-05-08) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_contrib/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst b/source/isaaclab_contrib/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst deleted file mode 100644 index 9a8805b42e1d..000000000000 --- a/source/isaaclab_contrib/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst +++ /dev/null @@ -1,6 +0,0 @@ -Changed -^^^^^^^ - -* Updated TacSL visuotactile sensor camera configuration and examples to use - :class:`~isaaclab.sensors.CameraCfg` and :class:`~isaaclab.sensors.Camera` - instead of deprecated tiled-camera aliases. diff --git a/source/isaaclab_contrib/config/extension.toml b/source/isaaclab_contrib/config/extension.toml index 7fd2b5d139e5..0fba6e220442 100644 --- a/source/isaaclab_contrib/config/extension.toml +++ b/source/isaaclab_contrib/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.3.0" +version = "0.3.1" # Description title = "Isaac Lab External Contributions" diff --git a/source/isaaclab_contrib/docs/CHANGELOG.rst b/source/isaaclab_contrib/docs/CHANGELOG.rst index 721c705a5981..ff3fa5a2a96a 100644 --- a/source/isaaclab_contrib/docs/CHANGELOG.rst +++ b/source/isaaclab_contrib/docs/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog --------- +0.3.1 (2026-05-09) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Updated TacSL visuotactile sensor camera configuration and examples to use + :class:`~isaaclab.sensors.CameraCfg` and :class:`~isaaclab.sensors.Camera` + instead of deprecated tiled-camera aliases. + + 0.3.0 (2026-02-13) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst b/source/isaaclab_newton/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst deleted file mode 100644 index 2fd8df763f82..000000000000 --- a/source/isaaclab_newton/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Fixed :class:`~isaaclab_newton.sensors.contact_sensor.ContactSensor` to use - current Newton contact sensor API names, removing deprecation warnings from - Newton contact sensor test runs. diff --git a/source/isaaclab_newton/changelog.d/antoiner-docs-joint-friction.rst b/source/isaaclab_newton/changelog.d/antoiner-docs-joint-friction.rst deleted file mode 100644 index e84764b739c9..000000000000 --- a/source/isaaclab_newton/changelog.d/antoiner-docs-joint-friction.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed :class:`~isaaclab_newton.assets.Articulation` joint friction docs to identify Newton friction as a force or - torque value instead of a unitless coefficient. diff --git a/source/isaaclab_newton/changelog.d/antoiner-fix-fk-invalidation.rst b/source/isaaclab_newton/changelog.d/antoiner-fix-fk-invalidation.rst deleted file mode 100644 index e3f16a0a8893..000000000000 --- a/source/isaaclab_newton/changelog.d/antoiner-fix-fk-invalidation.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Fixed stale Newton forward-kinematics state after explicit pose writes so - downstream collision queries and :attr:`~isaaclab_newton.assets.RigidObjectData.body_link_pose_w` - reads use updated transforms. diff --git a/source/isaaclab_newton/changelog.d/disable-articulation-sim-ci.skip b/source/isaaclab_newton/changelog.d/disable-articulation-sim-ci.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 8ddb2526072e..0a55aaddb353 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.7.0" +version = "0.7.1" # 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 afde5c8d2ec5..a472bf0643c6 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,22 @@ Changelog --------- +0.7.1 (2026-05-09) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_newton.assets.Articulation` joint friction docs to identify Newton friction as a force or + torque value instead of a unitless coefficient. +* Fixed :class:`~isaaclab_newton.sensors.contact_sensor.ContactSensor` to use + current Newton contact sensor API names, removing deprecation warnings from + Newton contact sensor test runs. +* Fixed stale Newton forward-kinematics state after explicit pose writes so + downstream collision queries and :attr:`~isaaclab_newton.assets.RigidObjectData.body_link_pose_w` + reads use updated transforms. + + 0.7.0 (2026-05-08) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/changelog.d/huidongc-ovrtx-keep-system-alive.rst b/source/isaaclab_ov/changelog.d/huidongc-ovrtx-keep-system-alive.rst deleted file mode 100644 index 834776759402..000000000000 --- a/source/isaaclab_ov/changelog.d/huidongc-ovrtx-keep-system-alive.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Set ``keep_system_alive=True`` on the internal OVRTX ``RendererConfig`` in - :class:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer` so the renderer - system is not torn down prematurely during pytest sessions. diff --git a/source/isaaclab_ov/changelog.d/pbarejko-init-warp.rst b/source/isaaclab_ov/changelog.d/pbarejko-init-warp.rst deleted file mode 100644 index 413bfe794f05..000000000000 --- a/source/isaaclab_ov/changelog.d/pbarejko-init-warp.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Initialize Warp runtime for OvRTX renderer. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 63cda50eb5c0..7bc51bfb5e75 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.1.5" +version = "0.1.6" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index d1af152a61e5..d0ae068e08d9 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.1.6 (2026-05-09) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Set ``keep_system_alive=True`` on the internal OVRTX ``RendererConfig`` in + :class:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer` so the renderer + system is not torn down prematurely during pytest sessions. +* Initialize Warp runtime for OvRTX renderer. + + 0.1.5 (2026-05-08) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/changelog.d/malesiani-ovphysx-04-fixes.rst b/source/isaaclab_ovphysx/changelog.d/malesiani-ovphysx-04-fixes.rst deleted file mode 100644 index 1708ee377f18..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/malesiani-ovphysx-04-fixes.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Fixed OvPhysX articulation tensor reads and writes for ``ovphysx`` 0.4 - compatibility. -* Restored DirectGPU startup settings for OvPhysX GPU simulations. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 1e541402d828..1ad422a1df32 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.3" +version = "0.1.4" # 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 8eef22620a69..cea22cdc70c5 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog --------- +0.1.4 (2026-05-09) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed OvPhysX articulation tensor reads and writes for ``ovphysx`` 0.4 + compatibility. +* Restored DirectGPU startup settings for OvPhysX GPU simulations. + + 0.1.3 (2026-05-08) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst b/source/isaaclab_physx/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst deleted file mode 100644 index bdec42289b0d..000000000000 --- a/source/isaaclab_physx/changelog.d/antoiner-backend-deprecation-warning-cleanup.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed PhysX backend tests to use current contact sensor and asset API names, - removing deprecation warnings from scoped test runs. diff --git a/source/isaaclab_physx/changelog.d/antoiner-docs-joint-friction.rst b/source/isaaclab_physx/changelog.d/antoiner-docs-joint-friction.rst deleted file mode 100644 index 652271d896b1..000000000000 --- a/source/isaaclab_physx/changelog.d/antoiner-docs-joint-friction.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed :class:`~isaaclab_physx.assets.Articulation` joint friction docs to distinguish legacy coefficients from - PhysX 5 static and dynamic friction efforts. diff --git a/source/isaaclab_physx/changelog.d/disable-articulation-sim-ci.skip b/source/isaaclab_physx/changelog.d/disable-articulation-sim-ci.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 00264e1238ff..d630d9c945c8 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.6.1" +version = "0.6.2" # 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 bc487f3190ab..0220a659e721 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.6.2 (2026-05-09) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_physx.assets.Articulation` joint friction docs to distinguish legacy coefficients from + PhysX 5 static and dynamic friction efforts. +* Fixed PhysX backend tests to use current contact sensor and asset API names, + removing deprecation warnings from scoped test runs. + + 0.6.1 (2026-05-08) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/changelog.d/huidongc-enable-ovrtx-rendering.skip b/source/isaaclab_tasks/changelog.d/huidongc-enable-ovrtx-rendering.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_tasks/changelog.d/malesiani-ovphysx-camera-cartpole.rst b/source/isaaclab_tasks/changelog.d/malesiani-ovphysx-camera-cartpole.rst deleted file mode 100644 index 5a352196a0ed..000000000000 --- a/source/isaaclab_tasks/changelog.d/malesiani-ovphysx-camera-cartpole.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* Added the ``ovphysx`` physics preset to the cartpole camera presets task. diff --git a/source/isaaclab_tasks/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst b/source/isaaclab_tasks/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst deleted file mode 100644 index 6fd8e22e713f..000000000000 --- a/source/isaaclab_tasks/changelog.d/pr-5410-task-deprecation-warning-cleanup.rst +++ /dev/null @@ -1,8 +0,0 @@ -Changed -^^^^^^^ - -* Updated task camera configs and environments to use - :class:`~isaaclab.sensors.CameraCfg` and :class:`~isaaclab.sensors.Camera` - instead of deprecated tiled-camera aliases. -* Updated task state and write call sites to use explicit state properties and - indexed simulation write APIs. diff --git a/source/isaaclab_tasks/changelog.d/pr-5423-state-observation-mdp.rst b/source/isaaclab_tasks/changelog.d/pr-5423-state-observation-mdp.rst deleted file mode 100644 index 655f379a939a..000000000000 --- a/source/isaaclab_tasks/changelog.d/pr-5423-state-observation-mdp.rst +++ /dev/null @@ -1,21 +0,0 @@ -Added -^^^^^ - -* Added explicit GR1T2 and Unitree G1 pick-place robot link pose and velocity - MDP helpers as replacements for packed robot link state observations. - -Changed -^^^^^^^ - -* Changed Dexsuite orientation tracking rewards to read root link orientation - directly instead of slicing packed root state tensors. - -Deprecated -^^^^^^^^^^ - -* Deprecated - :func:`~isaaclab_tasks.manager_based.manipulation.pick_place.mdp.observations.get_all_robot_link_state` - in favor of - :func:`~isaaclab_tasks.manager_based.manipulation.pick_place.mdp.observations.get_all_robot_link_pose` - and - :func:`~isaaclab_tasks.manager_based.manipulation.pick_place.mdp.observations.get_all_robot_link_velocity`. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index c797fcdb2cb9..486b9faccf4c 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.35" +version = "1.5.36" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index cc3f6a513120..3638b0aa6910 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,38 @@ Changelog --------- +1.5.36 (2026-05-09) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added explicit GR1T2 and Unitree G1 pick-place robot link pose and velocity + MDP helpers as replacements for packed robot link state observations. +* Added the ``ovphysx`` physics preset to the cartpole camera presets task. + +Changed +^^^^^^^ + +* Changed Dexsuite orientation tracking rewards to read root link orientation + directly instead of slicing packed root state tensors. +* Updated task camera configs and environments to use + :class:`~isaaclab.sensors.CameraCfg` and :class:`~isaaclab.sensors.Camera` + instead of deprecated tiled-camera aliases. +* Updated task state and write call sites to use explicit state properties and + indexed simulation write APIs. + +Deprecated +^^^^^^^^^^ + +* Deprecated + :func:`~isaaclab_tasks.manager_based.manipulation.pick_place.mdp.observations.get_all_robot_link_state` + in favor of + :func:`~isaaclab_tasks.manager_based.manipulation.pick_place.mdp.observations.get_all_robot_link_pose` + and + :func:`~isaaclab_tasks.manager_based.manipulation.pick_place.mdp.observations.get_all_robot_link_velocity`. + + 1.5.35 (2026-05-08) ~~~~~~~~~~~~~~~~~~~ From 4934cd0ce5a53b86342f32d89e5491016c841769 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Sat, 9 May 2026 14:57:18 -0700 Subject: [PATCH 031/133] Fix Pink IK DAQP dependency checks (#5556) # Description Pink IK uses DAQP through `qpsolvers`, but the install-time dependency repair only verified that Pinocchio could be imported. In environments where `pin-pink` or `qpsolvers` is present but DAQP is missing, unregistered, or too old for `qpsolvers` warm-start arguments, `solve_ik(..., solver="daqp")` can fail and fall back to current joint targets. The controller then reports a misleading end-effector position error in `test_pink_ik.py`. This change makes the installer probe the full Pink IK stack: `pinocchio`, DAQP registration in `qpsolvers`, and the `daqp.solve` API shape required by current `qpsolvers` (`primal_start`). It also aligns the IsaacLab dependency pin with the compatible DAQP release, `daqp==0.8.5`. Runtime handling stays narrow: ordinary IK failures keep the existing fallback, while missing DAQP or the specific `primal_start` API mismatch is surfaced with an actionable install message instead of being swallowed as an IK fallback. The docs config also mocks `qpsolvers`, matching the existing docs treatment for optional Pink IK dependencies such as `pink` and `pinocchio`, so API docs can be built without the runtime solver stack installed. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Test Plan - `./isaaclab.sh -p -m py_compile source/isaaclab/isaaclab/cli/commands/install.py source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py source/isaaclab/setup.py docs/conf.py` - `./isaaclab.sh -p -c "import inspect, pinocchio, daqp, qpsolvers; assert 'daqp' in qpsolvers.available_solvers; assert 'primal_start' in inspect.signature(daqp.solve).parameters; print('pink ik dependency probe passed')"` - `./isaaclab.sh -p -c "..."` small monkeypatch check that `TypeError("solve() got an unexpected keyword argument 'primal_start'")` raises the new DAQP compatibility `RuntimeError` - `./isaaclab.sh -p -m pytest source/isaaclab/test/controllers/test_pink_ik.py::test_movement_types -k "GR1T2-Abs-v0 and stay_still" -q --tb=short -s -x` - `./isaaclab.sh -p -m pytest source/isaaclab/test/controllers/test_pink_ik.py -q --tb=short -x` (`23 passed, 1 skipped`) - `VIRTUAL_ENV=/home/zhengyuz/Projects/IsaacLab.wt/feature-heterogeneous_dexsuite/env_isaaclab PATH=/home/zhengyuz/Projects/IsaacLab.wt/feature-heterogeneous_dexsuite/env_isaaclab/bin:$PATH make current-docs` from `docs/` - `VIRTUAL_ENV=/home/zhengyuz/Projects/IsaacLab.wt/feature-heterogeneous_dexsuite/env_isaaclab ./isaaclab.sh -f` ## Screenshots N/A. ## 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 (N/A - docs config only) - [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 added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` -- CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/conf.py | 1 + .../changelog.d/fix-pink-ik-daqp-install.rst | 5 +++ .../isaaclab/isaaclab/cli/commands/install.py | 45 ++++++++++--------- .../isaaclab/controllers/pink_ik/pink_ik.py | 45 +++++++++++++------ source/isaaclab/setup.py | 2 +- 5 files changed, 63 insertions(+), 35 deletions(-) create mode 100644 source/isaaclab/changelog.d/fix-pink-ik-daqp-install.rst diff --git a/docs/conf.py b/docs/conf.py index d75475634e30..a52ff90d31cc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -203,6 +203,7 @@ "toml", "pink", "pinocchio", + "qpsolvers", "nvidia.srl", "flatdict", "filelock", diff --git a/source/isaaclab/changelog.d/fix-pink-ik-daqp-install.rst b/source/isaaclab/changelog.d/fix-pink-ik-daqp-install.rst new file mode 100644 index 000000000000..e4d25c7ba957 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-pink-ik-daqp-install.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed Pink IK setup checks to reinstall and report the required ``daqp`` + solver when it is missing or incompatible. diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index ba9735b92a6d..46125ae46f1e 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -159,22 +159,22 @@ def _maybe_uninstall_prebundled_torch( ) -# Pinocchio stack required by isaaclab.controllers.pink_ik. Installed via the cmeel -# ``pin`` wheel, which provides the ``pinocchio`` Python module under +# Dependency stack required by isaaclab.controllers.pink_ik. Pinocchio is installed +# via the cmeel ``pin`` wheel, which provides the ``pinocchio`` Python module under # ``cmeel.prefix/lib/python3.12/site-packages/`` and registers it on sys.path via a -# ``cmeel.pth`` hook. -_PINOCCHIO_STACK = ("pin", "pin-pink==3.1.0", "daqp==0.7.2") +# ``cmeel.pth`` hook. DAQP provides the QP solver selected by the Pink IK controller. +_PINK_IK_STACK = ("pin", "pin-pink==3.1.0", "daqp==0.8.5") -def _ensure_pinocchio_installed(python_exe: str, pip_cmd: list[str], *, probe_env: dict[str, str]) -> None: - """Ensure ``pinocchio`` is importable, force-installing the cmeel pin stack if not. +def _ensure_pink_ik_dependencies_installed(python_exe: str, pip_cmd: list[str], *, probe_env: dict[str, str]) -> None: + """Ensure the Pink IK dependency stack is importable, force-installing it if not. Recent Isaac Sim base images preinstall ``pin-pink`` into the kit's bundled ``site-packages`` without its ``pin`` (cmeel pinocchio) dependency. Pip then treats the ``pin-pink`` requirement as satisfied and never resolves the - transitive ``pin`` dep, leaving ``import pinocchio`` broken. This probes - for ``pinocchio`` at runtime and force-installs the cmeel stack when needed - so the pink IK controller and its tests work out of the box. + transitive ``pin`` dep, leaving ``import pinocchio`` broken. This checks + the runtime dependencies and force-installs the cmeel stack when needed so + the pink IK controller and its tests work out of the box. Only runs on Linux x86_64 / aarch64 — the same platforms that have pinocchio listed in :mod:`isaaclab`'s ``setup.py`` install requirements. @@ -194,7 +194,13 @@ def _ensure_pinocchio_installed(python_exe: str, pip_cmd: list[str], *, probe_en return probe_result = run_command( - [python_exe, "-c", "import pinocchio"], + [ + python_exe, + "-c", + "import inspect, pinocchio, daqp, qpsolvers; " + "assert 'daqp' in qpsolvers.available_solvers; " + "assert 'primal_start' in inspect.signature(daqp.solve).parameters", + ], env=probe_env, check=False, capture_output=True, @@ -203,19 +209,16 @@ def _ensure_pinocchio_installed(python_exe: str, pip_cmd: list[str], *, probe_en if probe_result.returncode == 0: return - print_info( - "``import pinocchio`` failed — the kit-bundled ``pin-pink`` likely shipped without its" - " ``pin`` dep. Force-installing the cmeel pinocchio stack." - ) + print_info("Pink IK dependency probe failed. Force-installing the cmeel pinocchio and DAQP stack.") install_result = run_command( - pip_cmd + ["install", "--upgrade", "--force-reinstall", *_PINOCCHIO_STACK], + pip_cmd + ["install", "--upgrade", "--force-reinstall", *_PINK_IK_STACK], check=False, ) if install_result.returncode != 0: print_warning( - "Force-installing the cmeel pinocchio stack failed (returncode " + "Force-installing the cmeel pinocchio and DAQP stack failed (returncode " f"{install_result.returncode}). The pink IK controller and its tests will not be" - " usable until ``pin pin-pink==3.1.0 daqp==0.7.2`` is installed manually." + " usable until ``pin pin-pink==3.1.0 daqp==0.8.5`` is installed manually." ) @@ -735,10 +738,10 @@ def command_install(install_type: str = "all") -> None: # Can prevent that from happening. _ensure_cuda_torch() - # Ensure ``pinocchio`` is actually importable. The kit-bundled ``pin-pink`` in recent - # Isaac Sim images ships without its cmeel ``pin`` dependency, so the transitive - # requirement from ``pip install -e source/isaaclab`` can be silently skipped. - _ensure_pinocchio_installed(python_exe, pip_cmd, probe_env=probe_env) + # Ensure Pink IK's runtime dependencies are actually importable. The kit-bundled + # ``pin-pink`` in recent Isaac Sim images can cause transitive dependencies from + # ``pip install -e source/isaaclab`` to be silently skipped. + _ensure_pink_ik_dependencies_installed(python_exe, pip_cmd, probe_env=probe_env) # Repoint prebundled packages in Isaac Sim to the environment's copies so # the active venv/conda versions are always loaded regardless of PYTHONPATH diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py index d1162b480910..1520afdb0177 100644 --- a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py @@ -20,6 +20,7 @@ import torch from pink import solve_ik from pink.tasks import Task +from qpsolvers.exceptions import SolverNotFound from isaaclab.assets import ArticulationCfg from isaaclab.controllers import utils as controller_utils @@ -34,6 +35,9 @@ from .pink_ik_cfg import PinkIKControllerCfg +_QP_SOLVER = "daqp" + + class PinkIKController: """Integration of Pink IK controller with Isaac Lab. @@ -240,30 +244,45 @@ def compute( # Update Pink's robot configuration with the current joint positions self.pink_configuration.update(joint_positions_pink) + def _return_current_joint_positions(error: Exception) -> torch.Tensor: + if self.cfg.show_ik_warnings: + print( + "Warning: IK quadratic solver could not find a solution! Did not update the target joint" + f" positions.\nError: {error}" + ) + + if self.cfg.xr_enabled: + from isaaclab.ui.xr_widgets import XRVisualization + + XRVisualization.push_event("ik_error", {"error": error}) + return torch.tensor(curr_controlled_joint_pos, device=self.device, dtype=torch.float32) + # Solve IK using Pink's solver try: velocity = solve_ik( self.pink_configuration, self._variable_input_tasks + self._fixed_input_tasks, dt, - solver="daqp", + solver=_QP_SOLVER, safety_break=self.cfg.fail_on_joint_limit_violation, ) assert not np.isnan(velocity).any(), "Solution to IK contains NaN." joint_angle_changes = velocity * dt + except SolverNotFound as e: + raise RuntimeError( + f"Pink IK requires the '{_QP_SOLVER}' QP solver. Install the Pink IK stack with " + "``./isaaclab.sh -i`` or manually install ``pin pin-pink==3.1.0 daqp==0.8.5``." + ) from e + except TypeError as e: + if "primal_start" in str(e): + raise RuntimeError( + "Pink IK requires a DAQP version compatible with qpsolvers warm-start arguments. " + "Install the Pink IK stack with ``./isaaclab.sh -i`` or manually install " + "``pin pin-pink==3.1.0 daqp==0.8.5``." + ) from e + return _return_current_joint_positions(e) except (AssertionError, Exception) as e: - # Print warning and return the current joint positions as the target - if self.cfg.show_ik_warnings: - print( - "Warning: IK quadratic solver could not find a solution! Did not update the target joint" - f" positions.\nError: {e}" - ) - - if self.cfg.xr_enabled: - from isaaclab.ui.xr_widgets import XRVisualization - - XRVisualization.push_event("ik_error", {"error": e}) - return torch.tensor(curr_controlled_joint_pos, device=self.device, dtype=torch.float32) + return _return_current_joint_positions(e) # Reorder the joint angle changes back to Isaac Lab conventions joint_vel_isaac_lab = torch.tensor( diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index d56f73a34225..cfced25a24aa 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -69,7 +69,7 @@ # required by isaaclab.isaaclab.controllers.pink_ik f"pin ; platform_system == 'Linux' and ({SUPPORTED_ARCHS_ARM})", f"pin-pink==3.1.0 ; platform_system == 'Linux' and ({SUPPORTED_ARCHS_ARM})", - f"daqp==0.7.2 ; platform_system == 'Linux' and ({SUPPORTED_ARCHS_ARM})", + f"daqp==0.8.5 ; platform_system == 'Linux' and ({SUPPORTED_ARCHS_ARM})", ] # Adds OpenUSD dependencies based on architecture for Kit less mode. INSTALL_REQUIRES += [ From 63b1257f9c026a9e0f62ffd59aaa2dbe1e396202 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 06:03:58 +0000 Subject: [PATCH 032/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 4.8.1 → 4.8.2 --- .../isaaclab/changelog.d/fix-pink-ik-daqp-install.rst | 5 ----- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 10 ++++++++++ 3 files changed, 11 insertions(+), 6 deletions(-) delete mode 100644 source/isaaclab/changelog.d/fix-pink-ik-daqp-install.rst diff --git a/source/isaaclab/changelog.d/fix-pink-ik-daqp-install.rst b/source/isaaclab/changelog.d/fix-pink-ik-daqp-install.rst deleted file mode 100644 index e4d25c7ba957..000000000000 --- a/source/isaaclab/changelog.d/fix-pink-ik-daqp-install.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed Pink IK setup checks to reinstall and report the required ``daqp`` - solver when it is missing or incompatible. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 98d55ac40369..338691cd0c00 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.8.1" +version = "4.8.2" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index dda10e77f1f4..186d00855ac4 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +4.8.2 (2026-05-10) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed Pink IK setup checks to reinstall and report the required ``daqp`` + solver when it is missing or incompatible. + + 4.8.1 (2026-05-09) ~~~~~~~~~~~~~~~~~~ From 6dedbb7c2b1e8430a43479c7e14791c25c85c538 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Sun, 10 May 2026 00:37:08 -0700 Subject: [PATCH 033/133] Refactor cloning around cfg-driven ClonePlan (#5528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description This PR a lot simplify cloner logic: Refactors scene cloning so `InteractiveScene` builds a `ClonePlan` directly from asset configuration, rewrites spawner configs to spawn representative sources in their selected environment paths, and then replicates directly from those sources to the remaining destinations. This removes the previous template round trip and hard-deletes `clone_from_template`. This also updates the cloner API around `CloneCfg` and `make_clone_plan`, adds explicit `spawn_paths` support for multi-asset spawners, tightens rigid object collection spawning invariants, and refreshes docs, tests, and changelog coverage for the new planning flow. Fixes # N/A Dependencies: none. ## Type of change - New feature (non-breaking change which adds functionality) - Breaking change (existing functionality will not work without user modification) - Documentation update ## Screenshots N/A. ## Test plan Focused tests were run individually while developing this branch: - `source/isaaclab/test/scene/test_interactive_scene.py` - `source/isaaclab/test/sim/test_cloner.py` - `source/isaaclab/test/sim/test_spawn_wrappers.py` - `source/isaaclab_physx/test/sim/test_cloner.py` - `py_compile` checks for touched Python modules ## 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 added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Cursor --- docs/source/how-to/cloning.rst | 476 +++++++++++------- .../multi_backend_architecture.rst | 2 +- .../clone-plan-visualizer-cleanup.minor.rst | 35 ++ .../octi-cloner_ordering.major.rst | 29 ++ source/isaaclab/isaaclab/cloner/__init__.pyi | 6 +- source/isaaclab/isaaclab/cloner/clone_plan.py | 38 +- source/isaaclab/isaaclab/cloner/cloner_cfg.py | 48 +- .../isaaclab/isaaclab/cloner/cloner_utils.py | 144 +----- .../isaaclab/scene/interactive_scene.py | 249 +++++---- .../isaaclab/sim/simulation_context.py | 23 +- .../sim/spawners/wrappers/wrappers.py | 48 +- .../sim/spawners/wrappers/wrappers_cfg.py | 16 + .../test/scene/test_interactive_scene.py | 266 ++++++++-- source/isaaclab/test/sim/test_cloner.py | 66 +-- ...scene_data_provider_visualizer_contract.py | 120 ++--- .../test_simulation_context_visualizers.py | 2 +- .../isaaclab/test/sim/test_spawn_wrappers.py | 35 ++ .../changelog.d/octi-cloner_ordering.rst | 5 + .../rigid_object_collection.py | 3 +- .../cloner/newton_replicate.py | 20 +- .../changelog.d/octi-cloner_ordering.skip | 0 .../cloner/ovphysx_replicate.py | 12 +- .../changelog.d/octi-cloner_ordering.rst | 5 + .../rigid_object_collection.py | 3 +- .../isaaclab_physx/cloner/physx_replicate.py | 6 +- .../physx_scene_data_provider.py | 75 +-- source/isaaclab_physx/test/sim/test_cloner.py | 60 ++- 27 files changed, 1055 insertions(+), 737 deletions(-) create mode 100644 source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst create mode 100644 source/isaaclab/changelog.d/octi-cloner_ordering.major.rst create mode 100644 source/isaaclab_newton/changelog.d/octi-cloner_ordering.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/octi-cloner_ordering.skip create mode 100644 source/isaaclab_physx/changelog.d/octi-cloner_ordering.rst diff --git a/docs/source/how-to/cloning.rst b/docs/source/how-to/cloning.rst index bad0ff8a5026..ce513cd29497 100644 --- a/docs/source/how-to/cloning.rst +++ b/docs/source/how-to/cloning.rst @@ -5,213 +5,347 @@ Cloning Environments .. currentmodule:: isaaclab -Isaac Lab uses a **template-based cloning** system to efficiently replicate environments for -parallel simulation. Instead of authoring each environment individually on the USD stage, -you define a single template and let the cloner stamp out copies with optional per-environment -variation. +Isaac Lab creates many parallel environments by spawning representative source prims and +then cloning them to the remaining environment paths. This guide starts with direct cloning +so the primitive contract is clear, then shows how :class:`~isaaclab.cloner.ClonePlan` and +:class:`~isaaclab.scene.InteractiveScene` build on top of that contract. -This guide covers the cloning API and how to customize environment creation. +.. contents:: On this page + :local: + :depth: 2 -How Cloning Works ------------------ -The cloning pipeline has three stages: +Direct Cloning +-------------- -1. **Template authoring** -- You place one or more *prototype* prims under a template root - (default ``/World/template``). Each prototype is a variant of an asset (e.g., different robot - configurations or object meshes). +Use direct cloning for custom scene pipelines, tooling, or tests that need explicit +control over the replication contract. -2. **Clone plan** -- The cloner discovers prototypes, enumerates all possible combinations (one - per prototype group), and assigns a combination to each environment using a *strategy*. +The cloner operates on three pieces of data: -3. **Replication** -- The selected prototypes are replicated to per-environment prim paths via - USD spec copying and physics-backend-specific replication. +1. **Source prims** that already exist on the stage. +2. **Destination templates** containing ``{}``, which is formatted with each environment id. +3. **A boolean mask** with shape ``[len(sources), num_envs]`` that selects which source + populates each environment. -Most users interact with cloning indirectly through -:class:`~isaaclab.scene.InteractiveScene`, which calls -:func:`~isaaclab.cloner.clone_from_template` during ``clone_environments()``. -For advanced use cases, you can call the cloning utilities directly. +The direct flow is: - -Basic Usage ------------ - -The simplest case is homogeneous cloning -- every environment gets the same assets: +1. Create the environment namespace prims. +2. Spawn representative source prims. +3. Call the physics replicate function for your backend. +4. Call :func:`~isaaclab.cloner.usd_replicate` with the same source-to-environment mapping. .. code-block:: python - from isaaclab.cloner import TemplateCloneCfg, clone_from_template - from isaaclab.sim import SimulationContext - - sim = SimulationContext() - stage = sim.stage + import torch - # Spawn a single prototype under the template root using a spawner import isaaclab.sim as sim_utils + from isaaclab.cloner import usd_replicate + from isaaclab_physx.cloner import physx_replicate - spawn_cfg = sim_utils.UsdFileCfg(usd_path="path/to/robot.usd") - spawn_cfg.func("/World/template/Robot/proto_asset_0", spawn_cfg) + num_envs = 128 + stage = sim_utils.get_current_stage() + env_ids = torch.arange(num_envs, device="cuda:0") - # Configure and clone - clone_cfg = TemplateCloneCfg(device=sim.cfg.device) - clone_from_template(stage, num_clones=128, template_clone_cfg=clone_cfg) + sim_utils.create_prim("/World/envs", "Xform") + for env_id in range(num_envs): + sim_utils.create_prim(f"/World/envs/env_{env_id}", "Xform") -This creates 128 environments at ``/World/envs/env_0`` through ``/World/envs/env_127``, -each containing a copy of the robot. + source = "/World/envs/env_0/Cube" + destination = "/World/envs/env_{}/Object" + cube_cfg = sim_utils.CuboidCfg(size=(0.5, 0.5, 0.5)) + cube_cfg.func(source, cube_cfg) -Configuration Reference ------------------------ + mask = torch.ones((1, num_envs), dtype=torch.bool, device="cuda:0") -:class:`~isaaclab.cloner.TemplateCloneCfg` controls the cloning behavior: + physx_replicate(stage, [source], [destination], env_ids, mask, device="cuda:0") + usd_replicate(stage, [source], [destination], env_ids, mask) -.. list-table:: - :header-rows: 1 - :widths: 25 15 60 +This creates one source cube at ``/World/envs/env_0/Cube`` and clones it to +``/World/envs/env_1/Object`` through ``/World/envs/env_127/Object``. When a source path is +the same as the destination for an environment, ``usd_replicate`` skips the self-copy. - * - Field - - Default - - Description - * - ``template_root`` - - ``"/World/template"`` - - Root path under which prototype prims are authored. - * - ``template_prototype_identifier`` - - ``"proto_asset"`` - - Name prefix used to discover prototype prims. The cloner finds all prims whose - base name starts with this identifier (e.g., ``proto_asset_0``, ``proto_asset_1``). - * - ``clone_regex`` - - ``"/World/envs/env_.*"`` - - Destination path template. The ``.*`` is replaced with the environment index. - * - ``clone_usd`` - - ``True`` - - Whether to replicate USD prim specs to destination paths. - * - ``clone_physics`` - - ``True`` - - Whether to perform physics-backend-specific replication. - * - ``physics_clone_fn`` - - ``None`` - - Backend-specific physics replication function. Set automatically by - :class:`~isaaclab.scene.InteractiveScene`. - * - ``visualizer_clone_fn`` - - ``None`` - - Optional callback to prebuild visualizer artifacts from the clone plan. - * - ``clone_strategy`` - - ``random`` - - Strategy function for assigning prototypes to environments. See - :ref:`cloning-strategies` below. - * - ``device`` - - ``"cpu"`` - - Torch device for mapping buffers. - * - ``clone_in_fabric`` - - ``False`` - - Enable cloning in Fabric (PhysX only, experimental). +Direct heterogeneous cloning uses the same API with more source rows. Each row in ``mask`` +selects the environments that receive the matching source. For example, this explicit mask +clones a cone into environments 0 and 2, and a sphere into environments 1 and 3: +.. code-block:: python -.. _cloning-strategies: + env_ids = torch.arange(4, device="cuda:0") + sources = ["/World/envs/env_0/Cone", "/World/envs/env_1/Sphere"] + destinations = ["/World/envs/env_{}/Object", "/World/envs/env_{}/Object"] -Cloning Strategies ------------------- + cone_cfg = sim_utils.ConeCfg(radius=0.25, height=0.5) + sphere_cfg = sim_utils.SphereCfg(radius=0.25) + cone_cfg.func(sources[0], cone_cfg) + sphere_cfg.func(sources[1], sphere_cfg) -When multiple prototypes exist in the template, the **clone strategy** determines which -prototype each environment receives. Isaac Lab provides two built-in strategies: + mask = torch.tensor([[True, False, True, False], [False, True, False, True]], dtype=torch.bool) -**Random** (default) + physx_replicate(stage, sources, destinations, env_ids, mask, device="cuda:0") + usd_replicate(stage, sources, destinations, env_ids, mask) -Each environment receives a randomly sampled prototype combination: +The mask above reads as: + +.. list-table:: + :header-rows: 1 + :widths: 15 40 20 25 + + * - Source row + - Source path + - Env ids + - Destination path + * - ``0`` + - ``/World/envs/env_0/Cone`` + - ``0, 2`` + - ``/World/envs/env_{}/Object`` + * - ``1`` + - ``/World/envs/env_1/Sphere`` + - ``1, 3`` + - ``/World/envs/env_{}/Object`` + +``usd_replicate`` copies parent paths before children and supports optional ``positions`` +and ``quaternions`` buffers. If ``positions`` is provided, it authors +``xformOp:translate`` on each destination using the environment id. The helper +:func:`~isaaclab.cloner.grid_transforms` creates the same grid layout used by +:class:`~isaaclab.scene.InteractiveScene`. .. code-block:: python - from isaaclab.cloner import TemplateCloneCfg, random + from isaaclab.cloner import grid_transforms - clone_cfg = TemplateCloneCfg( - clone_strategy=random, + positions, orientations = grid_transforms( + N=num_envs, + spacing=2.0, + up_axis="z", device="cuda:0", ) + usd_replicate(stage, [source], [destination], env_ids, mask, positions=positions) -This is useful for domain randomization and curriculum learning where you want diverse -environments. -**Sequential** +Clone Plans +----------- -Prototypes are assigned in round-robin order (``env_id % num_combinations``): +For one source row, passing ``sources``, ``destinations``, and ``mask`` by hand is simple. +For heterogeneous scenes, the mapping is easier to build with +:func:`~isaaclab.cloner.make_clone_plan`. -.. code-block:: python +:class:`~isaaclab.cloner.ClonePlan` stores the same flat contract used by direct cloning: - from isaaclab.cloner import TemplateCloneCfg, sequential +.. code-block:: text - clone_cfg = TemplateCloneCfg( - clone_strategy=sequential, - device="cuda:0", - ) + sources = [source_0, source_1, ...] + destinations = [destination_0, destination_1, ...] + clone_mask = bool tensor, shape [len(sources), num_envs] -This produces a deterministic, balanced distribution -- useful for reproducible experiments. +``clone_mask[i, j]`` is ``True`` when environment ``j`` should receive source row ``i``. +The same plan can be passed to USD replication, physics replication, and scene-data +providers. -**Custom strategies** can be written as any callable matching the signature -``(combinations: torch.Tensor, num_clones: int, device: str) -> torch.Tensor``, -where ``combinations`` has shape ``(num_combinations, num_groups)`` and the return -value has shape ``(num_clones, num_groups)``. +Homogeneous Plans +~~~~~~~~~~~~~~~~~ +In a homogeneous scene, every environment receives the same asset layout. The default plan +is: -Heterogeneous Environments --------------------------- +.. code-block:: text -To create environments with different assets, place multiple prototypes under the same -group in the template: + sources = ["/World/envs/env_0"] + destinations = ["/World/envs/env_{}"] + clone_mask = all True, shape [1, num_envs] -.. code-block:: python +This means the scene spawns everything for ``env_0`` and replicates that environment to +``env_1`` through ``env_N``. - # Spawn three different object prototypes under the same group - import isaaclab.sim as sim_utils +Heterogeneous Plans +~~~~~~~~~~~~~~~~~~~ - sim_utils.CuboidCfg(size=(0.5, 0.5, 0.5)).func( - "/World/template/Object/proto_asset_0", sim_utils.CuboidCfg(size=(0.5, 0.5, 0.5)) - ) - sim_utils.ConeCfg(radius=0.25, height=0.5).func( - "/World/template/Object/proto_asset_1", sim_utils.ConeCfg(radius=0.25, height=0.5) - ) - sim_utils.SphereCfg(radius=0.25).func( - "/World/template/Object/proto_asset_2", sim_utils.SphereCfg(radius=0.25) - ) +Heterogeneous cloning is used when different environments receive different prototypes. +For example, an object with three variants may have representative source prims at: + +.. code-block:: text - clone_cfg = TemplateCloneCfg( + /World/envs/env_0/Object + /World/envs/env_1/Object + /World/envs/env_2/Object + +These paths have the same leaf name because each variant will be cloned to +``/World/envs/env_{}/Object``, but their authored contents are different. For example, +``env_0/Object`` could be a cone, ``env_1/Object`` a cuboid, and ``env_2/Object`` a sphere. + +The plan maps those source rows to all environments: + +.. code-block:: python + + from isaaclab.cloner import make_clone_plan, sequential + + plan = make_clone_plan( + sources=[ + [ + "/World/envs/env_0/Object", + "/World/envs/env_1/Object", + "/World/envs/env_2/Object", + ] + ], + destinations=["/World/envs/env_{}/Object"], + num_clones=8, clone_strategy=sequential, device="cuda:0", ) - clone_from_template(stage, num_clones=128, template_clone_cfg=clone_cfg) - # env_0 gets Cuboid, env_1 gets Cone, env_2 gets Sphere, env_3 gets Cuboid, ... -When prototypes span multiple groups (e.g., different robots *and* different objects), -the cloner enumerates the Cartesian product of all groups and assigns combinations -using the selected strategy. + # source row used by env: 0, 1, 2, 0, 1, 2, 0, 1 +Direct code can use the plan exactly like the hand-written direct example: -Environment Positioning ------------------------ +.. code-block:: python + + physx_replicate(stage, plan.sources, plan.destinations, env_ids, plan.clone_mask, device="cuda:0") + usd_replicate(stage, plan.sources, plan.destinations, env_ids, plan.clone_mask) + +When variants span multiple groups, such as robot variants and object variants, +``make_clone_plan`` enumerates the Cartesian product of the groups and assigns one +combination per environment. Unused prototype rows may still appear in the plan with an +all-false mask row. + +.. _cloning-strategies: -Environments are arranged in a grid layout using :func:`~isaaclab.cloner.grid_transforms`: +Clone Strategies +~~~~~~~~~~~~~~~~ + +A clone strategy chooses prototype combinations for the environments: + +* :func:`~isaaclab.cloner.random` samples combinations randomly and is the default. +* :func:`~isaaclab.cloner.sequential` assigns combinations in round-robin order, which is + useful for reproducible tests and balanced coverage. + +Custom strategies are callables with this signature: .. code-block:: python - from isaaclab.cloner import grid_transforms + def my_strategy(combinations: torch.Tensor, num_clones: int, device: str) -> torch.Tensor: + ... - positions, orientations = grid_transforms( - N=128, # number of environments - spacing=2.0, # meters between neighbors - up_axis="Z", - device="cuda:0", - ) - # positions: (128, 3), orientations: (128, 4) identity quaternions +``combinations`` has shape ``[num_combinations, num_groups]`` and the return value must have +shape ``[num_clones, num_groups]``. + + +Common Workflow: ``InteractiveScene`` +------------------------------------- + +:class:`~isaaclab.scene.InteractiveScene` automates the direct cloning flow for task scenes. +It inspects scene configuration, builds a :class:`~isaaclab.cloner.ClonePlan`, rewrites +spawner paths to the representative sources, spawns those sources, runs physics and USD +replication, and filters inter-environment collisions for PhysX when configured. + +Put per-environment assets under ``{ENV_REGEX_NS}`` and global assets under normal USD +paths: + +.. code-block:: python + + import isaaclab.sim as sim_utils + from isaaclab.assets import AssetBaseCfg + from isaaclab.scene import InteractiveScene, InteractiveSceneCfg + from isaaclab.utils import configclass + from isaaclab_assets.robots.cartpole import CARTPOLE_CFG + + + @configclass + class MySceneCfg(InteractiveSceneCfg): + # Cloned once per environment. + robot = CARTPOLE_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + # Authored once globally, not cloned per environment. + light = AssetBaseCfg( + prim_path="/World/Light", + spawn=sim_utils.DistantLightCfg(intensity=3000.0), + ) + + + scene_cfg = MySceneCfg(num_envs=128, env_spacing=2.0, replicate_physics=True) + scene = InteractiveScene(cfg=scene_cfg) + +For heterogeneous scenes, use :class:`~isaaclab.sim.spawners.wrappers.MultiAssetSpawnerCfg` +or :class:`~isaaclab.sim.spawners.wrappers.MultiUsdFileCfg`. ``InteractiveScene`` assigns +representative source paths to the spawner and lets the clone strategy choose which +prototype each environment receives. See :doc:`multi_asset_spawning` for the asset +configuration details. -:class:`~isaaclab.scene.InteractiveScene` calls this automatically based on -``InteractiveSceneCfg.env_spacing``. +The most important scene options are on :class:`~isaaclab.scene.InteractiveSceneCfg`: +.. list-table:: + :header-rows: 1 + :widths: 25 15 60 -Collision Filtering -------------------- + * - Field + - Default + - When to change it + * - ``replicate_physics`` + - ``True`` + - Keep enabled for homogeneous environments and fast startup. Disable it when each + environment needs independently authored physics or USD randomization. + * - ``filter_collisions`` + - ``True`` + - Keep enabled for parallel RL so cloned environments do not collide with each other. + This is automatic for PhysX-backed scene cloning. + * - ``clone_in_fabric`` + - ``False`` + - Enables the PhysX Fabric cloning path for faster scene creation. Use USDRT for stage + inspection when Fabric cloning is enabled. -By default, assets in different environments can collide with each other. To prevent -cross-environment collisions (the typical setup for parallel RL), use -:func:`~isaaclab.cloner.filter_collisions`: + +Choosing an API +--------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 45 30 + + * - Goal + - Recommended API + - Notes + * - Build a custom cloning pipeline + - :func:`~isaaclab.cloner.usd_replicate` and a backend physics replicate function + - Useful for tests, tooling, or advanced scene construction. + * - Build complex direct mappings + - :func:`~isaaclab.cloner.make_clone_plan` + - Produces the same ``sources``, ``destinations``, and ``clone_mask`` used by direct cloning. + * - Build normal task scenes + - :class:`~isaaclab.scene.InteractiveScene` + - Preferred path. Configure assets with ``{ENV_REGEX_NS}`` and let the scene clone them. + * - Randomize which asset each environment receives + - ``InteractiveScene`` with :class:`~isaaclab.sim.spawners.wrappers.MultiAssetSpawnerCfg` or + :class:`~isaaclab.sim.spawners.wrappers.MultiUsdFileCfg` + - See :doc:`multi_asset_spawning` for the asset configuration details. + * - Use Isaac Sim's ``GridCloner`` + - Isaac Sim API + - Isaac Lab's tested path is the ``isaaclab.cloner`` API described here. + + +Migrating From Template Cloning +------------------------------- + +The template-root discovery API has been removed. Replace +``clone_from_template(...)`` calls with explicit source prims plus +:func:`~isaaclab.cloner.make_clone_plan`, a backend physics replicate function, and +:func:`~isaaclab.cloner.usd_replicate`. Replace ``TemplateCloneCfg`` with +:class:`~isaaclab.cloner.CloneCfg` for execution settings such as clone strategy, +Fabric cloning, and backend replication. + + +Collision Filtering and Isolation +--------------------------------- + +Some prims, such as terrain, are intentionally shared across environments and should collide +with every environment. These are modeled as global collision paths. The workaround is only +the per-environment filtering: when cloning is fully isolated per world, cloned environments +should not collide with each other and no manual per-environment filter should be needed. +Some PhysX cloning paths still rely on USD collision groups for that isolation fallback. In +the scene workflow this is handled by ``InteractiveScene`` when ``filter_collisions=True`` +and the backend is PhysX. + +For direct PhysX usage, call :func:`~isaaclab.cloner.filter_collisions` after cloning if +per-environment isolation is not already provided by the cloning backend: .. code-block:: python @@ -221,47 +355,43 @@ cross-environment collisions (the typical setup for parallel RL), use stage=stage, physicsscene_path="/physicsScene", collision_root_path="/World/collisions", - prim_paths=[f"/World/envs/env_{i}" for i in range(128)], - global_paths=["/World/defaultGroundPlane"], # collides with all envs + prim_paths=[f"/World/envs/env_{i}" for i in range(num_envs)], + global_paths=["/World/ground"], ) .. note:: - Collision filtering uses PhysX collision groups and is only applicable to the PhysX backend. - The Newton backend handles per-environment isolation through its world system. - + Collision filtering uses PhysX collision groups. Newton handles per-environment isolation + through its own world system. -Physics Backend Replication ---------------------------- -Each physics backend has its own replication function that registers cloned prims with the -physics engine: +Backend and Option Notes +------------------------ -- **PhysX**: :func:`~isaaclab_physx.cloner.physx_replicate` -- Uses the PhysX replicator - interface for fast physics body registration. -- **Newton**: :func:`~isaaclab_newton.cloner.newton_physics_replicate` -- Builds a Newton - ``ModelBuilder`` with per-environment worlds, supporting heterogeneous spawning. +**Physics replication.** :class:`~isaaclab.scene.InteractiveScene` selects the backend +replication function automatically. Direct PhysX users call +:func:`~isaaclab_physx.cloner.physx_replicate`; Newton users call +:func:`~isaaclab_newton.cloner.newton_physics_replicate`. -These functions are set automatically when using :class:`~isaaclab.scene.InteractiveScene`. -For direct usage: - -.. code-block:: python +**``replicate_physics=False``.** Disable physics replication when environments need +independent authored USD or physics state, such as some scale, texture, or color +randomization workflows. Startup and physics parsing are slower because the backend cannot +assume every environment is a clone of the same source. - import torch - from isaaclab_physx.cloner import physx_replicate +**``copy_from_source``.** ``InteractiveScene`` calls +``clone_environments(copy_from_source=True)`` when ``replicate_physics=False``. This skips +backend physics replication and leaves physics parsing to the backend. Spawner-level +``copy_from_source`` is a separate setting used by spawn functions that clone from a source +path matched by a regex. - physx_replicate( - stage=stage, - sources=["/World/envs/env_0/Robot"], - destinations=["/World/envs/env_{}/Robot"], # {} is replaced with env index - env_ids=torch.arange(128), - mapping=torch.ones(1, 128, dtype=torch.bool), - device="cuda:0", - ) +**Fabric cloning.** ``clone_in_fabric=True`` applies to PhysX replication. It can reduce +scene-creation time for large PhysX scenes, especially when many replicated rigid bodies are +authored. Fabric-backed stage data must be inspected through USDRT rather than normal USD +APIs. See Also -------- -- :doc:`multi_asset_spawning` -- spawning different assets per environment -- :doc:`optimize_stage_creation` -- fabric cloning and stage-in-memory optimizations +* :doc:`multi_asset_spawning` -- configuring multi-asset and multi-USD spawners. +* :doc:`optimize_stage_creation` -- Fabric cloning and stage-in-memory optimizations. diff --git a/docs/source/overview/core-concepts/multi_backend_architecture.rst b/docs/source/overview/core-concepts/multi_backend_architecture.rst index 8fd0e326437f..7a0edce5516a 100644 --- a/docs/source/overview/core-concepts/multi_backend_architecture.rst +++ b/docs/source/overview/core-concepts/multi_backend_architecture.rst @@ -56,7 +56,7 @@ This pattern applies to all simulation components: - :class:`~isaaclab_physx.scene_data_providers.PhysxSceneDataProvider` - :class:`~isaaclab_newton.scene_data_providers.NewtonSceneDataProvider` * - Cloner - - :func:`~isaaclab.cloner.clone_from_template` + - :func:`~isaaclab.cloner.usd_replicate` - :func:`~isaaclab_physx.cloner.physx_replicate` - :func:`~isaaclab_newton.cloner.newton_physics_replicate` diff --git a/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst b/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst new file mode 100644 index 000000000000..c9ceb9405226 --- /dev/null +++ b/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst @@ -0,0 +1,35 @@ +Added +^^^^^ + +* Added :class:`~isaaclab.cloner.ClonePlan` as the flat clone contract shared by + scene cloning, backend replication, and scene-data providers. +* Added :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` and + :meth:`~isaaclab.sim.SimulationContext.set_clone_plan` for publishing the + scene's clone plan. +* Added :attr:`~isaaclab.scene.InteractiveScene.clone_plan` for consumers holding + a scene reference. + +Changed +^^^^^^^ + +* **Breaking:** Changed scene-data providers to build visualizer backend models + from :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` instead of a + clone-time visualizer artifact. Use the published + :class:`~isaaclab.cloner.ClonePlan` for custom scene-data integrations. + +Removed +^^^^^^^ + +* **Breaking:** Removed + :attr:`~isaaclab.cloner.TemplateCloneCfg.visualizer_clone_fn`, + :func:`~isaaclab.cloner.resolve_visualizer_clone_fn`, and + :class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts`. + Use the :class:`~isaaclab.cloner.ClonePlan` published through + :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` instead. +* **Breaking:** Removed + :meth:`~isaaclab.sim.SimulationContext.get_scene_data_visualizer_prebuilt_artifact`, + :meth:`~isaaclab.sim.SimulationContext.set_scene_data_visualizer_prebuilt_artifact`, + and + :meth:`~isaaclab.sim.SimulationContext.clear_scene_data_visualizer_prebuilt_artifact`. + Use :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` / + :meth:`~isaaclab.sim.SimulationContext.set_clone_plan` instead. diff --git a/source/isaaclab/changelog.d/octi-cloner_ordering.major.rst b/source/isaaclab/changelog.d/octi-cloner_ordering.major.rst new file mode 100644 index 000000000000..fd0906e5cd66 --- /dev/null +++ b/source/isaaclab/changelog.d/octi-cloner_ordering.major.rst @@ -0,0 +1,29 @@ +Added +^^^^^ + +* Added explicit ``spawn_paths`` support to multi-asset spawners so scene + planning can spawn representative heterogeneous sources directly. + +Changed +^^^^^^^ + +* **Breaking:** Changed :class:`~isaaclab.scene.InteractiveScene` to build clone + plans directly from asset configuration, spawn representative sources in their + selected environments, and replicate from those sources instead of spawning and + discovering prototypes under ``/World/template``. +* **Breaking:** Replaced ``TemplateCloneCfg`` with + :class:`~isaaclab.cloner.CloneCfg` for clone execution settings. +* **Breaking:** Changed :func:`~isaaclab.cloner.make_clone_plan` to return a + :class:`~isaaclab.cloner.ClonePlan` object directly. +* **Breaking:** Changed clone plan publication to use + :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` and + :meth:`~isaaclab.sim.SimulationContext.set_clone_plan` for the single scene + clone plan. + +Removed +^^^^^^^ + +* **Breaking:** Removed :func:`~isaaclab.cloner.clone_from_template`. Use + :func:`~isaaclab.cloner.make_clone_plan`, + :func:`~isaaclab.cloner.usd_replicate`, and backend physics replication + functions for direct cloning workflows. diff --git a/source/isaaclab/isaaclab/cloner/__init__.pyi b/source/isaaclab/isaaclab/cloner/__init__.pyi index 8319388a8108..1ee123e7cf56 100644 --- a/source/isaaclab/isaaclab/cloner/__init__.pyi +++ b/source/isaaclab/isaaclab/cloner/__init__.pyi @@ -4,11 +4,10 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "CloneCfg", "ClonePlan", - "TemplateCloneCfg", "random", "sequential", - "clone_from_template", "disabled_fabric_change_notifies", "filter_collisions", "grid_transforms", @@ -17,10 +16,9 @@ __all__ = [ ] from .clone_plan import ClonePlan -from .cloner_cfg import TemplateCloneCfg +from .cloner_cfg import CloneCfg from .cloner_strategies import random, sequential from .cloner_utils import ( - clone_from_template, disabled_fabric_change_notifies, filter_collisions, grid_transforms, diff --git a/source/isaaclab/isaaclab/cloner/clone_plan.py b/source/isaaclab/isaaclab/cloner/clone_plan.py index 4a765463b32d..9dee97c68d55 100644 --- a/source/isaaclab/isaaclab/cloner/clone_plan.py +++ b/source/isaaclab/isaaclab/cloner/clone_plan.py @@ -5,35 +5,29 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass import torch -@dataclass(frozen=True) +@dataclass(frozen=True, eq=False) class ClonePlan: - """Per-group mapping from prototype prims to per-environment clones. - - Produced by :func:`~isaaclab.cloner.clone_from_template` for each prototype group it - discovers under the template root. Lets downstream consumers (e.g. mesh samplers, - ray-cast sensors) read prototype geometry once and scatter to environments via - :attr:`clone_mask` instead of walking per-env USD paths. - - Attributes are population-time invariants and the dataclass is frozen. Hash and - equality operate on :attr:`dest_template` only (the natural identity — it is the key - in :attr:`SimulationContext.get_clone_plans`); the mutable list/tensor fields are - excluded since ``torch.Tensor`` is not hashable and structural equality is rarely the - semantics consumers want. + """Flat cloning source of truth. + + Produced by scene planning after representative source prims are assigned. The + three fields are the same flat replication contract consumed by USD, physics, + and downstream scene-data providers: each source path maps to the destination + template at the same index, and :attr:`clone_mask` selects the environments + populated from that source. """ - dest_template: str - """Destination path template for this group, e.g. ``"/World/envs/env_{}/Object"``.""" + sources: tuple[str, ...] + """Source prim paths used for replication.""" - prototype_paths: list[str] = field(hash=False, compare=False) - """Prototype prim paths in this group, e.g. - ``["/World/template/Object/proto_asset_0", "/World/template/Object/proto_asset_1"]``.""" + destinations: tuple[str, ...] + """Destination path templates, one per source path.""" - clone_mask: torch.Tensor = field(hash=False, compare=False) - """Boolean tensor of shape ``[num_prototypes_in_group, num_envs]``; + clone_mask: torch.Tensor + """Boolean tensor of shape ``[len(sources), num_envs]``; ``clone_mask[i, j]`` is ``True`` iff env ``j`` was populated from - :attr:`prototype_paths` ``[i]``. Each column sums to exactly one.""" + :attr:`sources` ``[i]``.""" diff --git a/source/isaaclab/isaaclab/cloner/cloner_cfg.py b/source/isaaclab/isaaclab/cloner/cloner_cfg.py index 19decec0c011..369f70e33520 100644 --- a/source/isaaclab/isaaclab/cloner/cloner_cfg.py +++ b/source/isaaclab/isaaclab/cloner/cloner_cfg.py @@ -11,52 +11,14 @@ @configclass -class TemplateCloneCfg: - """Configuration for template-based cloning. +class CloneCfg: + """Configuration for environment replication. - This configuration is consumed by :func:`~isaaclab.scene.cloner.clone_from_template` to - replicate one or more "prototype" prims authored under a template root into multiple - per-environment destinations. It supports both USD-spec replication and PhysX replication - and allows choosing between random or round-robin prototype assignment across environments. - - The cloning flow is: - - 1. Discover prototypes under :attr:`template_root` whose base name starts with - :attr:`template_prototype_identifier` (for example, ``proto_asset_0``, ``proto_asset_1``). - 2. Build a per-prototype mapping to environments according to - :attr:`random_heterogeneous_cloning` (random) or modulo assignment (deterministic). - 3. Stamp the selected prototypes to destinations derived from :attr:`clone_regex`. - 4. Optionally perform PhysX replication for the same mapping. - - Example - ------- - - .. code-block:: python - - from isaaclab.cloner import TemplateCloneCfg, clone_from_template - from isaaclab.sim.utils.stage import get_current_stage - - stage = get_current_stage() - cfg = TemplateCloneCfg( - num_clones=128, - template_root="/World/template", - template_prototype_identifier="proto_asset", - clone_regex="/World/envs/env_.*", - clone_usd=True, - clone_physics=True, - random_heterogeneous_cloning=False, # use round-robin mapping - device="cpu", - ) - - clone_from_template(stage, num_clones=cfg.num_clones, template_clone_cfg=cfg) + The scene builds a :class:`~isaaclab.cloner.ClonePlan` directly from asset + configuration, spawns the representative source prims, and then uses this + configuration to dispatch USD and physics replication for that plan. """ - template_root: str = "/World/template" - """Root path under which template prototypes are authored.""" - - template_prototype_identifier: str = "proto_asset" - """Name prefix used to identify prototype prims under :attr:`template_root`.""" - clone_regex: str = "/World/envs/env_.*" """Destination template for per-environment paths. diff --git a/source/isaaclab/isaaclab/cloner/cloner_utils.py b/source/isaaclab/isaaclab/cloner/cloner_utils.py index 717d020a90ce..337fad42f45f 100644 --- a/source/isaaclab/isaaclab/cloner/cloner_utils.py +++ b/source/isaaclab/isaaclab/cloner/cloner_utils.py @@ -9,20 +9,13 @@ import itertools import logging import math -from collections.abc import Iterator -from typing import TYPE_CHECKING +from collections.abc import Iterator, Sequence import torch from pxr import Gf, Sdf, Usd, UsdGeom, UsdUtils, Vt -import isaaclab.sim as sim_utils - from . import _fabric_notices - -if TYPE_CHECKING: - from .cloner_cfg import TemplateCloneCfg - from .clone_plan import ClonePlan logger = logging.getLogger(__name__) @@ -105,118 +98,13 @@ def disabled_fabric_change_notifies(stage: Usd.Stage, *, restore: bool = True) - bindings.set_enable(fabric_id, True) -def clone_from_template( - stage: Usd.Stage, num_clones: int, template_clone_cfg: TemplateCloneCfg -) -> dict[str, ClonePlan]: - """Clone assets from a template root into per-environment destinations. - - This utility discovers prototype prims under ``cfg.template_root`` whose names start with - ``cfg.template_prototype_identifier``, builds a per-prototype mapping across - ``num_clones`` environments (random or modulo), and then performs USD and/or PhysX replication - according to the flags in ``cfg``. - - Args: - stage: The USD stage to author into. - num_clones: Number of environments to clone to (typically equals ``cfg.num_clones``). - template_clone_cfg: Configuration describing template location, destination pattern, - and replication/mapping behavior. - - Returns: - Mapping from each group's destination template (e.g. ``"/World/envs/env_{}/Object"``) - to its :class:`ClonePlan`. Empty when no prototype groups are discovered. - - Note: - This function suspends the Fabric USD notice listener for the duration of the call - and **leaves it disabled on return**. It is intended to be invoked from a scene-init - path that is followed by :meth:`isaaclab.sim.SimulationContext.reset`, whose Fabric - resync naturally recovers the listener state. Callers that bypass that reset - contract (ad-hoc tooling, unit tests on a bare stage) should re-enable Fabric - notices themselves or wrap the call in - :func:`disabled_fabric_change_notifies` with ``restore=True``. - """ - cfg: TemplateCloneCfg = template_clone_cfg - plans: dict[str, ClonePlan] = {} - # Suspend Fabric's USD notice listener for the duration of bulk authoring. ``restore=False`` - # because clone_from_template is only called at scene-init time, which is followed by - # ``SimulationContext.reset`` — that reset path does the Fabric resync naturally, and - # re-enabling here would trigger a redundant ``forceMinimalPopulate`` batch. - with disabled_fabric_change_notifies(stage, restore=False): - world_indices = torch.arange(num_clones, device=cfg.device) - clone_path_fmt = cfg.clone_regex.replace(".*", "{}") - prototype_id = cfg.template_prototype_identifier - prototypes = sim_utils.get_all_matching_child_prims( - cfg.template_root, - predicate=lambda prim: str(prim.GetPath()).split("/")[-1].startswith(prototype_id), - ) - if len(prototypes) > 0: - # Canonicalize prototype-root order. Some simulation/visualization backends might apply order-dependent - # processing, so varying USD traversal or set iteration order can change outputs noticeably. Sorting here - # removes that nondeterminism at the source (group order feeds ``make_clone_plan`` and downstream - # replication), which matters for run-to-run reproducibility across IsaacLab's multi-backend stack. - prototype_roots = sorted({"/".join(str(prototype.GetPath()).split("/")[:-1]) for prototype in prototypes}) - - # discover prototypes per root then make a clone plan - src: list[list[str]] = [] - dest: list[str] = [] - - for prototype_root in prototype_roots: - protos = sim_utils.find_matching_prim_paths(f"{prototype_root}/.*") - protos = [proto for proto in protos if proto.split("/")[-1].startswith(prototype_id)] - src.append(protos) - dest.append(prototype_root.replace(cfg.template_root, clone_path_fmt)) - - src_paths, dest_paths, clone_masking = make_clone_plan( - src, dest, num_clones, cfg.clone_strategy, cfg.device - ) - - # Per-group plans: slice ``clone_masking`` along the prototype axis using cumulative - # group sizes — each group's mask rows are contiguous in the ``[total_protos, num_envs]`` - # tensor that ``make_clone_plan`` produced. - offsets = [0, *itertools.accumulate(len(g) for g in src)] - plans = { - d: ClonePlan(dest_template=d, prototype_paths=list(ps), clone_mask=clone_masking[lo:hi]) - for ps, d, lo, hi in zip(src, dest, offsets, offsets[1:]) - } - - # Spawn the first instance of clones from prototypes, then deactivate the prototypes, those first - # instances will be served as sources for usd and physics replication. - proto_idx = clone_masking.to(torch.int32).argmax(dim=1) - proto_mask = torch.zeros_like(clone_masking) - proto_mask.scatter_(1, proto_idx.view(-1, 1).to(torch.long), clone_masking.any(dim=1, keepdim=True)) - usd_replicate(stage, src_paths, dest_paths, world_indices, proto_mask) - stage.GetPrimAtPath(cfg.template_root).SetActive(False) - get_pos = lambda path: stage.GetPrimAtPath(path).GetAttribute("xformOp:translate").Get() # noqa: E731 - positions = torch.tensor([get_pos(clone_path_fmt.format(i)) for i in world_indices]) - # Heterogeneous default: emit per-prototype (sources, destinations, mask) and trust - # env_0..N's existing xforms (proto-spawn above already placed them, so don't - # re-author). When every env happens to pick prototype 0, collapse below to a - # single env_0 → all-envs copy and re-author positions (the destination subtree - # replaces env_1..N's prior xform). - sources = [tpl.format(int(idx)) for tpl, idx in zip(dest_paths, proto_idx.tolist())] - usd_positions: torch.Tensor | None = None - if torch.all(proto_idx == 0): - sources = [clone_path_fmt.format(0)] - dest_paths = [clone_path_fmt] - clone_masking = clone_masking.new_ones(1, num_clones) - usd_positions = positions - - if cfg.clone_physics and cfg.physics_clone_fn is not None: - cfg.physics_clone_fn( - stage, sources, dest_paths, world_indices, clone_masking, positions=positions, device=cfg.device - ) - if cfg.clone_usd: - usd_replicate(stage, sources, dest_paths, world_indices, clone_masking, positions=usd_positions) - - return plans - - def make_clone_plan( - sources: list[list[str]], - destinations: list[str], + sources: Sequence[Sequence[str]], + destinations: Sequence[str], num_clones: int, clone_strategy: callable, device: str = "cpu", -) -> tuple[list[str], list[str], torch.Tensor]: +) -> ClonePlan: """Construct a cloning plan mapping prototype prims to per-environment destinations. The plan enumerates all combinations of prototypes, selects a combination per environment using ``clone_strategy``, @@ -231,14 +119,20 @@ def make_clone_plan( device: Torch device for tensors in the plan. Defaults to ``"cpu"``. Returns: - tuple: ``(src, dest, masking)`` where ``src`` and ``dest`` are flattened lists of prototype and - destination paths, and ``masking`` is a ``[num_src, num_clones]`` boolean tensor with True - when source ``src[i]`` is used for clone ``j``. + A :class:`ClonePlan` whose ``sources`` and ``destinations`` are flattened per-source rows and + whose ``clone_mask`` is a ``[num_src, num_clones]`` boolean tensor. """ - # 1) Flatten into src and dest lists - src = [p for group in sources for p in group] - dest = [dst for dst, group in zip(destinations, sources) for _ in group] + if len(sources) != len(destinations): + raise ValueError(f"Expected one destination per source group, got {len(destinations)} and {len(sources)}.") + if not sources: + raise ValueError("Expected at least one source group.") group_sizes = [len(group) for group in sources] + if any(size == 0 for size in group_sizes): + raise ValueError("Source groups must not be empty.") + + # 1) Flatten into src and dest lists + src = tuple(p for group in sources for p in group) + dest = tuple(dst for dst, group in zip(destinations, sources) for _ in group) # 2) Enumerate all combinations of "one prototype per group" # all_combos: list of tuples (g0_idx, g1_idx, ..., g_{G-1}_idx) @@ -256,13 +150,13 @@ def make_clone_plan( masking = torch.zeros((sum(group_sizes), num_clones), dtype=torch.bool, device=device) masking[rows, cols] = True - return src, dest, masking + return ClonePlan(sources=src, destinations=dest, clone_mask=masking) def usd_replicate( stage: Usd.Stage, - sources: list[str], - destinations: list[str], + sources: Sequence[str], + destinations: Sequence[str], env_ids: torch.Tensor, mask: torch.Tensor | None = None, positions: torch.Tensor | None = None, diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index ce744fe4bffe..ae39e3daa719 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -159,7 +159,7 @@ def __init__(self, cfg: InteractiveSceneCfg): # prepare cloner for environment replication self.env_prim_paths = [f"{self.env_ns}/env_{i}" for i in range(self.cfg.num_envs)] - self.cloner_cfg = cloner.TemplateCloneCfg( + self.cloner_cfg = cloner.CloneCfg( clone_regex=self.env_regex_ns, clone_in_fabric=self.cfg.clone_in_fabric, device=self.device, @@ -172,7 +172,6 @@ def __init__(self, cfg: InteractiveSceneCfg): # create source prim self.stage.DefinePrim(self.env_prim_paths[0], "Xform") - self.stage.DefinePrim(self.cloner_cfg.template_root, "Xform") self.env_fmt = self.env_regex_ns.replace(".*", "{}") # allocate env indices self._ALL_INDICES = torch.arange(self.cfg.num_envs, dtype=torch.long, device=self.device) @@ -195,7 +194,14 @@ def __init__(self, cfg: InteractiveSceneCfg): self._global_prim_paths = list() has_scene_cfg_entities = self._is_scene_setup_from_cfg() if has_scene_cfg_entities: + self._clone_plan = self._build_clone_plan_from_cfg() self._add_entities_from_cfg() + else: + self._clone_plan = cloner.ClonePlan( + sources=(self.env_fmt.format(0),), + destinations=(self.env_fmt,), + clone_mask=torch.ones((1, self.num_envs), device=self.device, dtype=torch.bool), + ) # Aggregate scene-data requirements from declared visualizers and constructed sensors, # then publish to ``SimulationContext`` so downstream providers (constructed later by @@ -209,6 +215,84 @@ def __init__(self, cfg: InteractiveSceneCfg): if self.cfg.filter_collisions and "physx" in self.physics_backend: self.filter_collisions(self._global_prim_paths) + def _build_clone_plan_from_cfg(self) -> cloner.ClonePlan | None: + """Build a clone plan from scene cfg spawn variants and write planned spawn paths. + + Returns ``None`` when the cfg has no env-scoped spawned assets. + """ + + def num_variants(spawn_cfg) -> int: + if isinstance(spawn_cfg, sim_utils.MultiAssetSpawnerCfg): + return len(spawn_cfg.assets_cfg) + if isinstance(spawn_cfg, sim_utils.MultiUsdFileCfg): + return 1 if isinstance(spawn_cfg.usd_path, str) else len(spawn_cfg.usd_path) + return 1 + + def set_spawn_paths(spawn_cfg, paths: list[str | None]) -> None: + if isinstance(spawn_cfg, (sim_utils.MultiAssetSpawnerCfg, sim_utils.MultiUsdFileCfg)): + spawn_cfg.spawn_paths = paths + else: + active = [path for path in paths if path is not None] + if len(active) != 1: + raise ValueError("Single spawner expects exactly one planned source path.") + spawn_cfg.spawn_path = active[0] + + cfg_fields = InteractiveSceneCfg.__dataclass_fields__ + items = [(k, v) for k, v in self.cfg.__dict__.items() if k not in cfg_fields and v is not None] + ordered_items = [item for item in items if not isinstance(item[1], SensorBaseCfg)] + ordered_items += [item for item in items if isinstance(item[1], SensorBaseCfg)] + + # One group is one prim path template plus its spawn variants. + groups = [] + for _, asset_cfg in ordered_items: + cfgs = asset_cfg.rigid_objects.values() if isinstance(asset_cfg, RigidObjectCollectionCfg) else [asset_cfg] + for cfg in (cfg for cfg in cfgs if hasattr(cfg, "prim_path")): + prim_path = cfg.prim_path.format(ENV_REGEX_NS=self.env_regex_ns) + if not hasattr(cfg, "spawn") or cfg.spawn is None or self.env_ns not in prim_path: + continue + if (count := num_variants(cfg.spawn)) <= 0: + raise ValueError(f"Spawner at '{prim_path}' must have at least one variant.") + groups.append((cfg.spawn, prim_path.replace(self.env_regex_ns, self.env_fmt), count)) + + if not groups: + return None + + # Homogeneous scenes still spawn sources at env_0, but publish the simpler env-root plan. + if all(count == 1 for _, _, count in groups): + for spawn_cfg, destination, _ in groups: + set_spawn_paths(spawn_cfg, [destination.format(0)]) + return cloner.ClonePlan( + sources=(self.env_fmt.format(0),), + destinations=(self.env_fmt,), + clone_mask=torch.ones((1, self.num_envs), device=self.device, dtype=torch.bool), + ) + + plan = cloner.make_clone_plan( + [[destination.format(i) for i in range(count)] for _, destination, count in groups], + [destination for _, destination, _ in groups], + self.num_envs, + self.cloner_cfg.clone_strategy, + self.device, + ) + + # Move each planned source row to the first environment that actually uses it. + row = 0 + sources = list(plan.sources) + for spawn_cfg, destination, count in groups: + mask = plan.clone_mask[row : row + count] + env_ids = mask.to(torch.int).argmax(dim=1).tolist() + active = mask.any(dim=1).tolist() + paths = [destination.format(env_id) if is_active else None for env_id, is_active in zip(env_ids, active)] + for i, path in zip(range(row, row + count), paths): + if path is not None: + sources[i] = path + set_spawn_paths(spawn_cfg, paths) + row += count + + plan = cloner.ClonePlan(sources=tuple(sources), destinations=plan.destinations, clone_mask=plan.clone_mask) + logger.debug("Built heterogeneous ClonePlan with %d source rows.", len(plan.sources)) + return plan + def clone_environments(self, copy_from_source: bool = False): """Creates clones of the environment ``/World/envs/env_0``. @@ -217,52 +301,44 @@ def clone_environments(self, copy_from_source: bool = False): If True, clones are independent copies of the source prim and won't reflect its changes (start-up time may increase). Defaults to False. """ + plan = self._clone_plan + assert self.sim is not None + if plan is None: + self.sim.set_clone_plan(None) + return + # PhysX-only: set env id bit count for replicated physics. Newton handles env separation in its own API. # Intentionally matches both physx and ovphysx (both are PhysX-based) if self.cfg.replicate_physics and "physx" in self.physics_backend: prim = self.stage.GetPrimAtPath("/physicsScene") prim.CreateAttribute("physxScene:envIdInBoundsBitCount", Sdf.ValueTypeNames.Int).Set(4) - # Suspend Fabric's USD notice listener around bulk authoring (re-entrant with the inner - # call inside :func:`clone_from_template`). ``restore=False`` because the downstream - # ``SimulationContext.reset`` does the Fabric resync — re-enabling here would batch-resync - # everything we just authored, which is slower than the unsuppressed baseline. + # Suspend Fabric's USD notice listener around bulk authoring. ``restore=False`` because the downstream + # ``SimulationContext.reset`` does the Fabric resync — re-enabling here would batch-resync everything + # we just authored, which is slower than the unsuppressed baseline. with cloner.disabled_fabric_change_notifies(self.stage, restore=False): - if self._is_scene_setup_from_cfg(): - self.cloner_cfg.clone_physics = not copy_from_source - plans = cloner.clone_from_template( - self.stage, num_clones=self.num_envs, template_clone_cfg=self.cloner_cfg + replicate_args = (plan.sources, plan.destinations, self._ALL_INDICES, plan.clone_mask) + + if not copy_from_source and self.cloner_cfg.physics_clone_fn is not None: + self.cloner_cfg.physics_clone_fn( + self.stage, + *replicate_args, + positions=self._default_env_origins, + device=self.cloner_cfg.device, ) - else: - mapping = torch.ones((1, self.num_envs), device=self.device, dtype=torch.bool) - replicate_args = ( - [self.env_fmt.format(0)], - [self.env_fmt], - self._ALL_INDICES, - mapping, + if self.cloner_cfg.clone_usd: + is_env_root_plan = ( + len(plan.sources) == 1 + and plan.sources[0] == self.env_fmt.format(0) + and plan.destinations == (self.env_fmt,) ) + usd_positions = self._default_env_origins if is_env_root_plan else None + cloner.usd_replicate(self.stage, *replicate_args, positions=usd_positions) - if not copy_from_source and self.cloner_cfg.physics_clone_fn is not None: - self.cloner_cfg.physics_clone_fn( - self.stage, *replicate_args, positions=self._default_env_origins, device=self.cloner_cfg.device - ) - if self.cloner_cfg.clone_usd: - cloner.usd_replicate(self.stage, *replicate_args, positions=self._default_env_origins) - # Synthesize a single trivial ClonePlan so consumers (scene data providers, - # pointcloud samplers, etc.) get a uniform interface regardless of whether - # the scene was authored via prototypes or by hand under env_0. - plans = { - self.env_fmt: cloner.ClonePlan( - dest_template=self.env_fmt, - prototype_paths=[self.env_fmt.format(0)], - clone_mask=mapping, - ) - } - - # Publish to ``SimulationContext`` (the canonical owner). The :attr:`clone_plans` - # property below forwards reads back through ``sim.get_clone_plans()`` so consumers - # holding a scene reference still see the published plans without a duplicate cache. - self.sim.set_clone_plans(plans) + # Publish to ``SimulationContext`` (the canonical owner). The :attr:`clone_plan` + # property below forwards reads back through ``sim.get_clone_plan()`` so consumers + # holding a scene reference still see the published plan without a duplicate cache. + self.sim.set_clone_plan(plan) def _aggregate_scene_data_requirements(self, visualizer_types=()) -> None: """Aggregate scene-data requirements from visualizers and sensor renderers. @@ -427,16 +503,14 @@ def surface_grippers(self) -> dict[str, SurfaceGripper]: return self._surface_grippers @property - def clone_plans(self) -> dict[str, cloner.ClonePlan]: - """Per-group clone plans produced by :meth:`clone_environments`. - - Forwards to :meth:`SimulationContext.get_clone_plans`, which is the canonical owner. - Keyed by each group's destination path template - (e.g. ``"/World/envs/env_{}/Object"``); the value records the prototype prim paths - and the per-env prototype assignment mask. Empty until :meth:`clone_environments` - runs, and (for the cfg path) empty when the scene cfg has no template prototypes. + def clone_plan(self) -> cloner.ClonePlan | None: + """Clone plan produced by :meth:`clone_environments`. + + Forwards to :meth:`SimulationContext.get_clone_plan`, which is the canonical owner. + The plan records the source paths, destination templates, and the per-env source + assignment mask. ``None`` until :meth:`clone_environments` runs. """ - return self.sim.get_clone_plans() + return self.sim.get_clone_plan() @property def extras(self) -> dict[str, FrameView]: @@ -772,30 +846,20 @@ def _add_entities_from_cfg(self): # noqa: C901 ] for asset_name, asset_cfg in ordered_items: - # Resolve old-style preset wrappers: configclass with a ``presets`` dict and a ``'default'`` key. - # These are multi-backend selector objects (e.g. VelocityEnvContactSensorCfg) that hold several - # alternative asset configs in a dict and are not themselves asset configs. - if hasattr(asset_cfg, "presets") and isinstance(asset_cfg.presets, dict) and "default" in asset_cfg.presets: - asset_cfg = asset_cfg.presets["default"] - setattr(self.cfg, asset_name, asset_cfg) # resolve prim_path with env regex if hasattr(asset_cfg, "prim_path"): asset_cfg.prim_path = asset_cfg.prim_path.format(ENV_REGEX_NS=self.env_regex_ns) # set spawn_path on spawner if cloning is needed if hasattr(asset_cfg, "spawn") and asset_cfg.spawn is not None: - if hasattr(asset_cfg, "prim_path") and self.env_ns in asset_cfg.prim_path: - template_base = asset_cfg.prim_path.replace(self.env_regex_ns, self.cloner_cfg.template_root) - proto_id = self.cloner_cfg.template_prototype_identifier - if isinstance(asset_cfg, SensorBaseCfg): - # Sensor may be nested under a proto_asset_N prim (e.g. a camera on a robot - # link). Search for the actual template location so spawning succeeds even - # though the parent asset lives at template_root//proto_asset_0/... - asset_cfg.spawn.spawn_path = self._resolve_sensor_template_spawn_path(template_base, proto_id) - else: - asset_cfg.spawn.spawn_path = f"{template_base}/{proto_id}_.*" - else: - # No cloning - spawn directly at prim_path + is_multi_spawner = isinstance( + asset_cfg.spawn, (sim_utils.MultiAssetSpawnerCfg, sim_utils.MultiUsdFileCfg) + ) + if self.env_ns not in asset_cfg.prim_path: asset_cfg.spawn.spawn_path = asset_cfg.prim_path + elif is_multi_spawner and not asset_cfg.spawn.spawn_paths: + raise RuntimeError(f"Clone planning did not assign spawn_paths for '{asset_cfg.prim_path}'.") + elif not is_multi_spawner and asset_cfg.spawn.spawn_path is None: + raise RuntimeError(f"Clone planning did not assign spawn_path for '{asset_cfg.prim_path}'.") # create asset if isinstance(asset_cfg, TerrainImporterCfg): # terrains are special entities since they define environment origins @@ -813,14 +877,19 @@ def _add_entities_from_cfg(self): # noqa: C901 rigid_object_cfg.prim_path = rigid_object_cfg.prim_path.format(ENV_REGEX_NS=self.env_regex_ns) # set spawn_path on spawner if cloning is needed if hasattr(rigid_object_cfg, "spawn") and rigid_object_cfg.spawn is not None: - if self.env_ns in rigid_object_cfg.prim_path: - spawn_tmpl = rigid_object_cfg.prim_path.replace( - self.env_regex_ns, self.cloner_cfg.template_root - ) - proto_id = self.cloner_cfg.template_prototype_identifier - rigid_object_cfg.spawn.spawn_path = f"{spawn_tmpl}/{proto_id}_.*" - else: + is_multi_spawner = isinstance( + rigid_object_cfg.spawn, (sim_utils.MultiAssetSpawnerCfg, sim_utils.MultiUsdFileCfg) + ) + if self.env_ns not in rigid_object_cfg.prim_path: rigid_object_cfg.spawn.spawn_path = rigid_object_cfg.prim_path + elif is_multi_spawner and not rigid_object_cfg.spawn.spawn_paths: + raise RuntimeError( + f"Clone planning did not assign spawn_paths for '{rigid_object_cfg.prim_path}'." + ) + elif not is_multi_spawner and rigid_object_cfg.spawn.spawn_path is None: + raise RuntimeError( + f"Clone planning did not assign spawn_path for '{rigid_object_cfg.prim_path}'." + ) self._rigid_object_collections[asset_name] = asset_cfg.class_type(asset_cfg) for rigid_object_cfg in asset_cfg.rigid_objects.values(): if hasattr(rigid_object_cfg, "collision_group") and rigid_object_cfg.collision_group == -1: @@ -882,39 +951,3 @@ def _add_entities_from_cfg(self): # noqa: C901 if hasattr(asset_cfg, "collision_group") and asset_cfg.collision_group == -1: asset_paths = sim_utils.find_matching_prim_paths(asset_cfg.prim_path) self._global_prim_paths += asset_paths - - def _resolve_sensor_template_spawn_path(self, template_base: str, proto_id: str) -> str: - """Resolve the actual template spawn path for a sensor nested under a proto_asset prim. - - Sensors parented to robot links live inside ``proto_asset_0`` rather than directly under - the template root. For example, a wrist camera at - ``/World/template/Robot/panda_hand/wrist_cam`` is actually spawned at - ``/World/template/Robot/proto_asset_0/panda_hand/wrist_cam``. - - This method inserts a ``proto_id_.*`` wildcard one level below the template root and - searches for the concrete parent prim so the camera spawner can find it. - - Args: - template_base: Template path derived by replacing the env regex with the template root. - Example: ``/World/template/Robot/panda_hand/wrist_cam``. - proto_id: Prototype identifier prefix (e.g. ``proto_asset``). - - Returns: - Concrete spawn path (e.g. ``/World/template/Robot/proto_asset_0/panda_hand/wrist_cam``) - if the parent is found, otherwise ``template_base/proto_id_.*`` as a fallback. - """ - template_root = self.cloner_cfg.template_root - # rel = e.g. "Robot/panda_hand/wrist_cam" - rel = template_base[len(template_root) + 1 :] - # asset = "Robot", remainder = "panda_hand/wrist_cam" - asset, _, remainder = rel.partition("/") - if not remainder: - return f"{template_base}/{proto_id}_.*" - - # parent = "panda_hand", leaf = "wrist_cam" - parent, _, leaf = remainder.rpartition("/") - search = ( - f"{template_root}/{asset}/{proto_id}_.*/{parent}" if parent else f"{template_root}/{asset}/{proto_id}_.*" - ) - found = sim_utils.find_matching_prim_paths(search) - return f"{found[0]}/{leaf}" if found else f"{template_base}/{proto_id}_.*" diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index d5ef6f64e9c5..121b01fdb622 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -176,11 +176,10 @@ def __init__(self, cfg: SimulationCfg | None = None): self._scene_data_provider: BaseSceneDataProvider | None = None self._visualizers: list[BaseVisualizer] = [] self._scene_data_requirements = SceneDataRequirement() - # Per-group clone plans published by InteractiveScene after cloning. Providers (e.g. - # the Newton visualizer model rebuilder on a PhysX backend) consume these to derive - # their own backend args. Empty dict until :meth:`InteractiveScene.clone_environments` - # runs. - self._clone_plans: dict[str, ClonePlan] = {} + # Clone plan published by InteractiveScene after cloning. Providers (e.g. the + # Newton visualizer model rebuilder on a PhysX backend) consume this to derive + # their own backend args. None until :meth:`InteractiveScene.clone_environments` runs. + self._clone_plan: ClonePlan | None = None self._visualizer_step_counter = 0 # Default visualization dt used before/without visualizer initialization. physics_dt = getattr(self.cfg.physics, "dt", None) @@ -635,18 +634,18 @@ def update_scene_data_requirements(self, requirements: SceneDataRequirement) -> """Update scene-data requirements.""" self._scene_data_requirements = requirements - def get_clone_plans(self) -> dict[str, ClonePlan]: - """Return per-group clone plans published by the scene, keyed by destination template. + def get_clone_plan(self) -> ClonePlan | None: + """Return the clone plan published by the scene. Set by :meth:`InteractiveScene.clone_environments` after replication. Consumed by scene data providers that build backend models (e.g. Newton visualizer model on a - PhysX backend) from the same plan the cloner used. Empty dict until the scene clones. + PhysX backend) from the same plan the cloner used. ``None`` until the scene clones. """ - return self._clone_plans + return self._clone_plan - def set_clone_plans(self, plans: dict[str, ClonePlan]) -> None: - """Set the cloner's per-group clone-plan map.""" - self._clone_plans = plans + def set_clone_plan(self, plan: ClonePlan | None) -> None: + """Set the cloner's clone plan.""" + self._clone_plan = plan @property def visualizers(self) -> list[BaseVisualizer]: diff --git a/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers.py b/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers.py index 285dd0373063..f6f087cfa129 100644 --- a/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers.py +++ b/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers.py @@ -31,9 +31,6 @@ def spawn_multi_asset( Assets are created in the order they appear in ``cfg.assets_cfg`` using the base name in ``prim_path``, which must contain ``.*`` (for example, ``/World/Env_0/asset_.*`` spawns ``asset_0``, ``asset_1``, ...). - The prefix portion of ``prim_path`` may also include ``.*`` (for example, ``/World/env_.*/asset_.*``); - in this case, assets are spawned under the first match (``env_0``) and that structure is cloned to - other matching environments by the scene's cloner. Args: prim_path: The prim path to spawn the assets. @@ -46,21 +43,33 @@ def spawn_multi_asset( Returns: The created prim at the first prim path. """ - split_path = prim_path.split("/") - prefix_path, base_name = "/".join(split_path[:-1]), split_path[-1] - if ".*" not in base_name: - raise ValueError( - f" The base name '{base_name}' in the prim path '{prim_path}' must contain '.*' to indicate" - " the path each individual multiple-asset to be spawned." - ) + if cfg.spawn_paths is not None: + if len(cfg.spawn_paths) != len(cfg.assets_cfg): + raise ValueError( + f"Expected spawn_paths to match assets_cfg length, got {len(cfg.spawn_paths)} and" + f" {len(cfg.assets_cfg)}." + ) + asset_prim_paths = list(cfg.spawn_paths) + else: + split_path = prim_path.split("/") + prefix_path, base_name = "/".join(split_path[:-1]), split_path[-1] + if ".*" not in base_name: + raise ValueError( + f" The base name '{base_name}' in the prim path '{prim_path}' must contain '.*' to indicate" + " the path each individual multiple-asset to be spawned." + ) + asset_prim_paths = [f"{prefix_path}/{base_name.replace('.*', str(i))}" for i in range(len(cfg.assets_cfg))] + if cfg.random_choice: logger.warning( "`random_choice` parameter in `spawn_multi_asset` is deprecated, and nothing will happen. " "Use `isaaclab.scene.interactive_scene_cfg.InteractiveSceneCfg.random_heterogeneous_cloning` instead." ) - proto_prim_paths = list() - for index, asset_cfg in enumerate(cfg.assets_cfg): + spawned_prim_paths: list[str] = [] + for asset_prim_path, asset_cfg in zip(asset_prim_paths, cfg.assets_cfg): + if asset_prim_path is None: + continue # append semantic tags if specified if cfg.semantic_tags is not None: if asset_cfg.semantic_tags is None: @@ -74,19 +83,18 @@ def spawn_multi_asset( if hasattr(asset_cfg, attr_name) and attr_value is not None: setattr(asset_cfg, attr_name, attr_value) - proto_prim_path = f"{prefix_path}/{base_name.replace('.*', str(index))}" asset_cfg.func( - proto_prim_path, + asset_prim_path, asset_cfg, translation=translation, orientation=orientation, clone_in_fabric=clone_in_fabric, replicate_physics=replicate_physics, ) - # append to proto prim paths - proto_prim_paths.append(proto_prim_path) - - return sim_utils.find_first_matching_prim(proto_prim_paths[0]) + spawned_prim_paths.append(asset_prim_path) + if not spawned_prim_paths: + raise ValueError("No assets were spawned. At least one spawn path must be active.") + return sim_utils.find_first_matching_prim(spawned_prim_paths[0]) def spawn_multi_usd_file( @@ -126,13 +134,13 @@ def spawn_multi_usd_file( usd_template_cfg = UsdFileCfg() for attr_name, attr_value in cfg.__dict__.items(): # skip names we know are not present - if attr_name in ["func", "usd_path", "random_choice", "spawn_path"]: + if attr_name in ["func", "usd_path", "random_choice", "spawn_path", "spawn_paths"]: continue # set the attribute into the template setattr(usd_template_cfg, attr_name, attr_value) # create multi asset configuration of USD files - multi_asset_cfg = MultiAssetSpawnerCfg(assets_cfg=[]) + multi_asset_cfg = MultiAssetSpawnerCfg(assets_cfg=[], spawn_paths=cfg.spawn_paths) for usd_path in usd_paths: usd_cfg = usd_template_cfg.replace(usd_path=usd_path) multi_asset_cfg.assets_cfg.append(usd_cfg) diff --git a/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py b/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py index d9b7d9ed0c35..e335393f7d94 100644 --- a/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py @@ -49,6 +49,14 @@ class MultiAssetSpawnerCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): assets_cfg: list[SpawnerCfg] = MISSING """List of asset configurations to spawn.""" + spawn_paths: list[str | None] | None = None + """Optional concrete spawn paths, one per asset configuration. + + When set, :func:`spawn_multi_asset` uses these paths instead of deriving + sibling paths from the input ``prim_path``. Entries set to ``None`` are + skipped. + """ + random_choice: bool = True """ This parameter is ignored. See :attr:`isaaclab.scene.interactive_scene_cfg.InteractiveSceneCfg.random_heterogeneous_cloning` for details. @@ -77,6 +85,14 @@ class MultiUsdFileCfg(UsdFileCfg): usd_path: str | list[str] = MISSING """Path or a list of paths to the USD files to spawn asset from.""" + spawn_paths: list[str | None] | None = None + """Optional concrete spawn paths, one per USD path. + + When set, :func:`spawn_multi_usd_file` uses these paths instead of deriving + sibling paths from the input ``prim_path``. Entries set to ``None`` are + skipped. + """ + random_choice: bool = True """Whether to randomly select an asset configuration. Default is True. diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 390129b9e4f2..626e4d8e44df 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -20,7 +20,7 @@ import isaaclab.sim as sim_utils from isaaclab.actuators import ImplicitActuatorCfg -from isaaclab.assets import ArticulationCfg, RigidObjectCfg +from isaaclab.assets import ArticulationCfg, RigidObjectCfg, RigidObjectCollectionCfg from isaaclab.physics.scene_data_requirements import SceneDataRequirement from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sim import build_simulation_context @@ -130,14 +130,8 @@ def test_reset_to_env_ids_input_types(device, setup_scene): assert_state_equal(prev_state, scene.get_state()) -def test_clone_environments_non_cfg_publishes_clone_plans(monkeypatch: pytest.MonkeyPatch): - """Non-cfg clone path must dispatch physics + USD replicate and publish a ``ClonePlan``. - - Replaces the old test that asserted a per-call visualizer clone callback was invoked. The - visualizer-fn callback was removed in favor of providers reading - :meth:`SimulationContext.get_clone_plans`; this test asserts the new contract: even - without prototype templates, the scene synthesizes a single trivial ClonePlan. - """ +def test_clone_environments_executes_env_root_plan_with_positions(monkeypatch: pytest.MonkeyPatch): + """Env-root plans replicate the whole environment and keep grid positions.""" from isaaclab.cloner import ClonePlan scene = object.__new__(InteractiveScene) @@ -146,24 +140,27 @@ def test_clone_environments_non_cfg_publishes_clone_plans(monkeypatch: pytest.Mo scene.physics_backend = "physx" scene._sensors = {} - set_plans_calls: list = [] - sim_state: dict = {"plans": {}} + set_plan_calls: list = [] + sim_state: dict = {"plan": None} - def _set_clone_plans(plans): - sim_state["plans"] = plans - set_plans_calls.append(plans) + def _set_clone_plan(plan): + sim_state["plan"] = plan + set_plan_calls.append(plan) scene.sim = SimpleNamespace( get_scene_data_requirements=lambda: SceneDataRequirement(), update_scene_data_requirements=lambda requirements: None, - set_clone_plans=_set_clone_plans, - get_clone_plans=lambda: sim_state["plans"], + set_clone_plan=_set_clone_plan, + get_clone_plan=lambda: sim_state["plan"], ) scene.env_fmt = "/World/envs/env_{}" scene._ALL_INDICES = torch.arange(3, dtype=torch.long) scene._default_env_origins = torch.zeros((3, 3), dtype=torch.float32) - scene._is_scene_setup_from_cfg = lambda: False - + scene._clone_plan = ClonePlan( + sources=(scene.env_fmt.format(0),), + destinations=(scene.env_fmt,), + clone_mask=torch.ones((1, scene.num_envs), dtype=torch.bool), + ) # Avoid binding this unit test to global SimulationContext singleton state. monkeypatch.setattr(InteractiveScene, "device", property(lambda self: "cpu")) @@ -198,24 +195,237 @@ def _usd_replicate(stage, *args, **kwargs): mapping = physics_calls[0][1][3] assert mapping.dtype == torch.bool assert mapping.shape == (1, scene.num_envs) - # Plans are published once per clone, regardless of physics/usd flag combinations. - assert len(set_plans_calls) == 1 - plans = set_plans_calls[-1] - assert set(plans.keys()) == {scene.env_fmt} - plan = plans[scene.env_fmt] + assert physics_calls[0][2]["positions"] is scene._default_env_origins + assert usd_calls[0][2]["positions"] is scene._default_env_origins + assert len(set_plan_calls) == 1 + plan = set_plan_calls[-1] assert isinstance(plan, ClonePlan) - assert plan.dest_template == scene.env_fmt - assert plan.prototype_paths == [scene.env_fmt.format(0)] + assert plan.sources == (scene.env_fmt.format(0),) + assert plan.destinations == (scene.env_fmt,) assert plan.clone_mask.shape == (1, scene.num_envs) - assert scene.clone_plans is plans + assert scene.clone_plan is plan physics_calls.clear() usd_calls.clear() - set_plans_calls.clear() + set_plan_calls.clear() scene.clone_environments(copy_from_source=True) assert len(physics_calls) == 0 assert len(usd_calls) == 1 - assert len(set_plans_calls) == 1 + assert len(set_plan_calls) == 1 + + +def test_clone_environments_skips_replication_without_plan(): + """Direct-path cfg scenes publish no plan and do not dispatch cloners.""" + scene = object.__new__(InteractiveScene) + scene._clone_plan = None + set_plan_calls = [] + scene.sim = SimpleNamespace(set_clone_plan=set_plan_calls.append) + + scene.clone_environments(copy_from_source=False) + + assert set_plan_calls == [None] + + +def test_clone_environments_executes_asset_level_plan_without_usd_positions(monkeypatch: pytest.MonkeyPatch): + """Asset-level plans preserve env-root transforms by skipping USD positions.""" + from isaaclab.cloner import ClonePlan + + scene = object.__new__(InteractiveScene) + scene.cfg = SimpleNamespace(replicate_physics=False, num_envs=2) + scene.stage = object() + scene.physics_backend = "physx" + scene._sensors = {} + scene.env_fmt = "/World/envs/env_{}" + scene._ALL_INDICES = torch.arange(2, dtype=torch.long) + scene._default_env_origins = torch.ones((2, 3), dtype=torch.float32) + scene._clone_plan = ClonePlan( + sources=("/World/envs/env_0/Object", "/World/envs/env_1/Object"), + destinations=("/World/envs/env_{}/Object", "/World/envs/env_{}/Object"), + clone_mask=torch.tensor([[True, False], [False, True]], dtype=torch.bool), + ) + + set_plan_calls: list = [] + scene.sim = SimpleNamespace(set_clone_plan=set_plan_calls.append) + monkeypatch.setattr(InteractiveScene, "device", property(lambda self: "cpu")) + + @contextlib.contextmanager + def _noop_fabric_notices(stage, *, restore=True): + yield + + monkeypatch.setattr("isaaclab.scene.interactive_scene.cloner.disabled_fabric_change_notifies", _noop_fabric_notices) + monkeypatch.setattr( + "isaaclab.scene.interactive_scene.cloner.usd_replicate", + lambda *args, **kwargs: usd_calls.append((args, kwargs)), + ) + + physics_calls = [] + usd_calls = [] + scene.cloner_cfg = SimpleNamespace( + device="cpu", + physics_clone_fn=lambda *args, **kwargs: physics_calls.append((args, kwargs)), + clone_usd=True, + ) + + scene.clone_environments(copy_from_source=False) + + assert len(physics_calls) == 1 + assert physics_calls[0][1]["positions"] is scene._default_env_origins + assert len(usd_calls) == 1 + assert usd_calls[0][1]["positions"] is None + assert set_plan_calls == [scene._clone_plan] + + +def test_build_clone_plan_from_cfg_plans_multi_and_single_spawners(monkeypatch: pytest.MonkeyPatch): + """Heterogeneous planning writes source paths for multi and single spawners.""" + from isaaclab.cloner import sequential + + scene = object.__new__(InteractiveScene) + scene.cfg = SimpleNamespace( + num_envs=4, + object=SimpleNamespace( + prim_path="{ENV_REGEX_NS}/Object", + spawn=sim_utils.MultiAssetSpawnerCfg( + assets_cfg=[ + sim_utils.ConeCfg(radius=0.1, height=0.2), + sim_utils.SphereCfg(radius=0.1), + ] + ), + ), + robot=SimpleNamespace( + prim_path="{ENV_REGEX_NS}/Robot", + spawn=sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), + ), + ) + scene.env_fmt = "/World/envs/env_{}" + scene.cloner_cfg = SimpleNamespace(clone_strategy=sequential) + monkeypatch.setattr(InteractiveScene, "device", property(lambda self: "cpu")) + + plan = scene._build_clone_plan_from_cfg() + + assert plan is not None + assert plan.sources == ( + "/World/envs/env_0/Object", + "/World/envs/env_1/Object", + "/World/envs/env_0/Robot", + ) + assert plan.destinations == ( + "/World/envs/env_{}/Object", + "/World/envs/env_{}/Object", + "/World/envs/env_{}/Robot", + ) + assert scene.cfg.object.spawn.spawn_paths == ["/World/envs/env_0/Object", "/World/envs/env_1/Object"] + assert scene.cfg.robot.spawn.spawn_path == "/World/envs/env_0/Robot" + assert scene.cfg.object.prim_path == "{ENV_REGEX_NS}/Object" + assert scene.cfg.robot.prim_path == "{ENV_REGEX_NS}/Robot" + assert torch.equal(plan.clone_mask.to(torch.int).argmax(dim=0).cpu(), torch.tensor([0, 1, 0, 1])) + + +def test_build_clone_plan_from_cfg_defaults_to_env0_plan(monkeypatch: pytest.MonkeyPatch): + """Homogeneous cfg scenes use the default env_0-to-all ClonePlan.""" + from isaaclab.cloner import sequential + + scene = object.__new__(InteractiveScene) + scene.cfg = SimpleNamespace( + num_envs=3, + robot=SimpleNamespace( + prim_path="{ENV_REGEX_NS}/Robot", + spawn=sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), + ), + ) + scene.env_fmt = "/World/envs/env_{}" + scene.cloner_cfg = SimpleNamespace(clone_strategy=sequential) + monkeypatch.setattr(InteractiveScene, "device", property(lambda self: "cpu")) + + plan = scene._build_clone_plan_from_cfg() + + assert plan is not None + assert plan.sources == ("/World/envs/env_0",) + assert plan.destinations == (scene.env_fmt,) + assert plan.clone_mask.shape == (1, scene.num_envs) + assert scene.cfg.robot.spawn.spawn_path == "/World/envs/env_0/Robot" + + +def test_build_clone_plan_from_cfg_returns_none_without_env_scoped_groups(monkeypatch: pytest.MonkeyPatch): + """Direct-path cfg scenes should not force env-root replication.""" + from isaaclab.cloner import sequential + + scene = object.__new__(InteractiveScene) + scene.cfg = SimpleNamespace( + num_envs=1, + robot=SimpleNamespace( + prim_path="/World/Robot", + spawn=sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), + ), + ) + scene.env_fmt = "/World/envs/env_{}" + scene.cloner_cfg = SimpleNamespace(clone_strategy=sequential) + monkeypatch.setattr(InteractiveScene, "device", property(lambda self: "cpu")) + + assert scene._build_clone_plan_from_cfg() is None + assert scene.cfg.robot.spawn.spawn_path is None + + +def test_build_clone_plan_from_cfg_sets_collection_member_paths(monkeypatch: pytest.MonkeyPatch): + """Rigid object collection members are planned independently.""" + from isaaclab.cloner import sequential + + scene = object.__new__(InteractiveScene) + cube_cfg = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube", + spawn=sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), + ) + shape_cfg = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Shape", + spawn=sim_utils.MultiAssetSpawnerCfg( + assets_cfg=[sim_utils.ConeCfg(radius=0.1, height=0.2), sim_utils.SphereCfg(radius=0.1)] + ), + ) + scene.cfg = SimpleNamespace( + num_envs=4, + objects=RigidObjectCollectionCfg(rigid_objects={"cube": cube_cfg, "shape": shape_cfg}), + ) + scene.env_fmt = "/World/envs/env_{}" + scene.cloner_cfg = SimpleNamespace(clone_strategy=sequential) + monkeypatch.setattr(InteractiveScene, "device", property(lambda self: "cpu")) + + plan = scene._build_clone_plan_from_cfg() + + assert plan is not None + planned_cube = scene.cfg.objects.rigid_objects["cube"] + planned_shape = scene.cfg.objects.rigid_objects["shape"] + assert planned_cube.spawn.spawn_path == "/World/envs/env_0/Cube" + assert planned_shape.spawn.spawn_paths == ["/World/envs/env_0/Shape", "/World/envs/env_1/Shape"] + assert "/World/envs/env_{}/Cube" in plan.destinations + assert "/World/envs/env_{}/Shape" in plan.destinations + + +def test_build_clone_plan_from_cfg_marks_unused_variants(monkeypatch: pytest.MonkeyPatch): + """Unused variants keep a mask row but do not get spawned.""" + from isaaclab.cloner import sequential + + scene = object.__new__(InteractiveScene) + scene.cfg = SimpleNamespace( + num_envs=2, + object=SimpleNamespace( + prim_path="{ENV_REGEX_NS}/Object", + spawn=sim_utils.MultiAssetSpawnerCfg( + assets_cfg=[ + sim_utils.ConeCfg(radius=0.1, height=0.2), + sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), + sim_utils.SphereCfg(radius=0.1), + ] + ), + ), + ) + scene.env_fmt = "/World/envs/env_{}" + scene.cloner_cfg = SimpleNamespace(clone_strategy=sequential) + monkeypatch.setattr(InteractiveScene, "device", property(lambda self: "cpu")) + + plan = scene._build_clone_plan_from_cfg() + + assert plan is not None + assert scene.cfg.object.spawn.spawn_paths == ["/World/envs/env_0/Object", "/World/envs/env_1/Object", None] + assert plan.clone_mask[2].sum() == 0 def test_aggregate_scene_data_requirements_merges_visualizers_and_renderers(monkeypatch: pytest.MonkeyPatch): diff --git a/source/isaaclab/test/sim/test_cloner.py b/source/isaaclab/test/sim/test_cloner.py index 1f8af90387b5..1f526ac74584 100644 --- a/source/isaaclab/test/sim/test_cloner.py +++ b/source/isaaclab/test/sim/test_cloner.py @@ -20,7 +20,7 @@ from pxr import UsdGeom import isaaclab.sim as sim_utils -from isaaclab.cloner import ClonePlan, TemplateCloneCfg, clone_from_template, sequential, usd_replicate +from isaaclab.cloner import make_clone_plan, sequential, usd_replicate from isaaclab.sim import build_simulation_context pytestmark = pytest.mark.isaacsim_ci @@ -221,56 +221,20 @@ def test_clone_decorator_wildcard_patterns( ) -def test_clone_from_template_returns_clone_plan(sim): - """clone_from_template exposes per-group ClonePlan dicts with prototype-to-env masks. - - Builds two USD prototypes under one group, clones across four envs with the deterministic - sequential strategy, and asserts the returned dict has one entry keyed by the group's - destination template, with a ``[2, 4]`` boolean mask whose columns sum to one. - """ - num_clones = 4 - cfg = TemplateCloneCfg(device=sim.cfg.device, clone_strategy=sequential, clone_physics=False) - - sim_utils.create_prim(cfg.template_root, "Xform") - sim_utils.create_prim(f"{cfg.template_root}/Object", "Xform") - sim_utils.create_prim(f"{cfg.template_root}/Object/proto_asset_0", "Xform") - sim_utils.create_prim(f"{cfg.template_root}/Object/proto_asset_1", "Xform") - sim_utils.create_prim("/World/envs", "Xform") - for i in range(num_clones): - sim_utils.create_prim(f"/World/envs/env_{i}", "Xform", translation=(0, 0, 0)) +def test_make_clone_plan_returns_flat_source_rows(sim): + """make_clone_plan exposes the flat source-to-env mask used by scene cloning.""" + plan = make_clone_plan( + [["/World/envs/env_0/Object", "/World/envs/env_1/Object"]], + ["/World/envs/env_{}/Object"], + num_clones=4, + clone_strategy=sequential, + device=sim.cfg.device, + ) - stage = sim_utils.get_current_stage() - plans = clone_from_template(stage, num_clones=num_clones, template_clone_cfg=cfg) - - assert isinstance(plans, dict) - assert list(plans.keys()) == ["/World/envs/env_{}/Object"] - plan = plans["/World/envs/env_{}/Object"] - assert isinstance(plan, ClonePlan) - assert plan.dest_template == "/World/envs/env_{}/Object" - assert sorted(plan.prototype_paths) == [ - "/World/template/Object/proto_asset_0", - "/World/template/Object/proto_asset_1", - ] - assert plan.clone_mask.shape == (2, num_clones) + assert plan.sources == ("/World/envs/env_0/Object", "/World/envs/env_1/Object") + assert plan.destinations == ("/World/envs/env_{}/Object", "/World/envs/env_{}/Object") + assert plan.clone_mask.shape == (2, 4) assert plan.clone_mask.dtype == torch.bool - # Each env gets exactly one prototype (column-sum invariant) assert torch.all(plan.clone_mask.sum(dim=0) == 1) - # Sequential strategy assigns env i → prototype (i % num_protos) - actual_proto_idx = plan.clone_mask.to(torch.int).argmax(dim=0).cpu() - assert torch.equal(actual_proto_idx, torch.tensor([0, 1, 0, 1])) - - -def test_clone_from_template_returns_empty_dict_when_no_prototypes(sim): - """clone_from_template returns an empty dict when no prototypes match the identifier.""" - num_clones = 2 - cfg = TemplateCloneCfg(device=sim.cfg.device, clone_strategy=sequential, clone_physics=False) - - sim_utils.create_prim(cfg.template_root, "Xform") - sim_utils.create_prim("/World/envs", "Xform") - for i in range(num_clones): - sim_utils.create_prim(f"/World/envs/env_{i}", "Xform", translation=(0, 0, 0)) - - stage = sim_utils.get_current_stage() - plans = clone_from_template(stage, num_clones=num_clones, template_clone_cfg=cfg) - - assert plans == {} + actual_source_idx = plan.clone_mask.to(torch.int).argmax(dim=0).cpu() + assert torch.equal(actual_source_idx, torch.tensor([0, 1, 0, 1])) 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 979d66cc4a7e..d8e640c394f8 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 @@ -78,35 +78,33 @@ def test_get_newton_model_returns_model_when_sync_enabled(stub_provider): assert stub_provider.get_newton_model() == "full-model" -def test_build_from_clone_plans_populates_provider_state(stub_provider, newton_stub): - """Building from per-group clone plans sets model, state, and rigid-body paths. +def test_build_from_clone_plan_populates_provider_state(stub_provider, newton_stub): + """Building from a flat clone plan sets model, state, and rigid-body paths. - Asserts the provider derives its own (sources, destinations, mask) from the plans - without consulting any auxiliary spec object: representative source paths are recovered - from ``dest_template.format()``, masks are concatenated - along the prototype axis, and per-env positions are read from stage xforms. + Asserts the provider consumes the single source-of-truth ``(sources, + destinations, mask)`` contract directly and reads per-env positions from stage + xforms. """ newton_stub.model = SimpleNamespace( body_label=["/World/envs/env_0/Object/A"], articulation_label=["/World/envs/env_0/Robot"], ) - plans = { - "/World/envs/env_{}/Object": ClonePlan( - dest_template="/World/envs/env_{}/Object", - prototype_paths=["/World/template/Object/proto_0", "/World/template/Object/proto_1"], - # proto 0 → env 0, 2 ; proto 1 → env 1, 3 - clone_mask=torch.tensor([[True, False, True, False], [False, True, False, True]], dtype=torch.bool), + plan = ClonePlan( + sources=( + "/World/envs/env_0/Object", + "/World/envs/env_1/Object", + "/World/envs/env_0/Robot", ), - "/World/envs/env_{}/Robot": ClonePlan( - dest_template="/World/envs/env_{}/Robot", - prototype_paths=["/World/template/Robot/proto_0"], - clone_mask=torch.ones((1, 4), dtype=torch.bool), + destinations=("/World/envs/env_{}/Object", "/World/envs/env_{}/Object", "/World/envs/env_{}/Robot"), + # object 0 -> env 0, 2 ; object 1 -> env 1, 3 ; robot -> all envs + clone_mask=torch.tensor( + [[True, False, True, False], [False, True, False, True], [True, True, True, True]], dtype=torch.bool ), - } - stub_provider._simulation_context = SimpleNamespace(get_clone_plans=lambda: plans) + ) + stub_provider._simulation_context = SimpleNamespace(get_clone_plan=lambda: plan) stub_provider._stage = _silent_stage() - stub_provider._build_newton_model_from_clone_plans() + stub_provider._build_newton_model_from_clone_plan() assert stub_provider._newton_model is newton_stub.model assert stub_provider._newton_state is newton_stub.state_obj @@ -116,7 +114,6 @@ def test_build_from_clone_plans_populates_provider_state(stub_provider, newton_s assert stub_provider._last_newton_model_build_source == "built" kw = newton_stub.calls[-1] - # Source recovery picks the first-env user per prototype. assert kw["sources"] == [ "/World/envs/env_0/Object", "/World/envs/env_1/Object", @@ -127,41 +124,35 @@ def test_build_from_clone_plans_populates_provider_state(stub_provider, newton_s assert kw["positions"].shape == (4, 3) -def test_build_from_clone_plans_missing_sets_error_state(stub_provider): - """When no clone plans are published, model/state stay unset.""" - stub_provider._simulation_context = SimpleNamespace(get_clone_plans=lambda: {}) +def test_build_from_clone_plan_missing_sets_error_state(stub_provider): + """When no clone plan is published, model/state stay unset.""" + stub_provider._simulation_context = SimpleNamespace(get_clone_plan=lambda: None) stub_provider._stage = object() - stub_provider._build_newton_model_from_clone_plans() + stub_provider._build_newton_model_from_clone_plan() assert stub_provider._last_newton_model_build_source == "missing" assert stub_provider._newton_model is None assert stub_provider._newton_state is None -def test_build_from_clone_plans_skips_unused_prototype_rows(stub_provider, newton_stub): - """A prototype row with no assigned env (all-False mask row) is dropped, not raised on. +def test_build_from_clone_plan_skips_unused_source_rows(stub_provider, newton_stub): + """A source row with no assigned env (all-False mask row) is dropped, not raised on. When ``num_prototypes > num_envs`` under a sequential strategy (or any strategy that - leaves some prototypes unused), ``clone_mask[row].nonzero()[0]`` would otherwise raise - ``IndexError``. The provider must filter unused rows out of sources/destinations/mask. + leaves some prototypes unused), the provider must filter unused rows out of + sources/destinations/mask. """ # 3 prototypes, 2 envs, sequential: env 0 → proto 0, env 1 → proto 1, proto 2 unused. - plans = { - "/World/envs/env_{}/Object": ClonePlan( - dest_template="/World/envs/env_{}/Object", - prototype_paths=[ - "/World/template/Object/proto_0", - "/World/template/Object/proto_1", - "/World/template/Object/proto_2", - ], - clone_mask=torch.tensor([[True, False], [False, True], [False, False]], dtype=torch.bool), - ) - } - stub_provider._simulation_context = SimpleNamespace(get_clone_plans=lambda: plans) + plan = ClonePlan( + sources=("/World/envs/env_0/Object", "/World/envs/env_1/Object", "/World/envs/env_0/Object"), + destinations=("/World/envs/env_{}/Object",) * 3, + clone_mask=torch.tensor([[True, False], [False, True], [False, False]], dtype=torch.bool), + ) + stub_provider._simulation_context = SimpleNamespace(get_clone_plan=lambda: plan) stub_provider._stage = _silent_stage() - stub_provider._build_newton_model_from_clone_plans() + stub_provider._build_newton_model_from_clone_plan() assert stub_provider._last_newton_model_build_source == "built" kw = newton_stub.calls[-1] @@ -170,8 +161,8 @@ def test_build_from_clone_plans_skips_unused_prototype_rows(stub_provider, newto assert kw["mapping"].shape == (2, 2) -def test_build_from_clone_plans_uses_dest_template_for_env_lookup(stub_provider, newton_stub): - """Env-origin lookup uses the per-plan ``dest_template`` prefix, not a hardcoded path. +def test_build_from_clone_plan_uses_destination_template_for_env_lookup(stub_provider, newton_stub): + """Env-origin lookup uses the plan's destination prefix, not a hardcoded path. A scene with a non-default env path (``/Stage/scenes/env_``) should still have its xform translates read correctly. Replaces the prior hardcoded ``/World/envs/env_``. @@ -182,40 +173,27 @@ def _get_prim(path): visited.append(path) return SimpleNamespace(IsValid=lambda: False) - plans = { - "/Stage/scenes/env_{}/Object": ClonePlan( - dest_template="/Stage/scenes/env_{}/Object", - prototype_paths=["/Stage/template/Object/proto_0"], - clone_mask=torch.ones((1, 3), dtype=torch.bool), - ) - } - stub_provider._simulation_context = SimpleNamespace(get_clone_plans=lambda: plans) + plan = ClonePlan( + sources=("/Stage/scenes/env_0/Object",), + destinations=("/Stage/scenes/env_{}/Object",), + clone_mask=torch.ones((1, 3), dtype=torch.bool), + ) + stub_provider._simulation_context = SimpleNamespace(get_clone_plan=lambda: plan) stub_provider._stage = SimpleNamespace(GetPrimAtPath=_get_prim) - stub_provider._build_newton_model_from_clone_plans() + stub_provider._build_newton_model_from_clone_plan() assert {f"/Stage/scenes/env_{i}" for i in range(3)} <= set(visited) assert not any(p.startswith("/World/envs/") for p in visited) -def test_clone_plan_is_hashable_with_unhashable_fields(): - """``ClonePlan`` must hash despite carrying a tensor and a list. - - With ``field(hash=False)`` on the unhashable members, hashing operates on - ``dest_template`` only — the natural identity (it is the dict key in - :meth:`SimulationContext.get_clone_plans`). - """ - plan_a = ClonePlan( - dest_template="/World/envs/env_{}/Object", - prototype_paths=["/World/template/Object/proto_0"], +def test_clone_plan_carries_flat_replication_contract(): + """``ClonePlan`` contains only sources, destinations, and the clone mask.""" + plan = ClonePlan( + sources=("/World/envs/env_0/Object",), + destinations=("/World/envs/env_{}/Object",), clone_mask=torch.ones((1, 4), dtype=torch.bool), ) - plan_b = ClonePlan( - dest_template="/World/envs/env_{}/Object", - prototype_paths=["/World/template/Object/proto_99"], - clone_mask=torch.zeros((1, 4), dtype=torch.bool), - ) - assert isinstance(hash(plan_a), int) - # Equality folds in only dest_template, so two plans with the same destination compare - # equal regardless of prototype/mask differences. - assert plan_a == plan_b + assert plan.sources == ("/World/envs/env_0/Object",) + assert plan.destinations == ("/World/envs/env_{}/Object",) + assert plan.clone_mask.shape == (1, 4) diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 3b7ca93dcfa3..1c40e21cb548 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -793,7 +793,7 @@ def _make_context_with_settings( ctx._visualizers = [] ctx._scene_data_provider = _FakeProvider() ctx._scene_data_requirements = None - ctx._clone_plans = {} + ctx._clone_plan = None ctx._visualizer_step_counter = 0 ctx._viz_dt = 0.01 ctx.get_setting = lambda name: settings.get(name) diff --git a/source/isaaclab/test/sim/test_spawn_wrappers.py b/source/isaaclab/test/sim/test_spawn_wrappers.py index c053a6362f4d..69ad8b105723 100644 --- a/source/isaaclab/test/sim/test_spawn_wrappers.py +++ b/source/isaaclab/test/sim/test_spawn_wrappers.py @@ -162,6 +162,41 @@ def test_spawn_multiple_shapes_with_individual_settings(sim): assert prim.GetAttribute("physics:mass").Get() in mass_variations +def test_spawn_multiple_shapes_with_explicit_spawn_paths(sim): + """Multi-asset spawner accepts planned per-variant source paths.""" + sim_utils.create_prim("/World/planned", "Xform", translation=(0, 0, 0)) + + cfg = sim_utils.MultiAssetSpawnerCfg( + assets_cfg=[ + sim_utils.ConeCfg(radius=0.3, height=0.6), + sim_utils.CuboidCfg(size=(0.3, 0.3, 0.3)), + sim_utils.SphereCfg(radius=0.3), + ], + spawn_paths=["/World/planned/apple", None, "/World/planned/banana"], + mass_props=sim_utils.MassPropertiesCfg(mass=1.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + ) + + prim = cfg.func("/World/ignored_without_regex", cfg) + + assert str(prim.GetPath()) == "/World/planned/apple" + assert sim.stage.GetPrimAtPath("/World/planned/apple").IsValid() + assert not sim.stage.GetPrimAtPath("/World/planned/ignored").IsValid() + assert sim.stage.GetPrimAtPath("/World/planned/banana").IsValid() + assert sim.stage.GetPrimAtPath("/World/planned/apple").GetAttribute("physics:mass").Get() == 1.0 + + +def test_spawn_multiple_shapes_spawn_paths_length_mismatch(sim): + """Explicit multi-asset paths must align one-to-one with variants.""" + cfg = sim_utils.MultiAssetSpawnerCfg( + assets_cfg=[sim_utils.ConeCfg(radius=0.3, height=0.6), sim_utils.SphereCfg(radius=0.3)], + spawn_paths=["/World/planned/apple"], + ) + + with pytest.raises(ValueError, match="spawn_paths"): + cfg.func("/World/ignored_without_regex", cfg) + + """ Tests - Multiple USDs. """ diff --git a/source/isaaclab_newton/changelog.d/octi-cloner_ordering.rst b/source/isaaclab_newton/changelog.d/octi-cloner_ordering.rst new file mode 100644 index 000000000000..807d81e7c558 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/octi-cloner_ordering.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed rigid object collection spawning to honor planned ``spawn_path`` + values while falling back to ``prim_path`` for direct construction. 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 8c499d75396c..b11415d48231 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 @@ -78,8 +78,9 @@ def __init__(self, cfg: RigidObjectCollectionCfg): for rigid_body_cfg in self.cfg.rigid_objects.values(): # spawn the asset if rigid_body_cfg.spawn is not None: + spawn_path = rigid_body_cfg.spawn.spawn_path or rigid_body_cfg.prim_path rigid_body_cfg.spawn.func( - rigid_body_cfg.prim_path, + spawn_path, rigid_body_cfg.spawn, translation=rigid_body_cfg.init_state.pos, orientation=rigid_body_cfg.init_state.rot, diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py index 34cd35de4fa2..544756858d51 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py @@ -5,6 +5,8 @@ from __future__ import annotations +from collections.abc import Sequence + import torch import warp as wp from newton import ModelBuilder, solvers @@ -17,7 +19,7 @@ def _build_newton_builder_from_mapping( stage: Usd.Stage, - sources: list[str], + sources: Sequence[str], env_ids: torch.Tensor, mapping: torch.Tensor, positions: torch.Tensor | None = None, @@ -53,7 +55,7 @@ def _build_newton_builder_from_mapping( builder = NewtonManager.create_builder(up_axis=up_axis) stage_info = builder.add_usd( stage, - ignore_paths=["/World/envs"] + sources, + ignore_paths=["/World/envs", *sources], schema_resolvers=schema_resolvers, ) @@ -117,7 +119,11 @@ def _build_newton_builder_from_mapping( def _rename_builder_labels( - builder: ModelBuilder, sources: list[str], destinations: list[str], env_ids: torch.Tensor, mapping: torch.Tensor + builder: ModelBuilder, + sources: Sequence[str], + destinations: Sequence[str], + env_ids: torch.Tensor, + mapping: torch.Tensor, ) -> None: """Rename builder labels/keys from source roots to destination roots. @@ -149,8 +155,8 @@ def _rename_builder_labels( def newton_physics_replicate( stage: Usd.Stage, - sources: list[str], - destinations: list[str], + sources: Sequence[str], + destinations: Sequence[str], env_ids: torch.Tensor, mapping: torch.Tensor, positions: torch.Tensor | None = None, @@ -195,8 +201,8 @@ def newton_physics_replicate( def newton_visualizer_prebuild( stage: Usd.Stage, - sources: list[str], - destinations: list[str], + sources: Sequence[str], + destinations: Sequence[str], env_ids: torch.Tensor, mapping: torch.Tensor, positions: torch.Tensor | None = None, diff --git a/source/isaaclab_ovphysx/changelog.d/octi-cloner_ordering.skip b/source/isaaclab_ovphysx/changelog.d/octi-cloner_ordering.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/cloner/ovphysx_replicate.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/cloner/ovphysx_replicate.py index 7c46a6060b88..d89a45280a50 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/cloner/ovphysx_replicate.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/cloner/ovphysx_replicate.py @@ -5,9 +5,9 @@ """OvPhysX replication hook for IsaacLab's cloning pipeline. -Called by :func:`isaaclab.cloner.clone_from_template` in place of the PhysX -or Newton replicators. Unlike those replicators, ovphysx.PhysX does not exist -yet at this point in the scene setup — it is created lazily on the first +Called from the scene cloning path in place of immediate PhysX or Newton +replication. Unlike those replicators, ovphysx.PhysX does not exist yet at +this point in the scene setup — it is created lazily on the first :meth:`~isaaclab_ovphysx.physics.OvPhysxManager.reset` call. This function records a *pending clone* on :class:`OvPhysxManager`. When @@ -20,6 +20,8 @@ from __future__ import annotations +from collections.abc import Sequence + import torch from pxr import Usd @@ -27,8 +29,8 @@ def ovphysx_replicate( stage: Usd.Stage, - sources: list[str], - destinations: list[str], + sources: Sequence[str], + destinations: Sequence[str], env_ids: torch.Tensor, mapping: torch.Tensor, positions: torch.Tensor | None = None, diff --git a/source/isaaclab_physx/changelog.d/octi-cloner_ordering.rst b/source/isaaclab_physx/changelog.d/octi-cloner_ordering.rst new file mode 100644 index 000000000000..807d81e7c558 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/octi-cloner_ordering.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed rigid object collection spawning to honor planned ``spawn_path`` + values while falling back to ``prim_path`` for direct construction. 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 6d07ddbf1bc1..2031ded53b2f 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 @@ -81,8 +81,9 @@ def __init__(self, cfg: RigidObjectCollectionCfg): for rigid_body_cfg in self.cfg.rigid_objects.values(): # spawn the asset if rigid_body_cfg.spawn is not None: + spawn_path = rigid_body_cfg.spawn.spawn_path or rigid_body_cfg.prim_path rigid_body_cfg.spawn.func( - rigid_body_cfg.prim_path, + spawn_path, rigid_body_cfg.spawn, translation=rigid_body_cfg.init_state.pos, orientation=rigid_body_cfg.init_state.rot, diff --git a/source/isaaclab_physx/isaaclab_physx/cloner/physx_replicate.py b/source/isaaclab_physx/isaaclab_physx/cloner/physx_replicate.py index d90d413bffa2..dcc5cc6d9677 100644 --- a/source/isaaclab_physx/isaaclab_physx/cloner/physx_replicate.py +++ b/source/isaaclab_physx/isaaclab_physx/cloner/physx_replicate.py @@ -5,6 +5,8 @@ from __future__ import annotations +from collections.abc import Sequence + import torch from omni.physx import get_physx_replicator_interface @@ -13,8 +15,8 @@ def physx_replicate( stage: Usd.Stage, - sources: list[str], # e.g. ["/World/Template/A", "/World/Template/B"] - destinations: list[str], # e.g. ["/World/envs/env_{}/Robot", "/World/envs/env_{}/Object"] + sources: Sequence[str], # e.g. ["/World/Template/A", "/World/Template/B"] + destinations: Sequence[str], # e.g. ["/World/envs/env_{}/Robot", "/World/envs/env_{}/Object"] env_ids: torch.Tensor, # env_ids mapping: torch.Tensor, # (num_sources, num_envs) bool; True -> place sources[i] into world=j positions: torch.Tensor | None = None, 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 9a88660da498..ec4c64d8f8c7 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 @@ -42,8 +42,7 @@ class PhysxSceneDataProvider(BaseSceneDataProvider): - body poses via PhysX tensor views, with FrameView fallback - camera poses & intrinsics - USD stage handles - - Newton model/state (built locally from the scene's per-group :class:`ClonePlan` map - when required) + - Newton model/state (built locally from the scene's :class:`ClonePlan` when required) """ # ---- Environment discovery / metadata ------------------------------------------------- @@ -129,7 +128,7 @@ def __init__(self, stage, simulation_context) -> None: self._last_newton_model_build_elapsed_ms: float | None = None if self._needs_newton_sync: - self._build_newton_model_from_clone_plans() + self._build_newton_model_from_clone_plan() self._setup_rigid_body_view() # ---- Newton model + PhysX view setup -------------------------------------------------- @@ -150,53 +149,55 @@ 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_clone_plans() + self._build_newton_model_from_clone_plan() self._setup_rigid_body_view() - def _build_newton_model_from_clone_plans(self) -> None: - """Build Newton model and state from the scene's per-group :class:`ClonePlan` map. - - Reads plans :meth:`InteractiveScene.clone_environments` publishes on - :class:`SimulationContext`, derives the flat ``(sources, destinations, mask)`` shape - :func:`isaaclab_newton.cloner.newton_visualizer_prebuild` expects, and caches the - resulting model/state. Per-prototype source paths recover as - ``dest_template.format()``; per-env positions are - read off ``xformOp:translate`` on the env-level prims derived from the same template. - Pre-condition violations raise :class:`RuntimeError` (logged as ``"missing"``); - ``isaaclab_newton`` being absent (optional dep) maps to ``"missing"`` via the - import's own exception types; unexpected failures fall through to ``"error"``. + def _build_newton_model_from_clone_plan(self) -> None: + """Build Newton model and state from the scene's :class:`ClonePlan`. + + Reads the plan :meth:`InteractiveScene.clone_environments` publishes on + :class:`SimulationContext`, validates the flat ``(sources, destinations, mask)`` + shape :func:`isaaclab_newton.cloner.newton_visualizer_prebuild` expects, and + caches the resulting model/state. Per-env positions are read off + ``xformOp:translate`` on the env-level prims derived from the first destination + template. Pre-condition violations raise :class:`RuntimeError` (logged as + ``"missing"``); ``isaaclab_newton`` being absent (optional dep) maps to + ``"missing"`` via the import's own exception types; unexpected failures fall + through to ``"error"``. """ start_t = time.perf_counter() source = "missing" try: - plans = self._simulation_context.get_clone_plans() - if not plans: - raise RuntimeError("No clone plans on simulation context.") + plan = self._simulation_context.get_clone_plan() + if plan is None: + raise RuntimeError("No clone plan on simulation context.") from isaaclab_newton.cloner.newton_replicate import newton_visualizer_prebuild - # Flatten per-group plans into one (sources, destinations, mask) bundle. Source - # paths recover via ``dest_template.format()``; - # all-False rows are dropped (possible when ``num_prototypes > num_envs``). - plan_list = list(plans.values()) - num_envs = plan_list[0].clone_mask.size(1) - if any(p.clone_mask.size(1) != num_envs for p in plan_list): - raise RuntimeError(f"Clone plans disagree on num_envs: {[p.clone_mask.size(1) for p in plan_list]}") + if len(plan.sources) != len(plan.destinations): + raise RuntimeError( + f"Clone plan sources and destinations disagree: {len(plan.sources)} != {len(plan.destinations)}" + ) + if plan.clone_mask.dim() != 2 or plan.clone_mask.size(0) != len(plan.sources): + raise RuntimeError( + f"Clone plan mask shape {tuple(plan.clone_mask.shape)} does not match {len(plan.sources)} sources." + ) + + # Drop all-False rows (possible when ``num_prototypes > num_envs``). sources, destinations, mask_rows = [], [], [] - for p in plan_list: - for i in range(p.clone_mask.size(0)): - nz = p.clone_mask[i].nonzero(as_tuple=False) - if nz.numel() == 0: - continue - sources.append(p.dest_template.format(int(nz[0].item()))) - destinations.append(p.dest_template) - mask_rows.append(p.clone_mask[i : i + 1]) + for i, (source_path, destination) in enumerate(zip(plan.sources, plan.destinations)): + if not plan.clone_mask[i].any(): + continue + sources.append(source_path) + destinations.append(destination) + mask_rows.append(plan.clone_mask[i : i + 1]) if not sources: - raise RuntimeError("All clone-plan prototype rows are empty.") + raise RuntimeError("All clone-plan source rows are empty.") mask = torch.cat(mask_rows, dim=0) + num_envs = plan.clone_mask.size(1) # Env-level path template = dest_template up to the first ``{}``. Per-env world # positions: xformOp:translate read off each env prim; missing prims fall through. - env_path_template = plan_list[0].dest_template.split("{}")[0] + "{}" + env_path_template = destinations[0].split("{}")[0] + "{}" positions = torch.zeros((num_envs, 3), dtype=torch.float32, device=self._device) for i in range(num_envs): prim = self._stage.GetPrimAtPath(env_path_template.format(i)) @@ -239,7 +240,7 @@ def _build_newton_model_from_clone_plans(self) -> None: self._clear_newton_model_state() except Exception as exc: source = "error" - logger.error("[PhysxSceneDataProvider] Failed to build Newton model from clone plans: %s", exc) + logger.error("[PhysxSceneDataProvider] Failed to build Newton model from clone plan: %s", exc) self._clear_newton_model_state() finally: self._last_newton_model_build_elapsed_ms = (time.perf_counter() - start_t) * 1000.0 diff --git a/source/isaaclab_physx/test/sim/test_cloner.py b/source/isaaclab_physx/test/sim/test_cloner.py index 4bfba07d99e8..f90c740f17ed 100644 --- a/source/isaaclab_physx/test/sim/test_cloner.py +++ b/source/isaaclab_physx/test/sim/test_cloner.py @@ -21,10 +21,9 @@ import isaaclab.sim as sim_utils from isaaclab.cloner import ( - TemplateCloneCfg, _fabric_notices, - clone_from_template, disabled_fabric_change_notifies, + make_clone_plan, sequential, usd_replicate, ) @@ -242,19 +241,9 @@ def test_physx_replicate_heterogeneous_isolated_sources(sim, device): assert "/World/envs" in attach_excluded -def test_clone_from_template(sim): - """Clone prototypes via TemplateCloneCfg and clone_from_template and exercise both USD and PhysX. - - Steps: - - Create /World/template and /World/envs/env_0..env_31 - - Spawn three prototypes under /World/template/Object/proto_asset_.* - - Clone using TemplateCloneCfg with random_heterogeneous_cloning=False (modulo mapping) - - Verify modulo placement exists; then call sim.reset(), and create PhysX view - """ +def test_direct_clone_plan_multi_asset(sim): + """Clone representative env sources directly and exercise both USD and PhysX.""" num_clones = 32 - clone_cfg = TemplateCloneCfg(device=sim.cfg.device, clone_strategy=sequential) - sim_utils.create_prim(clone_cfg.template_root, "Xform") - sim_utils.create_prim(f"{clone_cfg.template_root}/Object", "Xform") sim_utils.create_prim("/World/envs", "Xform") for i in range(num_clones): sim_utils.create_prim(f"/World/envs/env_{i}", "Xform", translation=(0, 0, 0)) @@ -282,11 +271,22 @@ def test_clone_from_template(sim): mass_props=sim_utils.MassPropertiesCfg(mass=1.0), collision_props=sim_utils.CollisionPropertiesCfg(), ) - prim = cfg.func(f"{clone_cfg.template_root}/Object/{clone_cfg.template_prototype_identifier}_.*", cfg) + plan = make_clone_plan( + [[f"/World/envs/env_{i}/Object" for i in range(len(cfg.assets_cfg))]], + ["/World/envs/env_{}/Object"], + num_clones, + sequential, + sim.cfg.device, + ) + spawn_paths: list[str | None] = list(plan.sources) + cfg.spawn_paths = spawn_paths + prim = cfg.func("/World/unused", cfg) assert prim.IsValid() stage = sim_utils.get_current_stage() - clone_from_template(stage, num_clones=num_clones, template_clone_cfg=clone_cfg) + env_ids = torch.arange(num_clones, dtype=torch.long, device=sim.cfg.device) + physx_replicate(stage, plan.sources, plan.destinations, env_ids, plan.clone_mask, device=sim.cfg.device) + usd_replicate(stage, plan.sources, plan.destinations, env_ids, plan.clone_mask) primitive_prims = sim_utils.get_all_matching_child_prims( "/World/envs", predicate=lambda prim: prim.GetTypeName() in ["Cone", "Cube", "Sphere"] @@ -302,27 +302,38 @@ def test_clone_from_template(sim): assert primitive_prim.GetTypeName() == "Sphere" sim.reset() - object_view_regex = f"{clone_cfg.clone_regex}/Object".replace(".*", "*") physics_sim_view = sim.physics_manager.get_physics_sim_view() - physx_view = physics_sim_view.create_rigid_body_view(object_view_regex) + physx_view = physics_sim_view.create_rigid_body_view("/World/envs/env_*/Object") assert physx_view is not None def _run_colocation_collision_filter(sim, asset_cfg, expected_types, assert_count=False): """Shared harness for colocated collision filter checks across devices.""" num_clones = 32 - clone_cfg = TemplateCloneCfg(device=sim.cfg.device, clone_strategy=sequential) - sim_utils.create_prim(clone_cfg.template_root, "Xform") - sim_utils.create_prim(f"{clone_cfg.template_root}/Object", "Xform") sim_utils.create_prim("/World/envs", "Xform") for i in range(num_clones): sim_utils.create_prim(f"/World/envs/env_{i}", "Xform", translation=(0, 0, 0)) - prim = asset_cfg.func(f"{clone_cfg.template_root}/Object/{clone_cfg.template_prototype_identifier}_.*", asset_cfg) + num_variants = len(asset_cfg.assets_cfg) if isinstance(asset_cfg, sim_utils.MultiAssetSpawnerCfg) else 1 + plan = make_clone_plan( + [[f"/World/envs/env_{i}/Object" for i in range(num_variants)]], + ["/World/envs/env_{}/Object"], + num_clones, + sequential, + sim.cfg.device, + ) + if isinstance(asset_cfg, sim_utils.MultiAssetSpawnerCfg): + spawn_paths: list[str | None] = list(plan.sources) + asset_cfg.spawn_paths = spawn_paths + prim = asset_cfg.func("/World/unused", asset_cfg) + else: + prim = asset_cfg.func(plan.sources[0], asset_cfg) assert prim.IsValid() stage = sim_utils.get_current_stage() - clone_from_template(stage, num_clones=num_clones, template_clone_cfg=clone_cfg) + env_ids = torch.arange(num_clones, dtype=torch.long, device=sim.cfg.device) + physx_replicate(stage, plan.sources, plan.destinations, env_ids, plan.clone_mask, device=sim.cfg.device) + usd_replicate(stage, plan.sources, plan.destinations, env_ids, plan.clone_mask) primitive_prims = sim_utils.get_all_matching_child_prims( "/World/envs", predicate=lambda prim: prim.GetTypeName() in expected_types @@ -335,9 +346,8 @@ def _run_colocation_collision_filter(sim, asset_cfg, expected_types, assert_coun assert primitive_prim.GetTypeName() == expected_types[i % len(expected_types)] sim.reset() - object_view_regex = f"{clone_cfg.clone_regex}/Object".replace(".*", "*") physics_sim_view = sim.physics_manager.get_physics_sim_view() - physx_view = physics_sim_view.create_rigid_body_view(object_view_regex) + physx_view = physics_sim_view.create_rigid_body_view("/World/envs/env_*/Object") for _ in range(100): sim.step() transforms = wp.to_torch(physx_view.get_transforms()) From 4feb184efe2f67fff8a7e28664770484c6d264d8 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 06:19:01 +0000 Subject: [PATCH 034/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 4.8.2 → 5.0.0 - isaaclab_newton: 0.7.1 → 0.7.2 - isaaclab_physx: 0.6.2 → 0.6.3 --- .../clone-plan-visualizer-cleanup.minor.rst | 35 ----------- .../octi-cloner_ordering.major.rst | 29 ---------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 58 +++++++++++++++++++ .../changelog.d/octi-cloner_ordering.rst | 5 -- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 10 ++++ .../changelog.d/octi-cloner_ordering.skip | 0 .../changelog.d/octi-cloner_ordering.rst | 5 -- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 10 ++++ 11 files changed, 81 insertions(+), 77 deletions(-) delete mode 100644 source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst delete mode 100644 source/isaaclab/changelog.d/octi-cloner_ordering.major.rst delete mode 100644 source/isaaclab_newton/changelog.d/octi-cloner_ordering.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/octi-cloner_ordering.skip delete mode 100644 source/isaaclab_physx/changelog.d/octi-cloner_ordering.rst diff --git a/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst b/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst deleted file mode 100644 index c9ceb9405226..000000000000 --- a/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst +++ /dev/null @@ -1,35 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab.cloner.ClonePlan` as the flat clone contract shared by - scene cloning, backend replication, and scene-data providers. -* Added :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` and - :meth:`~isaaclab.sim.SimulationContext.set_clone_plan` for publishing the - scene's clone plan. -* Added :attr:`~isaaclab.scene.InteractiveScene.clone_plan` for consumers holding - a scene reference. - -Changed -^^^^^^^ - -* **Breaking:** Changed scene-data providers to build visualizer backend models - from :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` instead of a - clone-time visualizer artifact. Use the published - :class:`~isaaclab.cloner.ClonePlan` for custom scene-data integrations. - -Removed -^^^^^^^ - -* **Breaking:** Removed - :attr:`~isaaclab.cloner.TemplateCloneCfg.visualizer_clone_fn`, - :func:`~isaaclab.cloner.resolve_visualizer_clone_fn`, and - :class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts`. - Use the :class:`~isaaclab.cloner.ClonePlan` published through - :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` instead. -* **Breaking:** Removed - :meth:`~isaaclab.sim.SimulationContext.get_scene_data_visualizer_prebuilt_artifact`, - :meth:`~isaaclab.sim.SimulationContext.set_scene_data_visualizer_prebuilt_artifact`, - and - :meth:`~isaaclab.sim.SimulationContext.clear_scene_data_visualizer_prebuilt_artifact`. - Use :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` / - :meth:`~isaaclab.sim.SimulationContext.set_clone_plan` instead. diff --git a/source/isaaclab/changelog.d/octi-cloner_ordering.major.rst b/source/isaaclab/changelog.d/octi-cloner_ordering.major.rst deleted file mode 100644 index fd0906e5cd66..000000000000 --- a/source/isaaclab/changelog.d/octi-cloner_ordering.major.rst +++ /dev/null @@ -1,29 +0,0 @@ -Added -^^^^^ - -* Added explicit ``spawn_paths`` support to multi-asset spawners so scene - planning can spawn representative heterogeneous sources directly. - -Changed -^^^^^^^ - -* **Breaking:** Changed :class:`~isaaclab.scene.InteractiveScene` to build clone - plans directly from asset configuration, spawn representative sources in their - selected environments, and replicate from those sources instead of spawning and - discovering prototypes under ``/World/template``. -* **Breaking:** Replaced ``TemplateCloneCfg`` with - :class:`~isaaclab.cloner.CloneCfg` for clone execution settings. -* **Breaking:** Changed :func:`~isaaclab.cloner.make_clone_plan` to return a - :class:`~isaaclab.cloner.ClonePlan` object directly. -* **Breaking:** Changed clone plan publication to use - :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` and - :meth:`~isaaclab.sim.SimulationContext.set_clone_plan` for the single scene - clone plan. - -Removed -^^^^^^^ - -* **Breaking:** Removed :func:`~isaaclab.cloner.clone_from_template`. Use - :func:`~isaaclab.cloner.make_clone_plan`, - :func:`~isaaclab.cloner.usd_replicate`, and backend physics replication - functions for direct cloning workflows. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 338691cd0c00..70492269607c 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.8.2" +version = "5.0.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 186d00855ac4..77a0b816c5d9 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,64 @@ Changelog --------- +5.0.0 (2026-05-11) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab.cloner.ClonePlan` as the flat clone contract shared by + scene cloning, backend replication, and scene-data providers. +* Added :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` and + :meth:`~isaaclab.sim.SimulationContext.set_clone_plan` for publishing the + scene's clone plan. +* Added :attr:`~isaaclab.scene.InteractiveScene.clone_plan` for consumers holding + a scene reference. +* Added explicit ``spawn_paths`` support to multi-asset spawners so scene + planning can spawn representative heterogeneous sources directly. + +Changed +^^^^^^^ + +* **Breaking:** Changed scene-data providers to build visualizer backend models + from :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` instead of a + clone-time visualizer artifact. Use the published + :class:`~isaaclab.cloner.ClonePlan` for custom scene-data integrations. +* **Breaking:** Changed :class:`~isaaclab.scene.InteractiveScene` to build clone + plans directly from asset configuration, spawn representative sources in their + selected environments, and replicate from those sources instead of spawning and + discovering prototypes under ``/World/template``. +* **Breaking:** Replaced ``TemplateCloneCfg`` with + :class:`~isaaclab.cloner.CloneCfg` for clone execution settings. +* **Breaking:** Changed :func:`~isaaclab.cloner.make_clone_plan` to return a + :class:`~isaaclab.cloner.ClonePlan` object directly. +* **Breaking:** Changed clone plan publication to use + :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` and + :meth:`~isaaclab.sim.SimulationContext.set_clone_plan` for the single scene + clone plan. + +Removed +^^^^^^^ + +* **Breaking:** Removed + :attr:`~isaaclab.cloner.TemplateCloneCfg.visualizer_clone_fn`, + :func:`~isaaclab.cloner.resolve_visualizer_clone_fn`, and + :class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts`. + Use the :class:`~isaaclab.cloner.ClonePlan` published through + :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` instead. +* **Breaking:** Removed + :meth:`~isaaclab.sim.SimulationContext.get_scene_data_visualizer_prebuilt_artifact`, + :meth:`~isaaclab.sim.SimulationContext.set_scene_data_visualizer_prebuilt_artifact`, + and + :meth:`~isaaclab.sim.SimulationContext.clear_scene_data_visualizer_prebuilt_artifact`. + Use :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` / + :meth:`~isaaclab.sim.SimulationContext.set_clone_plan` instead. +* **Breaking:** Removed :func:`~isaaclab.cloner.clone_from_template`. Use + :func:`~isaaclab.cloner.make_clone_plan`, + :func:`~isaaclab.cloner.usd_replicate`, and backend physics replication + functions for direct cloning workflows. + + 4.8.2 (2026-05-10) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/changelog.d/octi-cloner_ordering.rst b/source/isaaclab_newton/changelog.d/octi-cloner_ordering.rst deleted file mode 100644 index 807d81e7c558..000000000000 --- a/source/isaaclab_newton/changelog.d/octi-cloner_ordering.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Changed rigid object collection spawning to honor planned ``spawn_path`` - values while falling back to ``prim_path`` for direct construction. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 0a55aaddb353..ee6aa21d379f 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.7.1" +version = "0.7.2" # 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 a472bf0643c6..7ed2a512d2e1 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.7.2 (2026-05-11) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed rigid object collection spawning to honor planned ``spawn_path`` + values while falling back to ``prim_path`` for direct construction. + + 0.7.1 (2026-05-09) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/changelog.d/octi-cloner_ordering.skip b/source/isaaclab_ovphysx/changelog.d/octi-cloner_ordering.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_physx/changelog.d/octi-cloner_ordering.rst b/source/isaaclab_physx/changelog.d/octi-cloner_ordering.rst deleted file mode 100644 index 807d81e7c558..000000000000 --- a/source/isaaclab_physx/changelog.d/octi-cloner_ordering.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Changed rigid object collection spawning to honor planned ``spawn_path`` - values while falling back to ``prim_path`` for direct construction. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index d630d9c945c8..4e00f31716d6 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.6.2" +version = "0.6.3" # 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 0220a659e721..95e059b045b6 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.6.3 (2026-05-11) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed rigid object collection spawning to honor planned ``spawn_path`` + values while falling back to ``prim_path`` for direct construction. + + 0.6.2 (2026-05-09) ~~~~~~~~~~~~~~~~~~ From e1fba6e2e8459a33e8cdfc589e8c1c5d21b5bd8c Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Mon, 11 May 2026 16:10:21 -0700 Subject: [PATCH 035/133] Fixes benchmark scripts when tensorboard logs are missing (#5564) # Description When benchmarking scripts are executed with num_iterations set to below the threshold for reward logging, the run can produce missing reward data. However, the scripts are hardcoded to always parse rewards from tensorboard, which may not exist in these cases. This change patches the RL benchmarking scripts to only process rewards logging if they were written to tensorboard. ## 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 - [ ] 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 --- scripts/benchmarks/benchmark_rlgames.py | 19 ++-- scripts/benchmarks/benchmark_rsl_rl.py | 20 ++--- .../benchmarks/test/test_training_metrics.py | 90 +++++++++++++++++++ scripts/benchmarks/utils.py | 46 ++++++++++ 4 files changed, 148 insertions(+), 27 deletions(-) create mode 100644 scripts/benchmarks/test/test_training_metrics.py diff --git a/scripts/benchmarks/benchmark_rlgames.py b/scripts/benchmarks/benchmark_rlgames.py index ab1d625d5aaf..52257f722651 100644 --- a/scripts/benchmarks/benchmark_rlgames.py +++ b/scripts/benchmarks/benchmark_rlgames.py @@ -103,13 +103,9 @@ from scripts.benchmarks.utils import ( get_backend_type, get_preset_string, - get_success_rate_log, log_app_start_time, - log_convergence, log_python_imports_time, - log_rl_policy_episode_lengths, - log_rl_policy_rewards, - log_rl_policy_success_rates, + log_rl_training_metrics, log_runtime_step_times, log_scene_creation_time, log_simulation_start_time, @@ -288,15 +284,12 @@ def main( log_simulation_start_time(benchmark, Timer.get_timer_info("simulation_start") * 1000) log_total_start_time(benchmark, (task_startup_time_end - app_start_time_begin) / 1e6) log_runtime_step_times(benchmark, rl_training_times, compute_stats=True) - log_rl_policy_rewards(benchmark, log_data["rewards/iter"]) - log_rl_policy_episode_lengths(benchmark, log_data["episode_lengths/iter"]) - success_rates = get_success_rate_log(log_data) - if success_rates is not None: - log_rl_policy_success_rates(benchmark, success_rates) - log_convergence( + log_rl_training_metrics( benchmark, - log_data["rewards/iter"], - args_cli.task, + log_data, + reward_tag="rewards/iter", + episode_length_tag="episode_lengths/iter", + task=args_cli.task, workflow="rl_games", should_check_convergence=args_cli.check_convergence, reward_threshold=args_cli.reward_threshold, diff --git a/scripts/benchmarks/benchmark_rsl_rl.py b/scripts/benchmarks/benchmark_rsl_rl.py index 0eef6063fba7..2afb1f74833b 100644 --- a/scripts/benchmarks/benchmark_rsl_rl.py +++ b/scripts/benchmarks/benchmark_rsl_rl.py @@ -105,13 +105,9 @@ from scripts.benchmarks.utils import ( get_backend_type, get_preset_string, - get_success_rate_log, log_app_start_time, - log_convergence, log_python_imports_time, - log_rl_policy_episode_lengths, - log_rl_policy_rewards, - log_rl_policy_success_rates, + log_rl_training_metrics, log_runtime_step_times, log_scene_creation_time, log_simulation_start_time, @@ -287,16 +283,12 @@ def main( log_simulation_start_time(benchmark, Timer.get_timer_info("simulation_start") * 1000) log_total_start_time(benchmark, (task_startup_time_end - app_start_time_begin) / 1e6) log_runtime_step_times(benchmark, rl_training_times, compute_stats=True) - log_rl_policy_rewards(benchmark, log_data["Train/mean_reward"]) - log_rl_policy_episode_lengths(benchmark, log_data["Train/mean_episode_length"]) - success_rates = get_success_rate_log(log_data) - if success_rates is not None: - log_rl_policy_success_rates(benchmark, success_rates) - - log_convergence( + log_rl_training_metrics( benchmark, - log_data["Train/mean_reward"], - args_cli.task, + log_data, + reward_tag="Train/mean_reward", + episode_length_tag="Train/mean_episode_length", + task=args_cli.task, workflow="rsl_rl", should_check_convergence=args_cli.check_convergence, reward_threshold=args_cli.reward_threshold, diff --git a/scripts/benchmarks/test/test_training_metrics.py b/scripts/benchmarks/test/test_training_metrics.py new file mode 100644 index 000000000000..a3187fb15cc9 --- /dev/null +++ b/scripts/benchmarks/test/test_training_metrics.py @@ -0,0 +1,90 @@ +# 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 benchmark training-metric logging helpers.""" + +from __future__ import annotations + +import pytest + +from scripts.benchmarks.utils import SUCCESS_RATE_LOG_TAGS, log_rl_training_metrics + + +class _FakeBenchmark: + """Collect benchmark measurements without initializing benchmark backends.""" + + def __init__(self): + self.measurements: list[tuple[str, str, object, str]] = [] + + def add_measurement(self, phase, measurement): + self.measurements.append((phase, measurement.name, measurement.value, getattr(measurement, "unit", ""))) + + def measurement_by_name(self, name: str): + return next(m for m in self.measurements if m[1] == name) + + +@pytest.mark.parametrize( + "workflow,reward_tag,episode_length_tag", + [ + ("rl_games", "rewards/iter", "episode_lengths/iter"), + ("rsl_rl", "Train/mean_reward", "Train/mean_episode_length"), + ], +) +def test_log_rl_training_metrics_skips_missing_short_run_scalars( + workflow: str, reward_tag: str, episode_length_tag: str, capsys: pytest.CaptureFixture[str] +): + """Short benchmark runs may finish before reward and episode-length scalars are emitted.""" + benchmark = _FakeBenchmark() + + log_rl_training_metrics( + benchmark, + log_data={}, + reward_tag=reward_tag, + episode_length_tag=episode_length_tag, + task="Isaac-Ant-v0", + workflow=workflow, + should_check_convergence=True, + ) + + assert benchmark.measurements == [] + output = capsys.readouterr().out + assert f"TensorBoard log is missing '{reward_tag}'" in output + assert f"TensorBoard log is missing '{episode_length_tag}'" in output + assert f"Cannot check convergence because '{reward_tag}' was not logged" in output + + +@pytest.mark.parametrize( + "workflow,reward_tag,episode_length_tag", + [ + ("rl_games", "rewards/iter", "episode_lengths/iter"), + ("rsl_rl", "Train/mean_reward", "Train/mean_episode_length"), + ], +) +def test_log_rl_training_metrics_logs_present_normal_run_scalars( + workflow: str, reward_tag: str, episode_length_tag: str, capsys: pytest.CaptureFixture[str] +): + """Normal runs with reward and episode-length scalars should log train metrics.""" + benchmark = _FakeBenchmark() + + log_rl_training_metrics( + benchmark, + log_data={ + reward_tag: [1.0, 2.0, 3.0], + episode_length_tag: [10.0, 11.0], + SUCCESS_RATE_LOG_TAGS[0]: [0.25, 0.5], + }, + reward_tag=reward_tag, + episode_length_tag=episode_length_tag, + task="Isaac-Ant-v0", + workflow=workflow, + ) + + assert benchmark.measurement_by_name("Rewards")[2] == [1.0, 2.0, 3.0] + assert benchmark.measurement_by_name("Max Rewards")[2] == 3.0 + assert benchmark.measurement_by_name("Episode Lengths")[2] == [10.0, 11.0] + assert benchmark.measurement_by_name("Max Episode Lengths")[2] == 11.0 + assert benchmark.measurement_by_name("Success Rates")[2] == [0.25, 0.5] + assert benchmark.measurement_by_name("success_rate")[2] == 0.5 + assert "TensorBoard log is missing" not in capsys.readouterr().out diff --git a/scripts/benchmarks/utils.py b/scripts/benchmarks/utils.py index e157765adb0e..05effa524172 100644 --- a/scripts/benchmarks/utils.py +++ b/scripts/benchmarks/utils.py @@ -295,6 +295,52 @@ def log_success(benchmark, tracker, framework_iteration_count: int | None = None ) +def log_rl_training_metrics( + benchmark: BaseIsaacLabBenchmark, + log_data: dict[str, list[float]], + reward_tag: str, + episode_length_tag: str, + task: str, + workflow: str, + should_check_convergence: bool = False, + reward_threshold: float | None = None, + convergence_config: str = "full", +) -> None: + """Log optional RL training metrics from TensorBoard data. + + Short smoke-test runs can finish before the RL framework emits reward or + episode-length scalars. Missing tags should skip those measurements instead + of failing the whole benchmark. + """ + rewards = log_data.get(reward_tag) + episode_lengths = log_data.get(episode_length_tag) + if rewards: + log_rl_policy_rewards(benchmark, rewards) + else: + print(f"[WARNING] TensorBoard log is missing '{reward_tag}'; skipping reward benchmark metrics.") + if episode_lengths: + log_rl_policy_episode_lengths(benchmark, episode_lengths) + else: + print(f"[WARNING] TensorBoard log is missing '{episode_length_tag}'; skipping episode-length metrics.") + + success_rates = get_success_rate_log(log_data) + if success_rates is not None: + log_rl_policy_success_rates(benchmark, success_rates) + + if rewards: + log_convergence( + benchmark, + rewards, + task, + workflow=workflow, + should_check_convergence=should_check_convergence, + reward_threshold=reward_threshold, + convergence_config=convergence_config, + ) + elif should_check_convergence: + print(f"[WARNING] Cannot check convergence because '{reward_tag}' was not logged.") + + def parse_cprofile_stats( profile: cProfile.Profile, isaaclab_prefixes: list[str], From 5442de1475bd15fe4c9eccf83dcb10d107a4c6e0 Mon Sep 17 00:00:00 2001 From: hougantc-nvda <127865892+hougantc-nvda@users.noreply.github.com> Date: Mon, 11 May 2026 19:13:59 -0400 Subject: [PATCH 036/133] Fixes manus docs moving. (#5577) 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 - 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/how-to/cloudxr_teleoperation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/how-to/cloudxr_teleoperation.rst b/docs/source/how-to/cloudxr_teleoperation.rst index 3613228d5b7f..9ddc95d4dfd9 100644 --- a/docs/source/how-to/cloudxr_teleoperation.rst +++ b/docs/source/how-to/cloudxr_teleoperation.rst @@ -404,7 +404,7 @@ API as headset-based optical hand tracking in Isaac Teleop, so the same retarget work with both input sources. For plugin configuration details, see the `Manus plugin documentation -`_. +`_. The recommended workflow: From 707e87d13d42b1912137b303561741613c722669 Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Mon, 11 May 2026 18:34:50 -0700 Subject: [PATCH 037/133] Pre and post physics renderer initialization (#5573) 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 --- .../scene-initialize-renderers.minor.rst | 31 +++++++++++++++ .../isaaclab/isaaclab/envs/direct_marl_env.py | 1 + .../isaaclab/isaaclab/envs/direct_rl_env.py | 1 + .../isaaclab/envs/leapp_deployment_env.py | 1 + .../isaaclab/envs/manager_based_env.py | 1 + .../isaaclab/renderers/base_renderer.py | 4 ++ .../isaaclab/renderers/render_context.py | 12 ++++++ .../isaaclab/scene/interactive_scene.py | 38 +++++++++++++++++++ .../isaaclab/sim/simulation_context.py | 9 ++++- .../scene-initialize-renderers.rst | 10 +++++ .../envs/direct_rl_env_warp.py | 1 + .../envs/manager_based_env_warp.py | 1 + .../scene-initialize-renderers.rst | 9 +++++ .../renderers/newton_warp_renderer.py | 19 ++++++---- .../scene-initialize-renderers.rst | 20 ++++++++++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 30 ++++++++------- 16 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 source/isaaclab/changelog.d/scene-initialize-renderers.minor.rst create mode 100644 source/isaaclab_experimental/changelog.d/scene-initialize-renderers.rst create mode 100644 source/isaaclab_newton/changelog.d/scene-initialize-renderers.rst create mode 100644 source/isaaclab_ov/changelog.d/scene-initialize-renderers.rst diff --git a/source/isaaclab/changelog.d/scene-initialize-renderers.minor.rst b/source/isaaclab/changelog.d/scene-initialize-renderers.minor.rst new file mode 100644 index 000000000000..86e29205be95 --- /dev/null +++ b/source/isaaclab/changelog.d/scene-initialize-renderers.minor.rst @@ -0,0 +1,31 @@ +Added +^^^^^ + +* Added :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers` to + pre-create renderer backends for all scene sensors with a + ``renderer_cfg`` against the shared + :class:`~isaaclab.renderers.render_context.RenderContext`. The method is + idempotent and is now invoked from + :class:`~isaaclab.envs.DirectRLEnv`, + :class:`~isaaclab.envs.DirectMARLEnv`, + :class:`~isaaclab.envs.ManagerBasedEnv`, and + :class:`~isaaclab.envs.LeappDeploymentEnv` after scene construction so + that renderer backend creation order is deterministic and front-loaded + before the first :meth:`~isaaclab.sim.SimulationContext.reset`. +* Added :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.initialize` + post-physics lifecycle hook (default no-op) that runs once per backend + after :meth:`~isaaclab.sim.SimulationContext.reset` builds physics + models. ``__init__`` now defines the pre-physics phase (eagerly invoked + by :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers`) and + ``initialize`` defines the post-physics phase, letting backends whose + setup needs scene data (e.g. a built Newton model) defer that work + cleanly. Driven by + :meth:`~isaaclab.renderers.render_context.RenderContext.ensure_initialize`, + registered on + :class:`~isaaclab.physics.physics_manager.PhysicsEvent` ``PHYSICS_READY`` + by :class:`~isaaclab.sim.SimulationContext` at ``order=5`` so it fires + before sensor/asset callbacks (``order=10``). This decouples renderer + post-physics setup from camera initialization. Backends created lazily + after PHYSICS_READY are eagerly initialized at + :meth:`~isaaclab.renderers.render_context.RenderContext.get_renderer` + time. diff --git a/source/isaaclab/isaaclab/envs/direct_marl_env.py b/source/isaaclab/isaaclab/envs/direct_marl_env.py index b8fda8cf0986..56d3a38cdd1c 100644 --- a/source/isaaclab/isaaclab/envs/direct_marl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_marl_env.py @@ -145,6 +145,7 @@ def _init_sim(self, render_mode: str | None = None, **kwargs): with use_stage(self.sim.stage): self.scene = InteractiveScene(self.cfg.scene) self._setup_scene() + self.scene.initialize_renderers() print("[INFO]: Scene manager: ", self.scene) # set up camera viewport controller diff --git a/source/isaaclab/isaaclab/envs/direct_rl_env.py b/source/isaaclab/isaaclab/envs/direct_rl_env.py index 9251eb0fe817..c03ca1f73596 100644 --- a/source/isaaclab/isaaclab/envs/direct_rl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_rl_env.py @@ -150,6 +150,7 @@ def _init_sim(self, render_mode: str | None = None, **kwargs): with use_stage(self.sim.stage): self.scene = InteractiveScene(self.cfg.scene) self._setup_scene() + self.scene.initialize_renderers() print("[INFO]: Scene manager: ", self.scene) # set up camera viewport controller diff --git a/source/isaaclab/isaaclab/envs/leapp_deployment_env.py b/source/isaaclab/isaaclab/envs/leapp_deployment_env.py index fe81e8f82e5f..3284284570fe 100644 --- a/source/isaaclab/isaaclab/envs/leapp_deployment_env.py +++ b/source/isaaclab/isaaclab/envs/leapp_deployment_env.py @@ -183,6 +183,7 @@ def __init__(self, cfg: Any, leapp_yaml_path: str): with use_stage(self.sim.stage): self.scene = InteractiveScene(cfg.scene) + self.scene.initialize_renderers() with use_stage(self.sim.stage): self.sim.reset() self.scene.update(dt=self.physics_dt) diff --git a/source/isaaclab/isaaclab/envs/manager_based_env.py b/source/isaaclab/isaaclab/envs/manager_based_env.py index 92db9ad117b5..620cf2895718 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env.py @@ -169,6 +169,7 @@ def _init_sim(self): # set the stage context for scene creation steps which use the stage with use_stage(self.sim.stage): self.scene = InteractiveScene(self.cfg.scene) + self.scene.initialize_renderers() print("[INFO]: Scene manager: ", self.scene) # set up camera viewport controller diff --git a/source/isaaclab/isaaclab/renderers/base_renderer.py b/source/isaaclab/isaaclab/renderers/base_renderer.py index 2fc498eae8e3..be0da6e1c116 100644 --- a/source/isaaclab/isaaclab/renderers/base_renderer.py +++ b/source/isaaclab/isaaclab/renderers/base_renderer.py @@ -22,6 +22,10 @@ class BaseRenderer(ABC): """Abstract base class for renderer implementations.""" + def initialize(self) -> None: + """Post-physics one-time initialization hook. Called only once.""" + return + @abstractmethod def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: """Per-output layout (channels + dtype) this renderer can produce. diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index 1c1a45a19454..a6e49883350b 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -33,6 +33,7 @@ class RenderContext: __slots__ = ( "_renderer_entries", + "_physics_initialized", "_prepared_renderer_ids", "_prepared_num_envs", "_last_transforms_step", @@ -40,6 +41,7 @@ class RenderContext: def __init__(self) -> None: self._renderer_entries: list[tuple[RendererCfg, BaseRenderer]] = [] + self._physics_initialized: bool = False # Set to True after the first PHYSICS_READY callback fires. self._prepared_renderer_ids: set[int] = set() self._prepared_num_envs: int | None = None self._last_transforms_step: int | None = None @@ -65,8 +67,18 @@ def get_renderer(self, cfg: RendererCfg) -> BaseRenderer: "Created new renderer for simulation: %s", type(new_renderer).__name__, ) + if self._physics_initialized: + new_renderer.initialize() return new_renderer + def ensure_initialize(self) -> None: + """Idempotent call fired after PHYSICS_READY callback.""" + if self._physics_initialized: + return + self._physics_initialized = True + for _cfg, renderer in self._renderer_entries: + renderer.initialize() + def ensure_prepare_stage(self, stage: Any, num_envs: int) -> None: """Call :meth:`BaseRenderer.prepare_stage` for each registered backend (once per backend). diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index ae39e3daa719..6b13afb4565f 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -12,6 +12,8 @@ if TYPE_CHECKING: from isaaclab_physx.assets import DeformableObject, SurfaceGripper + from isaaclab.renderers.base_renderer import BaseRenderer + import torch import warp as wp @@ -364,6 +366,42 @@ def _sensor_renderer_types(self) -> list[str]: if (rcfg := getattr(getattr(s, "cfg", None), "renderer_cfg", None)) is not None ] + def initialize_renderers(self) -> list[BaseRenderer]: + """Pre-create renderer backends for all scene sensors with a ``renderer_cfg``. + + Walks the constructed sensors and registers each unique + :class:`~isaaclab.renderers.renderer_cfg.RendererCfg` with the + simulation-scoped :class:`~isaaclab.renderers.render_context.RenderContext`. + Configs that compare equal share a single backend (see + :meth:`~isaaclab.renderers.render_context.RenderContext.get_renderer`), so + calling this method is idempotent and safe to invoke before + :meth:`~isaaclab.sim.SimulationContext.reset`. + + Pre-creating backends here makes the order of renderer construction + deterministic (matches sensor registration order) and front-loads logging + instead of trickling out during the first :meth:`Camera._initialize_impl`. + :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.prepare_stage` is + intentionally not invoked here; it runs on first camera initialization + with the correct ``num_envs`` and final stage. + + Returns: + The list of unique renderer backends now registered on the + shared :class:`~isaaclab.renderers.render_context.RenderContext`, + in sensor registration order. + """ + ctx = self.sim.render_context + backends: list[BaseRenderer] = [] + seen: set[int] = set() + for sensor in self._sensors.values(): + rcfg = getattr(getattr(sensor, "cfg", None), "renderer_cfg", None) + if rcfg is None: + continue + backend = ctx.get_renderer(rcfg) + if id(backend) not in seen: + seen.add(id(backend)) + backends.append(backend) + return backends + def filter_collisions(self, global_prim_paths: list[str] | None = None): """Filter environments collisions. diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 121b01fdb622..607221bd4874 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -23,7 +23,7 @@ from isaaclab.app.settings_manager import SettingsManager from isaaclab.envs.utils.recording_hooks import run_recording_hooks_after_visualizers from isaaclab.markers.vis_marker_registry import VisMarkerRegistry -from isaaclab.physics import BaseSceneDataProvider, PhysicsManager, SceneDataProvider +from isaaclab.physics import BaseSceneDataProvider, PhysicsEvent, PhysicsManager, SceneDataProvider from isaaclab.physics.scene_data_requirements import ( SceneDataRequirement, resolve_scene_data_requirements, @@ -207,6 +207,13 @@ def __init__(self, cfg: SimulationCfg | None = None): # Shared renderers for all Camera sensors (compatible renderer_cfg only). self._render_context = RenderContext() + # Run renderer post-physics setup. + self.physics_manager.register_callback( + lambda _payload: self._render_context.ensure_initialize(), + PhysicsEvent.PHYSICS_READY, + order=5, + ) + type(self)._instance = self # Mark as valid singleton only after successful init def _apply_render_cfg_settings(self) -> None: diff --git a/source/isaaclab_experimental/changelog.d/scene-initialize-renderers.rst b/source/isaaclab_experimental/changelog.d/scene-initialize-renderers.rst new file mode 100644 index 000000000000..e33ce79b241e --- /dev/null +++ b/source/isaaclab_experimental/changelog.d/scene-initialize-renderers.rst @@ -0,0 +1,10 @@ +Changed +^^^^^^^ + +* Pre-create renderer backends in + :class:`~isaaclab_experimental.envs.ManagerBasedEnvWarp` and + :class:`~isaaclab_experimental.envs.DirectRLEnvWarp` by invoking + :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers` after scene + construction so that renderer backend creation order is deterministic and + front-loaded before the first + :meth:`~isaaclab.sim.SimulationContext.reset`. diff --git a/source/isaaclab_experimental/isaaclab_experimental/envs/direct_rl_env_warp.py b/source/isaaclab_experimental/isaaclab_experimental/envs/direct_rl_env_warp.py index 125f2e61a429..20c82e582690 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/envs/direct_rl_env_warp.py +++ b/source/isaaclab_experimental/isaaclab_experimental/envs/direct_rl_env_warp.py @@ -164,6 +164,7 @@ def __init__(self, cfg: DirectRLEnvCfg, render_mode: str | None = None, **kwargs with use_stage(self.sim.stage): self.scene = InteractiveSceneWarp(self.cfg.scene) self._setup_scene() + self.scene.initialize_renderers() # attach_stage_to_usd_context() print("[INFO]: Scene manager: ", self.scene) diff --git a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py index 8c558b925947..5ace5168a9c8 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py +++ b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py @@ -132,6 +132,7 @@ def __init__(self, cfg: ManagerBasedEnvCfg): with use_stage(self.sim.stage): self.scene = InteractiveScene(self.cfg.scene) # attach_stage_to_usd_context() + self.scene.initialize_renderers() print("[INFO]: Scene manager: ", self.scene) # Shared per-env Warp RNG state (accessible to all managers/terms via `env`). diff --git a/source/isaaclab_newton/changelog.d/scene-initialize-renderers.rst b/source/isaaclab_newton/changelog.d/scene-initialize-renderers.rst new file mode 100644 index 000000000000..80eef5e9823b --- /dev/null +++ b/source/isaaclab_newton/changelog.d/scene-initialize-renderers.rst @@ -0,0 +1,9 @@ +Changed +^^^^^^^ + +* Split :class:`~isaaclab_newton.renderers.NewtonWarpRenderer` construction + into a pre-physics ``__init__`` (stores cfg and registers the Newton-Warp + scene-data requirement on + :class:`~isaaclab.sim.SimulationContext`) and a post-physics + :meth:`~isaaclab_newton.renderers.NewtonWarpRenderer.initialize` (reads + the built Newton model. 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 78285636b6ef..0ba3558c3504 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py @@ -139,12 +139,15 @@ class NewtonWarpRenderer(BaseRenderer): RenderData = RenderData def __init__(self, cfg: NewtonWarpRendererCfg): + """Pre-physics initialization.""" from isaaclab.physics.scene_data_requirements import ( aggregate_requirements, requirement_for_renderer_type, ) self.cfg = cfg + self.newton_sensor: newton.sensors.SensorTiledCamera | None = None + sim = SimulationContext.instance() current_req = sim.get_scene_data_requirements() renderer_req = requirement_for_renderer_type("newton_warp") @@ -152,6 +155,8 @@ def __init__(self, cfg: NewtonWarpRendererCfg): if merged != current_req: sim.update_scene_data_requirements(merged) + def initialize(self) -> None: + """Post-physics setup: read the built Newton model and construct the sensor.""" newton_model = self.get_scene_data_provider().get_newton_model() if newton_model is None: raise RuntimeError( @@ -164,11 +169,11 @@ def __init__(self, cfg: NewtonWarpRendererCfg): self.newton_sensor = newton.sensors.SensorTiledCamera( newton_model, config=newton.sensors.SensorTiledCamera.RenderConfig( - enable_textures=cfg.enable_textures, - enable_shadows=cfg.enable_shadows, - enable_ambient_lighting=cfg.enable_ambient_lighting, - enable_backface_culling=cfg.enable_backface_culling, - max_distance=cfg.max_distance, + enable_textures=self.cfg.enable_textures, + enable_shadows=self.cfg.enable_shadows, + enable_ambient_lighting=self.cfg.enable_ambient_lighting, + enable_backface_culling=self.cfg.enable_backface_culling, + max_distance=self.cfg.max_distance, ), ) @@ -180,8 +185,8 @@ def __init__(self, cfg: NewtonWarpRendererCfg): if newton_model.shape_count > 0 and newton_model.bvh_shapes is None: newton.geometry.build_bvh_shape(newton_model, newton_model.state()) - if cfg.create_default_light: - self.newton_sensor.utils.create_default_light(enable_shadows=cfg.enable_shadows) + if self.cfg.create_default_light: + self.newton_sensor.utils.create_default_light(enable_shadows=self.cfg.enable_shadows) def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: """Publish the per-output layout this Newton Warp backend writes. diff --git a/source/isaaclab_ov/changelog.d/scene-initialize-renderers.rst b/source/isaaclab_ov/changelog.d/scene-initialize-renderers.rst new file mode 100644 index 000000000000..61103b21d517 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/scene-initialize-renderers.rst @@ -0,0 +1,20 @@ +Changed +^^^^^^^ + +* Construct the underlying OVRTX ``Renderer`` in + :class:`~isaaclab_ov.renderers.OVRTXRenderer` ``__init__`` instead of + during :meth:`~isaaclab_ov.renderers.OVRTXRenderer.prepare_stage`. This + pairs with the new pre-physics ``__init__`` / + post-physics :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.initialize` + lifecycle: when invoked eagerly via + :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers`, the OVRTX + ``Renderer`` is created before + :meth:`~isaaclab.sim.SimulationContext.reset` (and therefore before + ovphysx initialises), which OVRTX 0.3 requires. +* Replaced an ``assert`` on the OVRTX ``Renderer`` construction with an + explicit :class:`RuntimeError` so the failure is reported even when + Python is run with ``-O``. +* Renamed the internal ``OVRTXRenderer.initialize(spec)`` helper to + ``_initialize_from_spec(spec)`` to avoid shadowing the new + no-arg :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.initialize` + lifecycle hook. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 3c05a72dc30f..99ad0554048e 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -159,6 +159,21 @@ def __init__(self, cfg: OVRTXRendererCfg): self._camera_rel_path: str | None = None self._output_semantic_color_buffer: wp.array | None = None + logger.info("Creating OVRTX renderer...") + OVRTX_CONFIG = RendererConfig( + log_file_path=self.cfg.log_file_path, + log_level=self.cfg.log_level, + read_gpu_transforms=_IS_OVRTX_0_3_0_OR_NEWER, + keep_system_alive=True, + ) + self._renderer = Renderer(OVRTX_CONFIG) + if not self._renderer: + raise RuntimeError( + "Failed to create OVRTX Renderer; the underlying ovrtx.Renderer constructor returned a falsy" + " value. Check that ovrtx is installed correctly and its native dependencies are available." + ) + logger.info("OVRTX renderer created successfully") + def prepare_stage(self, stage: Any, num_envs: int) -> None: """Export the USD stage for OVRTX before create_render_data. @@ -178,7 +193,7 @@ def prepare_stage(self, stage: Any, num_envs: int) -> None: self._exported_usd_path = export_path logger.info("Exported to %s", export_path) - def initialize(self, spec: CameraRenderSpec): + def _initialize_from_spec(self, spec: CameraRenderSpec): """Initialize the OVRTX renderer with internal environment cloning. Args: @@ -198,17 +213,6 @@ def initialize(self, spec: CameraRenderSpec): usd_scene_path = self._exported_usd_path use_cloning = self.cfg.use_cloning - logger.info("Creating OVRTX renderer...") - OVRTX_CONFIG = RendererConfig( - log_file_path=self.cfg.log_file_path, - log_level=self.cfg.log_level, - read_gpu_transforms=_IS_OVRTX_0_3_0_OR_NEWER, - keep_system_alive=True, - ) - self._renderer = Renderer(OVRTX_CONFIG) - assert self._renderer, "Renderer should be valid after creation" - logger.info("OVRTX renderer created successfully") - if usd_scene_path is not None: logger.info("Injecting camera definitions...") @@ -367,7 +371,7 @@ def create_render_data(self, spec: CameraRenderSpec) -> OVRTXRenderData: matching the interface of Isaac RTX and Newton Warp which need no separate initialize(). """ if not self._initialized_scene: - self.initialize(spec) + self._initialize_from_spec(spec) return OVRTXRenderData(spec, DEVICE) # Map torch dtypes to their warp counterparts for zero-copy wrapping. From a337c0b036bae540bd47d4cd09b1c15fc0083f39 Mon Sep 17 00:00:00 2001 From: hougantc-nvda <127865892+hougantc-nvda@users.noreply.github.com> Date: Mon, 11 May 2026 21:38:40 -0400 Subject: [PATCH 038/133] Enables pipelined IsaacTeleop retargeting (#5493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR improves IsaacLab Teleop retargeting performance and installation reliability. - Added configurable IsaacTeleop retargeting execution via `IsaacTeleopCfg.retargeting_execution`. - Enabled deadline-paced pipelined retargeting by default, so IsaacTeleop retargeting work can overlap with Isaac Lab simulation stepping. - Preserved synchronous retargeting as an opt-in mode for exact current-frame behavior. - Added an extension-level `pip_upgrade_dependencies` setting in `extension.toml` so `./isaaclab.sh --install` can explicitly upgrade selected `install_requires` dependencies after editable install. - Used that mechanism for `isaaclab_teleop` to upgrade to the latest compatible `isaacteleop` without duplicating the version spec outside `setup.py`. ## Why The pipelined retargeting path reduces Python-side frame pressure by returning the latest completed retargeting output while the current frame is submitted in parallel. The install change fixes CI/local environments where an older compatible `isaacteleop` version is already installed. Since `pip install -e source/isaaclab_teleop` does not upgrade already-satisfied dependencies by default, which could keep using a stale IsaacTeleop package. The new targeted upgrade keeps the version range in `setup.py` as the source of truth while still allowing the install command to refresh `isaacteleop`. # 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 - 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 --- docs/source/features/isaac_teleop.rst | 8 + .../overview/developer-guide/development.rst | 23 ++- .../hougantc-enable-pipeline-retarget.rst | 5 + .../isaaclab/isaaclab/cli/commands/install.py | 114 ++++++++++++ .../test/cli/test_install_commands.py | 162 ++++++++++++++++++ .../hougantc-pipelined-retargeting.rst | 23 +++ source/isaaclab_teleop/config/extension.toml | 4 + source/isaaclab_teleop/docs/README.md | 5 + .../isaaclab_teleop/isaac_teleop_cfg.py | 15 ++ .../isaaclab_teleop/session_lifecycle.py | 1 + .../test/test_cloudxr_lifecycle.py | 81 ++++++++- .../templates/extension/config/extension.toml | 5 + 12 files changed, 437 insertions(+), 9 deletions(-) create mode 100644 source/isaaclab/changelog.d/hougantc-enable-pipeline-retarget.rst create mode 100644 source/isaaclab_teleop/changelog.d/hougantc-pipelined-retargeting.rst diff --git a/docs/source/features/isaac_teleop.rst b/docs/source/features/isaac_teleop.rst index 733150259586..f5d30e39b161 100644 --- a/docs/source/features/isaac_teleop.rst +++ b/docs/source/features/isaac_teleop.rst @@ -685,6 +685,14 @@ Key ``IsaacTeleopCfg`` fields: * ``xr_cfg`` -- :class:`~isaaclab_teleop.XrCfg` for anchor configuration (see below). * ``plugins`` -- list of Isaac Teleop plugin configurations (e.g. Manus). * ``sim_device`` -- torch device string (default ``"cuda:0"``). +* ``retargeting_execution`` -- IsaacTeleop retargeting execution settings. + Defaults to ``RetargetingExecutionConfig(mode="pipelined")`` with + ``DeadlinePacingConfig(safety_margin_s=0.025)`` so retargeting can run on + the IsaacTeleop worker instead of blocking the simulation loop. + The 25 ms safety margin staggers IsaacTeleop's Python work behind Isaac + Lab's step Python, giving native work such as rendering time to overlap + instead of having both Python stacks contend for the GIL at the start of + the step. .. warning:: diff --git a/docs/source/overview/developer-guide/development.rst b/docs/source/overview/developer-guide/development.rst index 3d9ddbf014a3..2f789ac01acd 100644 --- a/docs/source/overview/developer-guide/development.rst +++ b/docs/source/overview/developer-guide/development.rst @@ -81,18 +81,21 @@ Custom Extension Dependency Management ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Certain extensions may have dependencies which require the installation of additional packages before the extension -can be used. While Python dependencies are handled by the `setuptools `__ -package and specified in the ``setup.py`` file, non-Python dependencies such as `ROS `__ -packages or `apt `__ packages are not handled by setuptools. -Handling these kinds of dependencies requires an additional procedure. +can be used. Python dependencies are handled by the `setuptools `__ +package and specified in the ``setup.py`` file. Non-Python dependencies such as +`ROS `__ packages or `apt `__ +packages are not handled by setuptools. Handling these kinds of dependencies requires an additional procedure. -There are two types of dependencies that can be specified in the ``extension.toml`` file +There are three types of dependencies that can be specified in the ``extension.toml`` file under the ``isaac_lab_settings`` section: 1. **apt_deps**: A list of apt packages that need to be installed. These are installed using the `apt `__ package manager. 2. **ros_ws**: The path to the ROS workspace that contains the ROS packages. These are installed using the `rosdep `__ dependency manager. +3. **pip_upgrade_dependencies**: A list of ``install_requires`` dependency names that should be explicitly + upgraded after installing the extension with ``./isaaclab.sh --install``. List package names only. Version + ranges, extras, and platform markers are read from the installed extension metadata generated from ``setup.py``. As an example, the following ``extension.toml`` file specifies the dependencies for the extension: @@ -106,8 +109,11 @@ As an example, the following ``extension.toml`` file specifies the dependencies # note: if this path is relative, it is relative to the extension directory's root ros_ws = "/home/user/catkin_ws" -These dependencies are installed using the ``install_deps.py`` script provided in the ``tools`` directory. -To install all dependencies for all extensions, run the following command: + # Python dependency names to upgrade after installing this extension + pip_upgrade_dependencies = ["example_package"] + +The ``apt_deps`` and ``ros_ws`` dependencies are installed using the ``install_deps.py`` script provided in the +``tools`` directory. To install all apt and ROS dependencies for all extensions, run the following command: .. code-block:: bash @@ -121,6 +127,9 @@ To install all dependencies for all extensions, run the following command: and ``Dockerfile.ros2``. This ensures that all the 'apt' and 'rosdep' dependencies are installed before building the extensions respectively. +The ``pip_upgrade_dependencies`` entries are handled by ``./isaaclab.sh --install`` after the extension's editable +pip install completes. + Standalone applications ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/changelog.d/hougantc-enable-pipeline-retarget.rst b/source/isaaclab/changelog.d/hougantc-enable-pipeline-retarget.rst new file mode 100644 index 000000000000..451e8c1e572d --- /dev/null +++ b/source/isaaclab/changelog.d/hougantc-enable-pipeline-retarget.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed extension installation to honor ``pip_upgrade_dependencies`` declared + in ``config/extension.toml``. diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index 46125ae46f1e..f83c4dfdbf87 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -4,10 +4,13 @@ # SPDX-License-Identifier: BSD-3-Clause import os +import re import shutil import sys from pathlib import Path +import tomllib + from ..utils import ( ISAACLAB_ROOT, extract_isaacsim_path, @@ -286,6 +289,111 @@ def _ensure_cuda_torch() -> None: NVIDIA_INDEX_URL = "https://pypi.nvidia.com" +def _normalize_package_name(name: str) -> str: + """Normalize a Python package name for metadata comparisons.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def _requirement_name(requirement: str) -> str: + """Extract the distribution name from a requirement string.""" + requirement = requirement.split(";", 1)[0].strip() + return re.split(r"\s|<|>|=|!|~|\[|@", requirement, maxsplit=1)[0] + + +def _get_installed_distribution_requirements(python_exe: str, distribution_name: str) -> list[str]: + """Return installed ``Requires-Dist`` requirements for a distribution.""" + probe = """import importlib.metadata +import sys + +try: + dist = importlib.metadata.distribution(sys.argv[1]) +except importlib.metadata.PackageNotFoundError: + sys.exit(1) + +for requirement in dist.requires or []: + print(requirement) +""" + result = run_command( + [python_exe, "-c", probe, distribution_name], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print_warning(f"Could not read installed metadata for {distribution_name}; skipping dependency upgrades.") + return [] + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def _get_extension_pip_upgrade_dependencies(extension_dir: Path) -> list[str]: + """Read dependency names opted into targeted pip upgrades from ``extension.toml``.""" + extension_toml = extension_dir / "config" / "extension.toml" + if not extension_toml.is_file(): + return [] + + try: + with extension_toml.open("rb") as fd: + extension_data = tomllib.load(fd) + except tomllib.TOMLDecodeError as exc: + print_warning(f"Could not parse {extension_toml}: {exc}; skipping targeted dependency upgrades.") + return [] + + isaac_lab_settings = extension_data.get("isaac_lab_settings", {}) + if not isinstance(isaac_lab_settings, dict): + print_warning( + f"Ignoring invalid isaac_lab_settings in {extension_toml}; expected a table with pip_upgrade_dependencies." + ) + return [] + + upgrade_dependencies = isaac_lab_settings.get("pip_upgrade_dependencies", []) + if not isinstance(upgrade_dependencies, list) or not all(isinstance(item, str) for item in upgrade_dependencies): + print_warning(f"Ignoring invalid pip_upgrade_dependencies in {extension_toml}; expected a list of strings.") + return [] + + return upgrade_dependencies + + +def _get_pip_upgrade_command(pip_cmd: list[str], dependency_name: str, requirement: str) -> list[str]: + """Return a pip command that upgrades one dependency requirement.""" + if pip_cmd[0] == "uv": + return pip_cmd + ["install", "--upgrade-package", dependency_name, requirement] + return pip_cmd + ["install", "--upgrade", requirement] + + +def _upgrade_extension_pip_dependencies( + python_exe: str, + pip_cmd: list[str], + distribution_name: str, + dependency_names: list[str], +) -> None: + """Upgrade selected dependencies using installed distribution metadata requirements.""" + if not dependency_names: + return + + requirements = _get_installed_distribution_requirements(python_exe, distribution_name) + seen_dependency_names = set() + + for dependency_name in dependency_names: + normalized_dependency_name = _normalize_package_name(dependency_name) + if normalized_dependency_name in seen_dependency_names: + continue + seen_dependency_names.add(normalized_dependency_name) + + matching_requirements = [ + req for req in requirements if _normalize_package_name(_requirement_name(req)) == normalized_dependency_name + ] + if not matching_requirements: + print_warning( + f"Could not find dependency '{dependency_name}' in installed metadata for {distribution_name}; " + "skipping targeted upgrade." + ) + continue + + for requirement in matching_requirements: + print_info(f"Upgrading {dependency_name} for {distribution_name}: {requirement}") + run_command(_get_pip_upgrade_command(pip_cmd, dependency_name, requirement)) + + def _install_isaacsim() -> None: """Install Isaac Sim pip package if not already present.""" python_exe = extract_python_exe() @@ -414,6 +522,12 @@ def _install_isaaclab_submodules( editable = (submodule_extras or {}).get(item.name, "") install_target = f"{item}{editable}" run_command(pip_cmd + ["install", "--editable", install_target]) + _upgrade_extension_pip_dependencies( + python_exe, + pip_cmd, + item.name, + _get_extension_pip_upgrade_dependencies(item), + ) def _install_extra_frameworks(framework_name: str = "all") -> None: diff --git a/source/isaaclab/test/cli/test_install_commands.py b/source/isaaclab/test/cli/test_install_commands.py index a7c89ccd9d53..f773d9f026cf 100644 --- a/source/isaaclab/test/cli/test_install_commands.py +++ b/source/isaaclab/test/cli/test_install_commands.py @@ -17,6 +17,7 @@ import pytest +import isaaclab.cli.commands.install as install_cmd from isaaclab.cli.commands.install import ( _PREBUNDLE_REPOINT_PACKAGES, _ensure_cuda_torch, @@ -68,6 +69,167 @@ def _make_site_packages( return site_pkgs +# --------------------------------------------------------------------------- +# _install_isaaclab_submodules targeted dependency upgrades +# --------------------------------------------------------------------------- + + +class TestInstallSubmodulesTargetedDependencyUpgrades: + """Tests for extension.toml-driven dependency upgrades.""" + + def _make_extension(self, tmp_path, extension_toml: str) -> Path: + """Create a minimal installable extension fixture.""" + source_dir = tmp_path / "source" + extension_dir = source_dir / "isaaclab_teleop" + config_dir = extension_dir / "config" + config_dir.mkdir(parents=True) + (extension_dir / "setup.py").write_text("# test fixture\n", encoding="utf-8") + (config_dir / "extension.toml").write_text(extension_toml, encoding="utf-8") + return extension_dir + + def test_installs_editable_then_upgrades_declared_dependency_from_metadata(self, tmp_path): + """An opted-in dependency is upgraded using the requirement recorded in installed metadata.""" + extension_dir = self._make_extension( + tmp_path, + '[isaac_lab_settings]\npip_upgrade_dependencies = ["isaacteleop"]\n', + ) + + python_exe = str(tmp_path / "python") + pip_cmd = [python_exe, "-m", "pip"] + isaacteleop_req = 'isaacteleop[cloudxr,retargeters,ui] ~=1.2.0; platform_system == "Linux"' + + with ( + mock.patch("isaaclab.cli.commands.install.ISAACLAB_ROOT", tmp_path), + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=python_exe), + mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), + mock.patch( + "isaaclab.cli.commands.install._get_installed_distribution_requirements", + return_value=[isaacteleop_req], + ), + mock.patch("isaaclab.cli.commands.install.run_command") as mock_run, + ): + install_cmd._install_isaaclab_submodules(["isaaclab_teleop"]) + + assert [call.args[0] for call in mock_run.call_args_list] == [ + pip_cmd + ["install", "--editable", str(extension_dir)], + pip_cmd + ["install", "--upgrade", isaacteleop_req], + ] + + def test_uv_install_uses_upgrade_package_for_declared_dependency(self, tmp_path): + """uv upgrades only the declared package rather than using a global upgrade.""" + extension_dir = self._make_extension( + tmp_path, + '[isaac_lab_settings]\npip_upgrade_dependencies = ["isaacteleop"]\n', + ) + + python_exe = str(tmp_path / "python") + pip_cmd = ["uv", "pip"] + isaacteleop_req = 'isaacteleop[cloudxr,retargeters,ui] ~=1.2.0; platform_system == "Linux"' + + with ( + mock.patch("isaaclab.cli.commands.install.ISAACLAB_ROOT", tmp_path), + mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=python_exe), + mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), + mock.patch( + "isaaclab.cli.commands.install._get_installed_distribution_requirements", + return_value=[isaacteleop_req], + ), + mock.patch("isaaclab.cli.commands.install.run_command") as mock_run, + ): + install_cmd._install_isaaclab_submodules(["isaaclab_teleop"]) + + assert [call.args[0] for call in mock_run.call_args_list] == [ + pip_cmd + ["install", "--editable", str(extension_dir)], + pip_cmd + ["install", "--upgrade-package", "isaacteleop", isaacteleop_req], + ] + + def test_upgrades_all_matching_metadata_requirements(self, tmp_path): + """Duplicate metadata entries are preserved instead of collapsing to one requirement.""" + python_exe = str(tmp_path / "python") + pip_cmd = [python_exe, "-m", "pip"] + linux_req = 'example-package>=1.0; platform_system == "Linux"' + windows_req = 'example_package>=2.0; platform_system == "Windows"' + + with ( + mock.patch( + "isaaclab.cli.commands.install._get_installed_distribution_requirements", + return_value=[linux_req, windows_req], + ), + mock.patch("isaaclab.cli.commands.install.run_command") as mock_run, + ): + install_cmd._upgrade_extension_pip_dependencies( + python_exe, + pip_cmd, + "isaaclab_teleop", + ["example-package"], + ) + + assert [call.args[0] for call in mock_run.call_args_list] == [ + pip_cmd + ["install", "--upgrade", linux_req], + pip_cmd + ["install", "--upgrade", windows_req], + ] + + def test_skips_duplicate_declared_dependency_names(self, tmp_path): + """Duplicate TOML dependency names do not trigger duplicate pip commands.""" + python_exe = str(tmp_path / "python") + pip_cmd = [python_exe, "-m", "pip"] + req = "isaacteleop~=1.2.0" + + with ( + mock.patch( + "isaaclab.cli.commands.install._get_installed_distribution_requirements", + return_value=[req], + ), + mock.patch("isaaclab.cli.commands.install.run_command") as mock_run, + ): + install_cmd._upgrade_extension_pip_dependencies( + python_exe, + pip_cmd, + "isaaclab_teleop", + ["isaacteleop", "IsaacTeleop"], + ) + + mock_run.assert_called_once_with(pip_cmd + ["install", "--upgrade", req]) + + def test_skips_when_toml_has_no_upgrade_dependencies(self, tmp_path): + """Extensions without pip upgrade opt-ins do not trigger metadata probes.""" + extension_dir = self._make_extension(tmp_path, "[isaac_lab_settings]\n") + + assert install_cmd._get_extension_pip_upgrade_dependencies(extension_dir) == [] + + def test_warns_and_skips_invalid_upgrade_dependency_names(self, tmp_path): + """Invalid TOML value types warn and disable targeted upgrades.""" + extension_dir = self._make_extension( + tmp_path, + '[isaac_lab_settings]\npip_upgrade_dependencies = "isaacteleop"\n', + ) + + with mock.patch("isaaclab.cli.commands.install.print_warning") as mock_warning: + assert install_cmd._get_extension_pip_upgrade_dependencies(extension_dir) == [] + + mock_warning.assert_called_once() + + def test_warns_when_declared_dependency_missing_from_metadata(self, tmp_path): + """A declared dependency name must exist in installed package metadata.""" + with ( + mock.patch( + "isaaclab.cli.commands.install._get_installed_distribution_requirements", + return_value=["dex-retargeting==0.5.0"], + ), + mock.patch("isaaclab.cli.commands.install.print_warning") as mock_warning, + mock.patch("isaaclab.cli.commands.install.run_command") as mock_run, + ): + install_cmd._upgrade_extension_pip_dependencies( + str(tmp_path / "python"), + [str(tmp_path / "python"), "-m", "pip"], + "isaaclab_teleop", + ["isaacteleop"], + ) + + mock_warning.assert_called_once() + mock_run.assert_not_called() + + # --------------------------------------------------------------------------- # _torch_first_on_sys_path_is_prebundle # --------------------------------------------------------------------------- diff --git a/source/isaaclab_teleop/changelog.d/hougantc-pipelined-retargeting.rst b/source/isaaclab_teleop/changelog.d/hougantc-pipelined-retargeting.rst new file mode 100644 index 000000000000..2a58c6560fab --- /dev/null +++ b/source/isaaclab_teleop/changelog.d/hougantc-pipelined-retargeting.rst @@ -0,0 +1,23 @@ +Added +^^^^^ + +* Added :attr:`~isaaclab_teleop.IsaacTeleopCfg.retargeting_execution` for + configuring IsaacTeleop retargeting execution mode from Isaac Lab. + +Changed +^^^^^^^ + +* Changed :class:`~isaaclab_teleop.IsaacTeleopCfg` to enable IsaacTeleop + deadline-paced pipelined retargeting by default. This returns the latest + completed retargeting output while the current frame is submitted, using + ``DeadlinePacingConfig(safety_margin_s=0.025)`` to sample close to the next + simulation consumption point and stagger IsaacTeleop's Python work behind + Isaac Lab's step Python. Set + ``retargeting_execution=RetargetingExecutionConfig(mode="sync")`` to restore + exact current-frame retargeting. + +Fixed +^^^^^ + +* Fixed installation to upgrade to the latest compatible ``isaacteleop`` + package when installing ``isaaclab_teleop``. diff --git a/source/isaaclab_teleop/config/extension.toml b/source/isaaclab_teleop/config/extension.toml index 48415f4bf5eb..9fd1742d243f 100644 --- a/source/isaaclab_teleop/config/extension.toml +++ b/source/isaaclab_teleop/config/extension.toml @@ -13,6 +13,10 @@ keywords = ["kit", "robotics", "teleoperation", "xr", "isaaclab"] [dependencies] "isaaclab" = {} +[isaac_lab_settings] +# Names only. Version ranges, extras, and platform markers come from setup.py metadata. +pip_upgrade_dependencies = ["isaacteleop"] + [core] reloadable = false diff --git a/source/isaaclab_teleop/docs/README.md b/source/isaaclab_teleop/docs/README.md index bd6a3c7f6c7b..1f412e0f0238 100644 --- a/source/isaaclab_teleop/docs/README.md +++ b/source/isaaclab_teleop/docs/README.md @@ -120,9 +120,14 @@ rendering without blocking. | `retargeters_to_tune` | `Callable[[], list[BaseRetargeter]] \| None` | `None` | Retargeters to expose in the tuning UI | | `plugins` | `list[PluginConfig]` | `[]` | IsaacTeleop plugin configurations | | `sim_device` | `str` | `"cuda:0"` | Torch device for output action tensors | +| `retargeting_execution` | `RetargetingExecutionConfig` | `mode="pipelined", pacing=DeadlinePacingConfig(safety_margin_s=0.025)` | IsaacTeleop retargeting execution settings | | `teleoperation_active_default` | `bool` | `False` | Whether teleoperation is active on session start | | `app_name` | `str` | `"IsaacLabTeleop"` | Application name for the IsaacTeleop session | +The 25 ms `DeadlinePacingConfig` safety margin staggers IsaacTeleop's Python work behind Isaac Lab's +step Python, giving native work such as rendering time to overlap instead of having both Python stacks +contend for the GIL at the start of the step. + ### `XrCfg` | Field | Type | Default | Description | diff --git a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py index f94a63d57589..a15d09bebb01 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py +++ b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py @@ -12,6 +12,8 @@ from pathlib import Path from typing import TYPE_CHECKING +from isaacteleop.teleop_session_manager import DeadlinePacingConfig, RetargetingExecutionConfig + from isaaclab.utils import configclass from .control_events import TELEOP_CONTROL_CHANNEL_UUID @@ -95,6 +97,19 @@ def build_pipeline(): sim_device: str = "cuda:0" """Torch device string for placing output action tensors.""" + retargeting_execution: RetargetingExecutionConfig = field( + default_factory=lambda: RetargetingExecutionConfig( + mode="pipelined", + pacing=DeadlinePacingConfig(safety_margin_s=0.025), + ) + ) + """IsaacTeleop retargeting execution settings. + + Isaac Lab opts into IsaacTeleop's pipelined execution by default. Set this + to ``RetargetingExecutionConfig(mode="sync")`` for exact current-frame + retargeting while debugging or comparing behavior. + """ + teleoperation_active_default: bool = False """Whether teleoperation should be active by default when the session starts. diff --git a/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py b/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py index fa5f36f658e0..9c3a7813cd81 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py +++ b/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py @@ -502,6 +502,7 @@ def _try_start_session(self) -> bool: teleop_control_pipeline=self._teleop_control_pipeline, plugins=self._cfg.plugins, oxr_handles=oxr_handles, + retargeting_execution=self._cfg.retargeting_execution, ) # Create and enter the TeleopSession diff --git a/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py b/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py index 43131f70cfc3..457a2a26d967 100644 --- a/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py +++ b/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py @@ -20,6 +20,7 @@ import os import sys +from dataclasses import dataclass from pathlib import Path from types import ModuleType from unittest.mock import MagicMock, patch @@ -70,8 +71,38 @@ def _install_stubs(): """Insert MagicMock modules for all heavy dependencies.""" for name in _MODULES_TO_STUB: if name not in sys.modules: - _stubs_installed[name] = MagicMock() - sys.modules[name] = _stubs_installed[name] + sys.modules[name] = _stubs_installed.setdefault(name, MagicMock()) + if "." in name: + parent_name, child_name = name.rsplit(".", 1) + setattr(sys.modules[parent_name], child_name, sys.modules[name]) + + @dataclass + class DeadlinePacingConfig: + safety_margin_s: float = 0.025 + + @dataclass + class RetargetingExecutionConfig: + mode: str = "sync" + pacing: DeadlinePacingConfig | None = None + + tsm = sys.modules["isaacteleop.teleop_session_manager"] + tsm.DeadlinePacingConfig = DeadlinePacingConfig # type: ignore[attr-defined] + tsm.RetargetingExecutionConfig = RetargetingExecutionConfig # type: ignore[attr-defined] + + +def _restore_stubs(): + """Remove stubs installed for this test module from ``sys.modules``.""" + for name in reversed(_MODULES_TO_STUB): + stub = _stubs_installed.get(name) + if stub is None: + continue + if "." in name: + parent_name, child_name = name.rsplit(".", 1) + parent = sys.modules.get(parent_name) + if parent is not None and getattr(parent, child_name, None) is stub: + delattr(parent, child_name) + if sys.modules.get(name) is stub: + del sys.modules[name] _install_stubs() @@ -83,11 +114,21 @@ def _install_stubs(): ) from isaaclab_teleop.session_lifecycle import TeleopSessionLifecycle # noqa: E402 +_restore_stubs() + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def _stub_heavy_dependencies(): + """Keep CloudXR tests isolated from modules collected later in the suite.""" + _install_stubs() + yield + _restore_stubs() + + def _make_cfg() -> IsaacTeleopCfg: """Build a minimal IsaacTeleopCfg with a dummy pipeline_builder.""" return IsaacTeleopCfg( @@ -139,6 +180,42 @@ def test_profiles_are_in_same_directory(self): assert Path(CLOUDXR_AVP_ENV).parent == Path(CLOUDXR_JS_ENV).parent +# ============================================================================ +# IsaacTeleop execution config +# ============================================================================ + + +class TestRetargetingExecutionConfig: + """Tests for Isaac Lab's IsaacTeleop retargeting execution defaults.""" + + def test_session_config_receives_deadline_paced_pipelined_retargeting(self): + """The default retargeting execution config is passed into TeleopSession.""" + cfg = _make_cfg() + + assert cfg.retargeting_execution.mode == "pipelined" + assert cfg.retargeting_execution.pacing.safety_margin_s == 0.025 + + sentinel_execution = cfg.retargeting_execution + + lifecycle = TeleopSessionLifecycle(cfg) + lifecycle._pipeline = MagicMock() + lifecycle._teleop_control_pipeline = None + + session_config_cls = MagicMock(return_value=MagicMock()) + session_cls = MagicMock() + fake_tsm_module = sys.modules["isaacteleop.teleop_session_manager"] + + with ( + patch.object(fake_tsm_module, "TeleopSessionConfig", session_config_cls), + patch.object(fake_tsm_module, "TeleopSession", session_cls), + patch.object(lifecycle, "_ensure_xr_ar_profile_enabled"), + patch.object(lifecycle, "_acquire_kit_oxr_handles", return_value=object()), + ): + assert lifecycle.try_start_session() is True + + assert session_config_cls.call_args.kwargs["retargeting_execution"] is sentinel_execution + + # ============================================================================ # _ensure_cloudxr_runtime # ============================================================================ diff --git a/tools/template/templates/extension/config/extension.toml b/tools/template/templates/extension/config/extension.toml index dbe4b064fbc4..c23cf2de1287 100644 --- a/tools/template/templates/extension/config/extension.toml +++ b/tools/template/templates/extension/config/extension.toml @@ -33,3 +33,8 @@ name = "{{ name }}" # with rosdeps to be installed. If none, # leave it commented out. # ros_ws = "path/from/extension_root/to/ros_ws" +# TODO: Uncomment and list install_requires dependency names that should be upgraded +# after this extension is installed with ./isaaclab.sh --install. +# List package names only; version ranges, extras, and platform markers +# come from this extension's setup.py metadata. +# pip_upgrade_dependencies = ["example_package"] From dd5a21aeea35a69450cf26714c0e47ff4ca7293c Mon Sep 17 00:00:00 2001 From: mingxueg Date: Tue, 12 May 2026 10:12:49 +0800 Subject: [PATCH 039/133] Add assemble_trocar task for G129-Dex3 (#5082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds the **Assemble Trocar** manipulation task for the Unitree G1 (29-DoF + Dex3), with RLinf support. Key additions: - **Task MDP**: observations (body + Dex3 joint states), reward functions (4-stage sparse), termination conditions (timeout, success, object drop), and reset events (scene reset, task stage reset, random tray rotation). - **Camera presets**: front camera and left/right wrist cameras (TiledCamera, 224×224) configured for GR00T visual input. - **Robot presets**: G1 29-DoF + Dex3 articulation configuration. - **GR00T data config**: `IsaacLabDataConfig` defining video/state/action modality keys, transforms (SinCos state encoding, min-max action normalization, color jitter), and model-specific settings. - **RLinf extension update**: minor update to `isaaclab_contrib/rl/rlinf/extension.py` to support the new task registration. ## Type of change - 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` - [ ] 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 --------- Co-authored-by: Kelly Guo --- .../tasks/manipulation/g1_assemble_trocar.jpg | Bin 0 -> 476908 bytes .../experimental-features/bleeding-edge.rst | 141 ++++ docs/source/overview/environments.rst | 9 + .../reinforcement_learning/rlinf/README.md | 10 +- .../Adds-Assemble-Trocar-task-Based-RLinf.rst | 5 + .../isaaclab_assets/robots/unitree.py | 202 +++++- .../Adds-Assemble-Trocar-task-Based-RLinf.rst | 5 + .../isaaclab_contrib/rl/rlinf/extension.py | 15 +- .../Adds-Assemble-Trocar-task-Based-RLinf.rst | 7 + .../manipulation/assemble_trocar/__init__.py | 6 + .../assemble_trocar/config/__init__.py | 9 + .../assemble_trocar/config/camera_config.py | 131 ++++ .../assemble_trocar/config/gr00t_config.py | 144 ++++ .../isaaclab_ppo_gr00t_assemble_trocar.yaml | 298 ++++++++ .../assemble_trocar/config/robot_config.py | 147 ++++ .../assemble_trocar/g129_dex3_env_cfg.py | 444 ++++++++++++ .../assemble_trocar/mdp/__init__.py | 10 + .../assemble_trocar/mdp/events.py | 253 +++++++ .../assemble_trocar/mdp/observations.py | 119 ++++ .../assemble_trocar/mdp/rewards.py | 634 ++++++++++++++++++ .../assemble_trocar/mdp/terminations.py | 80 +++ 21 files changed, 2662 insertions(+), 7 deletions(-) create mode 100644 docs/source/_static/tasks/manipulation/g1_assemble_trocar.jpg create mode 100644 source/isaaclab_assets/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst create mode 100644 source/isaaclab_contrib/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst create mode 100644 source/isaaclab_tasks/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/camera_config.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/gr00t_config.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/isaaclab_ppo_gr00t_assemble_trocar.yaml create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/g129_dex3_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/events.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/observations.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/rewards.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/terminations.py diff --git a/docs/source/_static/tasks/manipulation/g1_assemble_trocar.jpg b/docs/source/_static/tasks/manipulation/g1_assemble_trocar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ad62167a29332177e8b88a2c18e9e299ca13e7a4 GIT binary patch literal 476908 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%0ARdMwbq#Hs5@h<25S~k< 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 z z>|@BJgIAD0Y2$b&c|vZLaM&3De?J5oXqNtYv-A>M(uPi+%M#=6R9@YEw7P4zk|5I& zJuQf4j!%}w&3D6d7ftwf<7~h;nH4#+IL{wnFBn(ZB&%Qil!{Pq^P3V+;H}@pp%kMV z2j3W04w3m+x`DTBbQvi%QX~V%DlsRP1Ott^-XZVCH?w-xpOvtew@n~_CqCjC^2V_5Z$aKZTn$h5u@Ekg9tFzxxfnpEIa&sWSN;k`>Xszi=e>U>kLIxynxW*4+r`Zcj zVRV0Yt`M9wTgrAIU*O8`FI?yQZFyah*a{vY(Hkg2vOrk{37f`g1^7okCI>;!4+3CMJVvW;fSI;%Wq-upq|W4=%IARy*lO?^o6;qSijH>Z3;=tvsi}A0+~Pw zM~v}BW9Cb5T!zQi{i}+FZDK%J0jp+M@6kE~8vU$v>>Jg&q1<~~kB&dOZbhqQJ;@@J zkbA)0z0k<;!}3~r*WWM(lD8OiC4+}LqA|)JIK1`}@y256YQe3~K&*$s`Z;3dEIkx> zH$OfJv@=Dpcsxt&l-4>ND%VFo+dOgIa{Pp#umnER3fmq()%iMm0d;Zn@(H@sWVFLL z^g|O0A5vXmdIbnG5bz!IG9t`o#9201Uu#Aj`l}T}Qa6W5(1lMk=RTWGw@Q>dKpoYH z4sX_JHDl|VKcCUmDXYLu^PGH%zv6si#NHUpV{y6Kbr^5k)e};%H!5 zAS-fOcd228c5~zaQsuyM{9glvP990Wg!zd726Ef|j~GrMq&+L|y^s?Cyu3Gi>KJ`% zx*8Y;drZq*v;)Iq?HJFuDT$yfW+-kzf&6(yY+N>}0opR}KWeB5{J<>A71b;bZC~G1 zk;Gf@6I`)f1W;^KcCjoPy7^)XKw1Xt!pD1nkb>b+2SET>t3BKRK>606u&nAoAS0*i zOCzA!xU=~XCH(yQ5^_Il5}rf{uZZxW;rIWIDDL# zbP8aY^;>_wc$nxG!2{4v@kje5g5Ovp8$ygFw|nmre39!6?6jvTuMhR5@{HSJOp5c9JrXBB>DOK>2>=s%PEC_ z4_nSCytw}OykG7~&ox#Mo@8DPYodg{S{pDcS zEs3eJVj1!^SLvvAYir7|EsHSuUDFkMOv_`+v-OZAJEyMnQUCMMlyBo?(9W;-+AM!f zQWsi&M%)su4&UnGHTy={ezX*Ky|?(TbWqih25T3`h=-lik!#%%s$!;JG5nVxUJG6Y zxS7z%1)37^jbzX2$>$;-l;=7hBz7FxgfE!IsGzXT^ovTObz|u#%YBs3cfjI}+lR)+ z6ICR5W3CI1KV*Ir1e&OoepO%L8I*oxGkohi=3i-EadaOzS3T>T1AhmlrI!Its()6~t%8e15Bch|47I4^hWM!C)@lwd4ntl>@|Iy8zbTB|nSG!sfCpAey zaB5a_dBCm3iv0GBLqto>(oSc0W#69T6N-?_5#F&B{Qyj$*woo1O+ZA?@28tTmhz{2+q#Vc0+5P-RmYs5 z-Av{CC~?^1)&F-fB?mwFUkbc<-c??SkQJ=FyZG-KzM=?KfwdW-?#<3~4>-peG^e^l z1#7Zz5(#E?$t!_`H%twfTh0b{S^HO{BZnr!yWALE;1!eg*^jS#kACx-F{>1EYBny# z9CB`WsyBz2@WNdURop}s_-H&;kx~s$m!=q@M*85SyMoC`cH5~QZ6iTAv~BTz4m*j; z((&pf01sq8HEEThqm!BP{J0T-5$eM=qrpF^8&D5*bkl=+U8ac#9Z6X6>VaP^MfmT) z8|HB=Ve?hBYU0%#Tr?G{r!$RU8H8ouy;`)#wkz&0tUQqF(`2{E1&M(+Pu*$emHvk> zO@ktpsb!VU0;LCkxft2l!CjFq0kWU>gRhUdY*melj5#}<((w9;$Of|^&&{|;J`PS$5!8l<&fY%odJ!#m4KAv5G>LB%TJx*+5kC5`@56eGd0=1rdh>7yIq z!uSSgu07P)i_9V3d)WJ~XYeAU?%fE1`Tgt)HlpyEL{uS<>1>AIOchNRyatb7556c=))RF0Anpr<_3TGK0ARv|Fd1~z9xfpL zS>oWN@uH5~UEqEV1}d1(e+8{NrGT%ebD0coqP>F;` z<61swibEHGngfO2lXJLQ#31-!0DKMKzMm8)-OPb(uT*v{86h2DNO~M#UPj>@P>#|= z+WouTsG#-$ECtFVW*RAqH_Zs-Iyhx$F0;=Iq1~@@l2FC+k3hxoc>V(%NEUO_!4FoD z{#1k_0kWea!2c&oAO&3pMF=5yna##0!3ex?3RrVR5M(z7#YZttL!_~wZuW4+rWUqP zpC5|)gZ;q{hJApE(r0-@{>sMo3HT8;9w2^+63p;tnm~r-0gdA_|6P_;7PNL7c*KCT zH^w7CysM>kuSSJzcJ!Aq2N%$Gx90c-0B{#?0WdX2J(NW>;vMF70S&PxbcZhc!G>)G zKiFgBDH-WYV3a}{_)rq{mlD2eLI3;jP8z~Usv}HzoEBx*P5x!E0>PABW0^<9{7PKF zFJtI}B}(v1$sdC*hw0emve0hEygi9X`kH*WYSYvDVtSZ*sX=`^X-JPr{dYku?mYGB z7p_5ayfZ7))X|?_j#cbqw$knkMzjQZ9U^BFWxt+dM ze(h7~Z&SZ!+WSp7L~BiD2mDJK9PcF*6#H31j-)qHa*rHvz3#chxl8}xCVE}7X!`h- zQuX&Y(l!3ezPl&KKqE)lhGQ8b_ycpqx^@H0Z4B+Fx9-h+&<&YSNc=_wl_Iu+C|{^a zDwC&WBq1dLgLuMPqy@qhEBoYJ@tHFbS@UcJZi9F(M2Jrsq|?$Q`EfD5n5Qt~gOtx``3^|!ATpq&zXK4a3mPxJEpZ>S1&|{D1XyY z(o3X&n4$TAfoT}J=LND#F`gHd-mw*|%=PgLPz|eW7IX1+lf0d-t%YEV_i=kZvx=pS zYUU3Jc;*#LAs0;eHaG5>Mi9rZOv8YnUh+$;8*M zF4m@Mr~ukZ>Q4Y0QVP!}Xwz}(1jM<)8GR%`R`Way_$FzKdN4cB17HP+&EFo$q5VzH z3CH~SF~Esen400ZWG|3``9k}HHzhu~fGwP%%Vy^V9n~6sxt)G63pKo<|;o*R%gA&^2awwiU94v;4=IrJnoD?fo^fdAjY7u1GO; zzS~*fw~XY^Gpw6AOwU;XxhD0W2yrPT^4`4OgHV;L2OR+1D}l_ltdHj#WIq3(M% zKXXISRdxhQl1E(hRh?TG!HA>VX?ND(+FB&M(m3K!nS_Or;!aclf=_gz04aKI^_7o1 z7U~^cjf&@tzn!?ny1qgdp)bu#eVz^ObHBMQ%agPiY)gNG3*ptvYnKOqhUef(TsfD# z-J!YK6u!>ESKIp1*aX-9+x6y>G4vAnkN>R~)}0m5i`B`w;r`c5!6#zjK~Ji^`mN9y z4tvTZO%BSU^$nE3#6Ep4gZuoq8iUg!RzHWr34atrx{6Nemlvz5FJH6nc-$5#YWi9H zjIv?teshED19P#0uAWYVE}~IP$m1G>Ep^j*n7VPzw_g#m)+a7PP^IlOA;s^Mo<$pf>60NqPoBbq?= zy*v+t#*6qf!S15}#dS=?B!e=F5R6X zmtugwQs^Q;5Y{RV%*fDG!*0#MgVPOR{vwmh#rMO|{@(!YA~A*zY5>K;3K^t*VYi*U zXueq-2(b3##`p&R8tFRLc=HI*4vJ4mdK&N%?Z9K5`JW;R zn^gc@qu%2G@HF&~aR9eI)bP7FB!3`2SqZpcVFd8fzE7CdwDpDmJ_&TB{dU&|z9&G} z{|7*Q)3tMl;@OB71NXDVmR6Z8(0;>xV1dN4z)UGHbJXDgye$$K!cz-DKjk9I{JWMm z*p(IY;dj!bBZqcvRm5t((1KAaJY=VSd-22GZ!35RWNqFGeUg6On1&{1rdRjjb^NF+ zjJJ`BaHg;w@Jc)0cSu}(MMv^BIr3@K)9peT07~lpB?HF-BwAprCWToge$L`N$euv5 ziZ|5yW7~NaBlRNaCm##zCD&*aKJu~vlWj6E0XB#HH=P zegEkdb?FB!`_2Uwi@fR)wkC(ZfvLLzwYS!G~XT;wC0U6>j%_%9WuKe-| z#JlT?8`b{?ylJ3HUk;s!{TF%#hdlgV*{~4nuK&Nt1q|`74#?smw~N5N!W}Z&i_Y8} z>!F5cf9y>U=%~CtQwKIB6YBi7T$P~9JI#0Yb&jrGzeT9a-H(;4K5GY2j1H(TQ}4~_ z**@c6@K-}Und&iRh9L&AY8w_Nn7e9u^(L3yRrP;a!oR=O3LW?#4xB~*=4CC<+mrw( z&yRmN^>5rw*$~)RK)D5o@^*XDHkKOypu^GZa%cT^2}p~;|Gx+S7Auly@Xfc_>2$Zb zG3L2?YYmT%#!5PHT>ig{y!!7&94p}R5oW4sPHGxzT-(p+@diyrJ{{gor&Mdm<7`(( z;{CM0X%0`#GG~h|`aRrn`*qf<&v)Q`-Cx7qUjY^Sa{7@Y3aWo|S^Z+*chr}|KQOG3kr!Jhpi zy@9y=#gXCb_gf`eNyFJ1QWRX`AMnZ>vw}Q}3}{TPgiVLpSqt>nX-EPS-^m;EBMH0!^g>4-2fvR(Hz}O;(-qnYd9#Fz zFeT6mnfOc%d&nA_DOsS$rpxmqh$yHjNl8`fa-gWtcY8iaq>{`BsxY_8H2Xm>$-+81 zuB;IveO;NsGbP5rO-A+NGsV4LpQ!I#`RP1coV%Pp8)n63XtwE>UD9=2!Q}};6Gj@T z;B24o%MKZrlUCPbcBjLbXya~dDEI9;KhMIh*uBBFIj)~q7hV3S54th_q}B2A*`=cc zk6}2-OM~bv>AfZhzw4Gz9haGT%Y3n>F92sT;%12WB%#Dn*^I4!^wmyr(N;NSZ3i^3k>Li!UC3eZVS z$OF7%TLC8<9*)qDBO};!>$#t7$A-81m$ySscL8l@Xg90(2Zt%>dN^>qy23*%xr&(~ z$5Fro|L$sl6FmPKahXjo4otLK{x>i3`IzyT3BiIP=6&F~mf$5`Aj3&Q2~2GgV?vey zeiNt%um+oKB)usW&=kjj0`|&z!9N7*0Ti=^0X*49Izmu{?*=G>AU9+7(VM*4chCQU zB`MObk%FaLlG?d?2X^oC>HL>a2vT6x91=bPjn|SS+J`LJNmji^OoOq(_t?GA(-2^@ zi3R@zhlxvmTVyu&SoQ))i%qG0uDxq^vi$-z3gCO&`tseO?FFQd0Ov0-!=gJbE<|Do z7Pts?)CipJ#$CRW7#jlKYNCCb<=Kh~_ZIi6!=;6qgFBew ze{id=Y`petWB`A$a^U$R|I%2t#F)QuRl5n-{Se54Cqg#}Gt}O9{^VJg5=LV-$WGN`|nVPiA+JL*R4`C=AEM z7!6KES}Gn)l0R~K@d>!^oH$_CL~6u;zX?^65ySV{AJbbgfPlZYf~9~4(Bt>dcjn)hDiuY5P~0S@#Lf4P{ZinD zkb~8#KzB<~Ha~NlJ|;|Sh<990b%?IQALG8dN$k#6QpY#0GNddlexA2UDi|wS=V~=3 zXg%|zzh$mGZqDb7E;#HhkM9V)Xz&tzU9CK7)38NZu6yz&X}dwXUO0FIdq{{MI-!Rg zlUID-yOMJH5;`G|Ja~AwehCnK(O6=O%m~$+XaJ{C9uZq+cL)CgWy;3jp}CpWD*p#0 z0lpN-w)=|m>v+6!5M{*)GbL#}mso75lHz`69V@ijc`eb#k=S0ycGY#c^n1=ou+oi`k&XHk!MwZT4E%Q4jH+&uih80}B_`<%47qnCiQDn*ai4%91D6AW=t4S>BFaD<%; zi50#2yW5r-im{Cb22K`!HK4ow?Kafx?j{p2WY_cBa!rc4bj@|RFd1^hi@Q{cuZo(? za$IfYvV3gYTy~PXymeb~4yXwvaq^!llRal*5`$XYjvk6 z3cIvL9Q&6r15!_~E3Fpv?`Bv={VfdcMEFzjxmo_~-?q-o9*&wth4V250dT1L>rDrY z)WBoWrq84i>4z??ojLeG$0N|Ym30c#hXINpvD7RM2-xm*Y?7WexV_%iEQ}-zIBsxQ z@601c3qnz80gBlGU1*a>GUT0={RsTh5iC!PHUY+c;@jw;18+vZG;Y{6A=t3X!R6gN zm~-K#LJ{|D&au#YleG28GxZYU4hh&nmRwlTU-*2q{Sk>|{e*YkH=B1ZShQ4;(fZ>- z1E>}1AszD9Sf;qT_#D*FAE%A()SvNhc6LxReV<0W6?+Y4R3n-*dB z!W3a22tvExsxLzt<*l*5bfM##eE$a31~L4qHNC8L5zMhu7CeZ$!30;}fqWYN{!na~ z17R46l}9dG5dy89qSwnJ=CdEkzuZc@gs4OQxJDoo)HaQsOY>q?)ih>El*&&eKgv8- z@jtQyLvRl>?uC^0z7L!&3KRM(ZjiraGg2ODHv@X`zWE=#tKw3bnCnz z&+udoaVKtxywvAFOa^$RW}T&Nd6V~T$fXkYB{Hdu@t$_WOdYqv3c13l!lW~j~?l{EgE_X z2{e_*J*pGK)5fzWCPrfz&T@p zw#T;$nbuTefOtqyUJ5pCKCp2Yd>$#@Nf=&q{xWoYA~9xjNMXEGcd^|6wo)(gO7=|E z&apY4nPj?lOEwJiFLXlptUu*MKpwEz_^aI{X5~{p>6F0a3@S@;2U4Ll*xjslM7r)v zZ>EQL|3wAKsn^J>Pvbww$bUU;_sCxVG@fcan)gHx{c&4;FXpN8{>ZzepcQHVGnx;d z^579M?KG2;b1sdojp`Tp8&GU$)?V#XukLe|sj}Oj%GC^y`j)}4w`%AI0UTHos=2iy zfKKVbu7H8<+~nwjgUZyOGkx5&$QUE)-p!W=z?jc* zuK6`3p87_d@^67Q8i%b`E#Egy6|x1(!o2&#)SZyt^`NbSnhDx!xK~3UdchBQG|ISX zmzZ{-w1u7Z(48z_(EihE+E|8kgzgUv@#5yD(t6>AyuDMdq05`_kAB#HW@`0MP8jO3 zNwLw0gYMkPis`@PeheLOHBC83-)z12zc^|Zz+*}pQO3sord9-AMVYuK6vF;D4pxz*(TuB(S?xoh*>LXlb$8{>*xOmB zALdK?Ugn`$pC1-%AA`_XPPGyx&&(JDJuf>V>V}ZKTO3YuG#Zp2iysjaYa~~v<+G&o zNhZq*j3L$plnHUbE-s7}_pI>o?z_DYD4UX~HR&UteDzTxXC zRUu*y9P8!iwB5Ag*%%=LRqc4-atTt$ESEV?=0|lXdQO-mN1#GV>=&T$bj0xLNnV;j z$Q#->qBQuvx0QV8g2&)zWnt+FKfPhsJY~?^sR`7Gsu)@;5Q7DxfANjDiisRyfJ)im z*)r;Ax}R=K+xxg^g~Je*KjnmjY{~~VK0haBWTU9ekv6d=?%ey@os@fOutFf^!Uo+W z9Z)W;NRE+mH-pE*?(R}9f#U}r-mwvigM&jUsttHI!QIEJ4tTsR_z<5XH#bOR0wGt! zdlPv-lhQ-$+30S9&q@YV@m!Idam%f@NZ;a@Jq{(9OOzMo_m0hSd?zJhX~H?UZ{T<6 zU^^FylGg~i-_x5x<>a1^G_N?AORdU8Lq&H2$xE~l3q5J;P_6hD)TzOLKvys9!w1zO zu!QZ0XfkF9!a?F{7yAYBLcLIY7Qz)(-YW%t=z)GdjpU5=Tl4xRmdC0R$Xk0fVR*af z`<}yeGlcByb>{IKHSh_hb2>p2&~s2-=GYN!m@aZrb_zOeGqk9QQlvo^<;X9e2w^9l1ZvOF&>-w{>ZG9$F zgJqL}9|h3Xe@yDSbh2jeyp2adk_M5i=x+0W>ddf>50jH_qUL@M z?K>9Vscp!El`a*6W8}kAe`305Ez+z94JlZRzV64;($fNNq z_H$pmIiOlf-cAh^9NrmG2J)}T{R_#0D?lF2QEs@so0MBG4d|I|prHhl_D2cs0e0D5 z3yP9T2Vlmo!se=3njtKLf(IM=ILuY#?SsN2mUmWP6ej zqg8J{;IujF$4oIM(uw7Ne4L|(_MBI$oJ$ts!}0>so^@dps=H@P-&;KdIK0NR3zeE| zpK-}CSpl69m?Jq*0S;%&)~%yczPB+gmvMKW6F@X!S&;kgYPXg*8r89z5|XMA)quAM zu@&P$c!YRxMhQwnZPVy0Df+vqz1CElt+$GR1qDp{io{tscC=_EpMmRaAIXt&F1YN_ zoIznyIQmd;J9!HUDstYc8 zfD`U`K&_KZ=5$33`TiWc%a1_Q(NT43etv~n7+jo>Inho7*R9;5jITCoeRTx&6)%yZ z>7*%K$~hMbuSC8s4oExo?nm~B!qTKq{~mE1UPZCT+58p<(s&5b!Hm91(X$APudSc{ zL|*3*d^xhS<*mZ}^4E~!KcGlK2)+ZCebKUj(~utMB+;+<4D!Wl5+l8NxaI3bzV+7c zZLjzmJkuD@FVLkk)+a1+^FU_S`fiElSE9-L<6onTsRSD=h{)@kGWSU1;;o+^n0254 z-d8CvjVwgsss_<80V7JGQYrurW}w{AB2 zXvvG!JB6M|;eqO#O7`=Qd`5^K{RG*i^^aewPxO=S38k>WF@gu}XVrm{zpGI;6|&o~ zm&_!dKjNTuz^N>+@zJH)a)N#ESqH&x`?-81%U7M&^tUOg^0?aDpzIcy>Gcdy#}_1j zXy6ZIF7oH8dw3mm5^XHdw*!fN0g>XU==!eWTmWIhk7@&iPY&6{CNY7|-RF%HNdGogcqjM*em$+JjtQ7ul zFavi~TfE2jJb})=fr$F{r{xXDKo7J)AzKyk7vvHuV~vuLub0xlRKH|vp9 z1pJzJ_Vo#elF28fa2&~>P6!1&Yh&PWz5WlL|G2z$^u$#TCKx~a?rZ@53wTQcKt9*G-6eXE-&bqpGi8ke#|W6{W>JT&!wOY}ojYdepin-oCq&9#<-A{=kb(lB;I zs?FIzlgG!1ub%QUoR2B23C!C(jSYuMBnu84;ek9Ydl(?+mvlD*h23yKOJW+juCu?; zPCw6t`@XS;_hBEDhlugVdwk8zg^CR?=-;I78))tz~szo~-+r%+hjvwuV%Q*_x zdQ3XKwoQ;r+tLUcLzX7NZ()^J#mSU+G-o5UF9u$dU1N4kqP`q1cv)&EFo)0m^jDx1 z=es6*WJT{&OyYDp>-S$4tDvNcyF~VV#>+@_c*Vze>E!()C7~-~9@)G=S!_3g@w4Rc zN#;<$?syXv&rnDudKzv zqk&)c*3ACw`NW)(;fGGrPdP~VAU*`6CxC2z_WzRj5Uj$OIITW!8ejVDHW$?EdOJaqMzV`N4VZ-}Y}cf* z$oa&n@^p#v+g$1uqg(sNP1%vmcm3(qJL(B`PBksfWkX|A1%`(39Shfsn6pjzudgr9 ztdmXM&>O%7AG)BkA#V?;#)WG;@%fUHCI5jD)+yL^3U=RcRDmh5s;prjVySQ9E5Gi$12cE zUCf#<5aIszS6zMD*`6?+=CnxXlU#L0keiyci3PilmdYg=ddW)RFl0XlNLsaw<1z*&D}lSClCou~)wZWA9e$JgT>Vf%3(Cij zVQnOL^>HR3mhoE3+Bb>|6RWbkGXHC;xCO&F$|7mR@g{V%wcIr>x});@40gCzR{O22 z{4N^fO`JxN{k-9qxO#86ELUx8UDEyMK*`5qvDcLCEnU56(l$ZJ4gHG_;N;Dr<7IY zR^x4(m8S+GT`E9m^PA+*W;`F2<*Px3TLzZ9`3{Um-E+JPP&DiF4t8YD20o^Pqfe{` z2hgzuNq&t`$Mj*X6b^8JXZ#q7oQl``s?(TgerDr(Fs=&nbEWkJX8Bo~Lg>D;L_^kG zI7Zs%QZh281B(16F@I6N?}Uz=c04|Ux7Ag*@R-*ht&gCc(w}xX#_=Wx3`-@(x=5K?2 zREu6xSwAZ09s-mZSyAt1rx52%VD2pW`A?|+)&`ksqZUPOm5N-Et*G#*#-5CqNdq*Q z$3-DS^r`tNjaY8;rzsR!m@U_j5=S=4i!M%Qp5@zE-=W?PG0-IoX!lP^?)Fn+@GVe) zH}9@@dTKSOd`C3c`94#d$}$9su98WoY+$1E!@5M_{r;T+OzQ$98HQrc`K`O%2Ph|0 zZ>!x}f)@l+yth_e`RlKWZ{i`{W#X?+#Y&cH(7kc7pQprC=v&9!UU#89$sVXQmtQpm zMPylY%f5YE#9+V5@*V*R7C;+WM--Z+tNqYv)>xE#z(k3~6bZJO>d>p|-UV-CY!0G4 zfAbr~YSc!G8=PazfsUz=!lFP4&nO?Jnc8sWoy&?b-`lDxp(MkBUaK0p?_PHrjfaERI~3Lx z1+GLmDa3=IbYul(nt45cuN2yZ@pk3=)Cjlq%od}5>Vn`{G3Lb`d^xcU56p&gjo&5< z95d&F2XfmPgm(`y6+MNUi=57p?jLI+vEwq z-W|^@PN00>!SJ!;q*(mrNe@J6S8(}p@h?}HGu1f+I_I@! zwv9D&3}A60yq?GOuBif;;(2~z0+%4;9l^s&<#7^X!MH|dD;rzh>-iHqZ<<>z4Hr?wTR)`HGvzij~y zLu}FofMF18erL|pLM^)-d0wqQOC!FX)UD4=<%L1oN@XX+PxLcB=;;TGv;88arPkb) z1X3q)?c4hW23ud6V)s|!h4nD2qXm4(^q!g7SbXS?>PEE5&TB$KJW1;Cl$`ZKXqX zHGpAGSI3r@r(LeI*;|&V0CwgR;}^{dKdEq-W;^TJs>U((pwdU5bpk{wqqvqFGbryC zzn-Bd#pnIOq}i9O%l@=?OtFv}ghMUSk0G4wZ}swsn0no$ZptX!mI&lu)IZ14QCwzF ztft>)0#;Zir*V%jy*_h9m#Q~163euKwhrrZUN0Xb(rt?QkMEZZy9b8HSMF(#pfB|V zZ8+ifx}oTFZepJ`$%P_MRabk{!&cf|_?Mc4_;oxU1ZwIOv~sAjwT^W=bDkZV8f^a! zKZfodE#WGCW#JMmA|vuky^>r#o;}DRNGym^VD>4HQ+$y)-1AJ~M3#q$faJeuiX=X)Z!~49E>|Jiko) zE4whQeaKo2A#FiVx_8dO1IMRHnwI}s5AK~5kWTB}bXJ}2Kp60aN*GG}({^WGiJp`VYN@(mIa(Wx+jh*Vff& zK^G~%BdX5k_m+r(L~E|Z^%Tm}_&KLGT#78f@~Yh{jjyE$`ex{TNK#l z@8zeJ;K33euncjX)IE~_SUpNauKYAQF)gXOrBY#l>fy7^@ATQm6G!mNqXhT;AOdrM zZHDW8HJ$~d%$UiqK0?{!HJ=x9o6E+3ic?}T_W$XIIlc|EfY8k0wVe+oz+xVT=&oL!LAkn|plyWcRY|uzGyZAaxqOivA+Y3E^Nx!r27<$6j$EWiR=AX4b5m(NJ?7;Om}!Fg*sC(=zR@KB3jJPLXpB_TOmyqh|yGrQBf-ED5kLS;#jX)t}yPm z!uQ|{x7GwH+Ju}Kkco_3!>rSO>*QJ1uBFMv(r9Bk?{RPsfh*AYk5wme3jH-AH2h?Y z%J#wna=tV)f0o63S;u6A=kqrb*=Ed*Lkwol>|P=clcS+Jx!Zq;P+mqNDZU{ z(B}oza}oXEn#XKSZLS@~l!P2Tk^E5)7)2}Xe?Ylrp`k!G1UnRcVA}*$Qg!4fcE_lY zkY9@LZfadrI#gg}%JNcQg@I+cBzrtaf>aaVDS4+F51*=Pn=(+31@%P6@-ZFOJHNH9 z7;>HB61MdL!~G?E`*1S**1-tp+ch4ySY}ID5fi(7JyoDVu}6lgwfnD?-5clduAQ2z ziLK`lIJ<@T$na-hQ!IQ_=EZ^kAzh(oX>(oPpW)yO6Rd`$X6ux*Y9I=@1cnranualr z5l_6y_qCcl(oiiRvBH`ETE}cR2I3+HkS(<~RsG47RPHw%B{o*#Aa7!e7*S*cw!2F`ywM2PY!&j`Q{{uQ{Wgc~4WW4 zD_d#Yt%jShrLp&^o@18zjQH7UX?Zl5WL6fY#VdyQZ}K&9gVm68#V6$o1;6 z4ryRz={LP6^F#eB35-9;pWcUqg&r@B=FjDf74mL1@RD}+kDwHti^U}-@f<32YJbgw z0v=L~fwEWmJge=z4OJCA=Z(fJiaA9i-2`g^A=S2IC^#Oaa9R!scouma{OGx7-21Jm86s; zMI_@M$AQ8q{6N4^V5^-ps=h5klZsZC2q48x8Zz0E!V_~mWU}f{d|i&Kp!V@arn?g z>o@D*0}Rb_qj?t_cFp*gU|sEf11lq(XBQE+Ds>Ea)Me(8?Niwcg2l$f?aZ^WMBF-h zle8ttjt$d9syo1GZOx7*`ZPP1^XaB=@)o0H+=gDd;7&621OxB2g{s_wxSJjNl+A+2 zNFZY?427PC()r@Ook=s(3M9NuV8m9Vy|WyFU}Ui|zHcFQ?~T}x_1IT0&~F0XY-vLF z^Gcwc^X~ozSsSOJ$}#k6dZ~?%ph~``Oyw+hP5T(z6-_Zps62{%5GS)wQKYTJ(K89HD`en5!HS zs!bqsZLsmq%M~87`;|uL#a=PZaqm2{0-)-+ zX{e;5^&2{s^TRh*T3g1O``MHgRg$>zCUz&MAuC4Mx6sgf9x=-lMAnS4DWT68LpG-a zRkSb}PWHCz@+?86w1SQCyq?>}^Y22R`u_t;^zZnblJF(cv#_O)F~ZUFVvFdMFGQaR zgigMn717MPr(2Vt|NK%mv!@YePy1>KYbnl;X8)XoWP?vu;N5U=;E*#w%HGSpm#G0s zL=7|^YctS;f%&PVr^&pQUTN#i4T*7IR^IEsmQE0z(Nn6Wqp&ybpB$sZP}m+{2^$zy zW3?yJzZ$jEUL}l^$;njm4JwYm z#dY)B@z@LEpHT~83`iWDCd<*bxZojD3`O!<3ZsYuzn6!-(QSC{C71MAB8Nwxpb6RR z8OFytk%NBG%2MEV_8OiNqx{a47$I*_E$4#2mwJsE@*FTDu@_etz^>`-9~YLwJ`ftC zWqer;8a#O~`XOtY|DB3#XwXp)}ndBNW%R!NUFU(7=?wiOZb(X$f-^PmoGR zz7k;k+_iJXk?20*@a69YztY7BZj7z(Y-`DK8tb6Cw<6tc^ApLigp9|PSa+`Sw&c6T za7Dt3J?s5&CsX z^jX40$I&{0jDs8R!Nta!l##&`QP|)lW}X9Wd4`?=-3Gm!`LZ(!PzedjT!&NGMmr_n zDukSGuh0Qq>k^jcbpsbHhq|2H0E>Gcvja=hHi)^V26o*~fm1I35#>z2{XOkbdDroC z*bxtOLq|d7l8x@Oeb$;xQ?5mLM_WSVIT<8g$kWd8&BP~ma(q)hrvl-fn#XyFP8X6x zK+m6Pj55np;bJIP-Cp`vLTa6jWwGp=w4hgj)#;OeUul4=IGR+BI$Kby=CnmxB8WH@ zTVp!tg=c=DMM|LqNJ&O{EjgHAnlopbbG)vIm9yzpRl|8l@eRp@si|s!CsSX~*EE?l zOI=SVT_@e+(UjeIxy5a2!T=7jHaGpN*OP!lGFvh+fQe6%(_%pUa%}uD3-I}P=9V&9 zr@=7)OCA&nHB>V72Zf#(B+?HQvRd~ewP)~6CxO-3I_&P)$Z$T7p4P~7)zhV$!$Y#L z31zzoR+?+}Dc>-%yXhl#-Hkek5yifQyh22ucoH`>SbPoV=eP}47?eI(>|$A`Y1(%_ zS><3TZ=Cn|i?{g0+NP<=IO1Y($ii(@O_npTaXWz{c21JbVVHu_2uGN%ALKH=9z6&y zuqxPW#*V?^cPSAWDiV~y`w+g{(ULHClbmSj9~Us|ai5`$8|E){{?v^$V(+>8z}cw< zm0!S;CE{hS``B#0LgXbj$M+A8@(Ss*0`m=t{g#br@A`zqRro`iJV91*JKMya zt>PM6n8Hqu>I0Ad9f;}n;v(bt2GRw7wo}Hk_0jp3SfSMKxxb?Zb#V&fo#xCFEiDH| zsI%7?)(R6g9@D*E%H_%vuJG~Y_6a|v;3c_4E?4+XVhG@lcH$=cP}#C9m6#$lKf~7S zZ)U#QEHMyf@Oz`LFSW4dXW4hZ=iahX~3*M^wnR^2*xdI z;r?v+_SrA;6)YwgG1_j53|DJ8?o_KAT@OR?Q?QGDj8aAn;IhUgX8ivEfkA%0o=3S@ z;*xBjF}!hzryW7${D&QTn&U4#HF*>;%WMtO%EdxNhkCI75~Ga!13($k&$wZ@G=;MK zdew5JeQQTY)8A0MF@Sa=nH=XBQOTeWV2nv|Y|{sL4`^LyP9fwbHD<@LXZ?vn4qPn-4 zOii@D#tY9Qs03tSjDgm=UkO6C_Vdjv#EW+u+o5()RfZTGmryw!Xals=BoJ9f{#&a> zD?>9BahB>ckTJ(@yjDwUBm4BXH&m&80jdx}zBXnw3XwC@W5;qKxI4n(aEp|^r zeR-e`mGv>}Q5{eKMt@r8u0_qfs~qsj6o&*WsZ)c{^HQ~rpqjdAa$89q^GLCXU*$WS z7##7=MF4F70BIZ(x{vK1`KY}$pDP=ky)N3fo!!kL`{m( zhS~-@k^L*C@ZOUmY0+v(%#+JJmgGo2U5cvRI_D?Xw@SzHt-OO!wl9Wr3Cj)!Hy+2m zcW;HindkF0jJ5Q!(4;DKI3@DNHbmzGuOObs`5&RJ8(D55)gz3g%oA3VJ9mH%-f^}O z&OHeK0CWy&o||ZA@WdtVU6Jkf?85QJFb*nhbHes8`0maK+%1*E!tWEv?9aKrW1dew zxuY1b%P%2i@8geVOBAv;lWOIp8?5e_ZZ0}G&NCqE{{ZU;wOg%4TE^i_(PY|M#uv;_ z?*qnVJ-xo6YD*svY0!LAeL6_dtRs=OTd`&zb8rNoL+em@XHC-^TGnH@Fv$?JpUE=D z3LyuE9eMyxYl!qo=hQUyxRt)sY^pUmSx@fp5m)6uEKR{ZI*)GkRWz2kjB72$MHj3IcA!I7bfNtQ(1~Z;8eYW+haq2ev;<&hI8E!5v zZitk}y8$s6A&cWDpd&t+>riU4SzUNzLDQln&uS)TT;l8!dAda=Usj;=zZMC#W&B{!sS23;?-)Bg{@;U z8wb<6I{n(a_xo9q-m{ON2uw}cH3)q z)CRl@%OAzg%*(rOkOgU98=sr{Y4(S^cZ z!UxPs^b#-L3_9YtaJW!=V>C1eeembR{{Um1bQ$ntxRjXPjy4iHek2dhwe<}%ZlS2y z5HWQVG7t8E$UmR+t`ou<1^%_FES7_6!z^tq+ki0wcpzsa{HLd-dS1H8be1~JlN05+ zT#!cW5(p!=PV@l>!~H})l-7;Jma=Cas>h$#Kb3T0jgGK~_+2(WWis>m*x5j;*rCsV0dAXUW&$DM$AUiM($4gau3{*MndHE+^5r~ zYlGCSzR9Xein}9|_sGB94b*-OxK~CVeHqP&h1W3tly?v{#~IxUA=yS zx$Eo6@9yNeoy*AQB-Pz>SCdw}^9~jy8)HGhRjjJyOGCMfMHE#CD58@z0JM~pQUPfx zYg@xQ8|&K4cB3K{`Q~xiNA*1@V@GN5>p&1f+Rf5ooE3vV!oAz^>Hh%ht({)_NBLkG zo_6x$Q{VhOPoM<$t!tu|@JDG1gpE`TFYu`71#x$J>~>m|&ZQ)~j1*MPRv%pYwmA05 z^*HBl?A?A(FvB>UHV1we zCqIRGpNKCmbv7?^Jd)#(8Mt4d=qrqsa7d)s&t1{{RpFf_W_Q(+N!(x*f>X4546Q+J zR<9)-W1f|?niS-+t<9{Gc|v4JWHK2Ca5jVU4&dV-Nw%G1AxI~aZN0DyKkqI0mGV@4 z(B}#X_Z>0Tm>R=g)Fr`t$-m$&xAYZ9Q}VT|a~1w4L(sX&UvPUGc$EdbMi}E!F*CyD zPy$9nA>97}bbHjX-)^`mWb2mA)w-!t!M}+xs&abZamFYE&2_H_-B~+JVy|;*IT#sr z`j6{e$rw1VbB^{|BL4tJ5~(21W(S%{$NI>@!h(9|A57OjuW0&3`aAhZlS+c>-gFBY zWIb1z4^G3AKptAGRQ0akz&3J8E$!iDRgNGAdXi3f9r{&o_(0_|v5QfLXqMbu0*G0d zcTl4^Bfd`+*J<%4kz^vbNjBRsxK~A#d6w_Pe|LUwk@ro6AkIuG^^u<^!S?BxPmd0BC}5BPMiLz?JXUB?~JjfIrnRv1Qi!zU_!cR9v* z7{KmoFk&$5kz=`qCTlB{0az4%QGr;`e*Bz{DIh$$!Y{Pm#}7X-#d>33Dkjl@0KguV zi+!Y8Y4$eP_V&I^%OsZ97*`u)QV7oOnFpW)r##kM-ZAe?4w3aCpS@8XNN25c(Y?jY zo>VbOE8w=&QceKLJbO~w!FhZqHqkty2+XqW=V}h5`%nh?)Q7D!>O%@`7+U@6i5LG+I5}{u{X05?GD269`>0cY<-xOk?I9=mON5lJm`H-Cu80 z%~-h5-Y*GTYvScyCY_*YwqT(d!Owi-`BH0MA6qR4M%3-*l5e+0=2a zVY`5EcCz{&xgN%%d+6FLpE5{yyjHB9W5!N1{?oY}9@*e>0IkgvMzC#O+B>+7%tek8 zb}qTvalzo%JK-%d(XO?)rzrbuCJORK2t&urJ;ywXjB0+?{mnAzCqX69u@0|iXQ&wx zc!Y53CHJl|umS=3V(5L3;5yY7*P|A3x0wF`@#icj3nKv|F~a1y1LfzYJ&jz_p|u_Z z*W*zjk<2@{5zZfsU{!eKvHt*tdlsI-P0#j;lgel1zCt%+u0O)Kj`BP0S{Q=dwT#|O z{zs83LW{X$P#*<@_XD9m{*;ZX>NXdav9*lJH193MNMym2&Kp#XfqnXQ^c8PZxq|mZ z(5)hce9K7W46L~emL9&iqgd39{;_3y6iTma1S%XR31t~L`V)`Bm=vR3V&hu>0EBeg ztaoh^+BRo)+N0+uJILe?ojB+!?VHJ|YIm0q+7uS^NqD=`G~9teL{sIlEPCT4emu~1 zOPBFnn(9O&kjz-$P7 zqU>3}(Qh={G@dKi1V$4bz*}Vp=Wmz{46x`jNI!*U_=95VH!#|2*EhC=sb+F;%n%Nq z+=0gx*R9+)x@O50vhm>Y#&OoUNp#(tn{QLN)#9ken_Rmy91bRRqSW_9)_0yAF-DSj za!D8{4Jp5HN%i1k^sbiP<~!SDvx@2NqCfKv);6~P0BGg|Dtip#v0&M5s)NDxfwE~7wd5ejG{^p0pd)5|z{t<0D}q{OPx9|Cbn-UGcN-Mv_`t~iwL?kQG~GYIkzH7Yw!TSXj^Pw!%Ie(-^dl$n zBDyV7`o?y&)S3yI8a0wKye~a6eSJn~0;hnl@<3H|-=Ofoz4R#GSOL%Q9t{7#Z<96NOcJWXE4kA=t%CZpX72s3hALMXma9Vr!c^2)fhDV z*ICFqib@3n9ZD&*v;eeFD4+t2(M2E%T1qoi0?|bjfKX{i(vVX7=71KDr4&#CbfT7! z^riyo7EtLHKigW01KQRj{B7&>u739NYwMXVCvws`$tJC9dc>N&>5%-eAlnpUqOE1u z8b^C#(MD*^Q6d^jD5L>J6i@+06|>=u5w-1VD{q)UzdX!4DDCwJlt|Tq+vk<-C{^KD=}Kljs5Ttt-i4v$gVL#BoO$&NvFb zmCB7q&dTatQ&HTdl%=h*jK0Iwi0nwK5z#|pR-uyw=?52R<+wCg+zo9k!^?Ifzd%+)#kTcKOy zcBcz}*F3-ef(>{Tv{TKI6i*>lF56U`DC$qOYTN1e(iZ~SKx2T;cMZQF{vn>D=|CR6 zCyq2>%yLaOf4Lq%^ej|Q<9k8}lWQw~zQKQ%Ys}!aol6In-AckH3+E{;Jpcsr?OOJB zZpsLgcKE!=c1Gk%M^y(MM|uG48^$s}`kQFe{r1oJR8n}R`NkB+p5cGSx$zuJ8tr%e zA5`@0=fNi z%G<;ds%izD*E=QM^Dp`Itp*X#AXp((TZsI~G8kdk??y{79ga^m7`%>VX0~v$x_zSD z28v()dc~o6Q=VBf>btJiLdJd$Hl+Ye?plOe) z>Tz3)1Q}@=n*me|oSOA}%{*#We`k@FSngsZENbn@{vH?a57MY<_VDSE6wsy-K@eM}iB{94V#gr>6j2=MBoPJf5vPTZ@B^5K*9q0q9 zeMD3ZsSjG_n(5COr@%}j?@9Y{ipubHMK#HylW=oPrtU( zt#v74BGTxviamUPS;|lT$qN`qow5 z=a>&Qr>^J&Mb&I=B)wUnxrRBs3T z0W034mXODgg$j~#tZ)b&RFB50L9RrabTGZi2#{RI@-zMBPM*_xanIr7FuL} z5Y}L|j@?|Oat1;-e6BOk&>TGh9^$cvwxws#yJJ8|tz((Lt%`|IE&j?wL-Yvp9LR1VSbyG8~FKi3AY z&#caTSap~q=|1I5<0?4(GAlYSx4|{c@JwU4xs8$J1N+@b^yiuYewk&cPoV1ew-;O5 z-VqFGEQUa$R>QEvaC-LVHD)ay=I2d<;?C|E?|#s-GbAdps6mA*h25U#pgF2W%73$% zmf_-3!JBEoY;@1(TUssHhTY*|#&WgQg`*m>YDWe?5jrqVp5|t*Ez)ZkEp;oWSnamm zpq2BznD60^2651F#TNG31?P)Qwh@-HLIiQY+}^4Q}6=6-5Pt9I97Nu=_lg3nNt6&qA7z32N9r$A3QBe|*q zHUu_CF`N#ym8rC{BQY!+c1JwdA>3fxBPGnJ(HLD=gAvYCo}BaB9Mdf=<-XLSwuc+! zb%{3|0?cqfoi=-!B)ghv$$4FiJ07f0B+(a{1-jfLG;*O6BaSe@lwCQ?>oppC4(a0GjP&TS#pOkj_PVWBzbk`+eIMIA>wSVK*_W*if#zFiK zAB}f9ly4=FAwx|UAa^dD4vzm{p1+IEUU;LTo68vJ!^}+j76wS5CIIhnk;m~azOeVkHA+>9$#eT z#K-Ln2An!mDM7B2kaajy0jFk|htW;VC^P`HQf8Ml08-IK03|PaN>M-#rqZ8kN?<6( zEi`qfb)*8)QqoWY8beN`BQyca(M2V4oujEKD4+$RiYb6mNkt$4rJESedkXen3g~I! z%?ugG`b^k)eL?KMhxpeS@cTg*`ia!;6E^mzC=W)Hx8>LCitMEE1-cfIZu{;lgl2o02LLbdk8Jh&3h_S?>Z4fk1@54T z5oM5+P%-k30Y3NxKK0&sgIYRwg>E5fhxV?d%JW7K%^YkF0sjDij-TzcwOgV^W0VDd3bMN_1WYhE)Wx2E(zP%yKMJ8S-N$kV;+thaU zBDm`-+nqk-Tw2IulmY{QGBPp8q4%z~!sZ)$OHDV!if(ng#RlT_fgVmrD4FSw?fyUp zcZhWR*;MK}t<{_tZ-ACcaUfpbB8(^l)bsVOa3~)2=-PIlV{Wqx8*6P=?MeGytEiv! zNJ;7!KYaW0d-SZiZ9FYyE6F*%)#fZ%`3kns9QGU&$F>CkSJQPFbsI%ZvdwjE7ndYT z@_(e;4yp2B0HdByIjfR8p_9y+uA?xlj%6%G#j)t*G~*>Y1p`0Roz=dlPA|CeK@IY#+dpmTMGt#Nhd7#49&Dhv|&aMCpkSR15M_$QeNQOKb8B5wwMb` z6MfeNZ_I=Eh{+s#(i_`IT){QPvt<36e<&ypy;4*w4+=Y;Se$WHq?*qE07qpD zF=FA2a`MZA9O}7D=b#<2noFy@i#xZ{uSC}OQf!dqF%evU3x+a~Lj&_Sl68;V3$G-5dA0r^fxuk))qCYOI>;&^mnKlXg-G>R?~5W{%{b-*MZgC~G#mv?&2 z<>1t0xCc@NS!M$OFvbo5#~(^;>8Z_fZ&vlv_^@+uyf%B0j+6nRqG-CE_OC9Dd8kcp z(ZMt^2(DCxZly}+J5CO9?N#&*Dt&eJD@)s(g_;W{cpJ|JenK#<*X8H%s2bYu?F(@g z)X_{N-qHehA5om=oY2~HODt|()T3?~?tSP2#*w00Y8no-W|9}bw?&>A!??x?+@u_5 zIP|A!x^|m+;N3Rt=c9p!m$?EK4SRUt?N1Ot$I zF5G=;vgyhhm@W$p6YEjQr>c{L89b5)S^%5GcUpFqCG85jDcTF144h}_O}MmM zbTdkJpui-G0K4J+Wpv$3PXNn@QHxR3$l8iDA2;zVy(642aP8v*Z(M&E)!pXES17T7yeuAp_ zvdUyzXPR?6ki$Dd;Bo#nYe#}bxVV-(d4of_Kp-h@oiciJS2YNxzP4XB-dM`y4={u2 z(*)ObJ#f;$fAT!)iT!pRt^Q(YI%uCpw=Z{gtA++NkOk^{VwX_7p7#FU?aHk1s-iNC z0k@|WX2J{Gdv%5zc-}G2=ai`&4%LYNtNJe;FrmB(e?(%;a5MiK~1M1 zk&J*d^{x}rId!r5a8MQW#XsyrpO(5<9-h`f@O7~du`ez>KBMULGVBC*`Xj^-z? z)_^(0vEBSMmk^s~h;)Cv1$1!fR`%BuOp)!DY;QY{IN)(uI_Y6_cBvazYUk;n>s#T! zh>m<$@R>BSJ<54RmfS}kwN~=nS=mWDpts6#y4=&_MDV1^vBx5-GQ@lz5 z(?Wu9UV*1yCFYZFaS0oziDgC4S5cC`mPK+Jg_8NV`g~2g z-s%rF-}2nQ)Kwo1>NB4U-@ySQ=T32V2pzyVVff&Wr2usHtZckObkvhPKQ*~oqHK1?^!zro ziS;$nMYC4Zm1O|IsN4Cgj*8`eU_CMjZ?5XNyE~I5%$hvoG~j5G87-!g-fa1iF-G^t zf5)u@73e2*bIfYa!@;N2VAH{-0@9AO+|z{u3QNo4k*nf0CQB7QC!Dplu<<>3Mit07Lt~dfB}-gC?u1CiuP{`XoJI= z2KkVEu53v+u6rlp{K>8_;r{@KzTc>wPT@0cY~LnF4=4WsTd(C_fq$sRP`c&OPSgYb zT0U?9{CNE9Dw61H1=PjyUa57b_-6X@)c*jaF3?{%^7ou%d-{50dRLC=)6XU3aYC&u zvbGfzWU)2d{7%(&EtZIm7^3~6;EsjQ3;zJKAE6b%Y5Jwsk$#t!QV8xN3>Auo#z6;z z$3t0O=7)2tL8a&sq*wX9)mcGnRBT5cdIlqseTV-5Ua>XJ68>uus_If$>Fcu=_M~ig zUfWSYJ^Hskrjt*e-rCX|l+L5CUdT*JesK;?H#hg3o`C&NY}>uEwbO4>O=3wkopNFc zvqvnb-Gq!XN&f(3ew<_jg(^Y%*G1vm`CeGG=DB#z?Aku}J0w5C0CiEjp+5BQ5a>6y zL2oaX%F=t68!RC>krx~tgP`NN%~y-Uejt_yopgJ_9Ba6jh^9xja8Kbt8tNftktK@K z7M4w_yCGk*+%NF_?#<;M=YW4hW;Z`(jHX~FZNp*5*{(+bksK!q*!ff*Ya%UT{_5fw z8roK#Wfs%!m7_^{#AK>%!C%KUabwk=Uu_>yjsZ2!+dGL~R+OT`;YDQ)JF<7Ksh?hnd^p-~OO_2HS0!;X0Qjw+ls z2s}V^{Z9VDE%*JFXNDv~0+KiGWWYJ(oaEH{bkMhmE*DnT=90!nB1u)6AjLruwm}(T zg~tY%7Gk@adq{*2Br7!g;sHtWE|;p1kq%gNQP6WqeW|UYeXt4PibnHcQe>L8;u0dUu}6b#P3|U5tcqM_fN0eza&hhMS{LeFfYX6TyFO&?G|* z^Kd~YjAI9pKn`tU)vYdG8(CQrWS%D$$g3N--bT*vlY@bpl{Jf~?3Ylq)9yu`wc%ux zNB~X1Z$q(H8@)y_I29h9tiz^3eW$LPBuzphn?n;C5y)el@(wZ5ooB4gZKq!82F50e z^;L_A!i6e99R>+L{*(YE=A{mo2D25M4`7!PLV@OzK^S4oWlLjj52rZf)NNy{E`z7) zR>^HOyZMlQ>fK&MB%FuJ^~XcldUt#m`kHBz=<6Ip#!@6Lj5gp$93Gqw)ad2Zzq21q zk=N{AXq6R5JF$b4KoaWucB^ZoX_t`SNg`faeVNA54#iW1sshBEC>R6q z0)RRj2h;RViPB1vzmp})rsZG}cjx8CIRTD3_Ncrk_8l|F7q*34?6(Uopx)USCmoC88CeoeA zxEE_GC|(Nf!RM!1t$(d0--GncB~irIlE}{O(1LO?`I<>|;}m<@TW?R5OC0_bucSIg z&Qm2uc8;ciHMHs5Sy&s)nga!tP%(|MyJ27td5 z20M`&$ftQYIL|_HSrA&vvrQto4B)F^;~@11*w(F`z2ii=k_Ctofi@l31dhA0+tRJ3 zmd4|L_SgYZfDC6nbBxl)Ys)U^d48PmzR&vnj8w5;rEqY21I0+T1r!~l>0JrY#LW}4 zIP%UwS0Ep+txs{ITiifnx``ex7=}CyR|7pGnrm+@3%Ce?WRh?IsiU)5BUu+|Mi~qa zK^4;-H6A_{T?puT1XNZj1>`LDu98NKV>>zmJ5vLiw1F-Y&5RO>d- zG8Qv~j(w}FDy=-x2&D!h0WBF-Y>eo`9fZzHd`_dgSgKp@fohnBec#eY!0wfj}bG$a};3VIX{md(2vf$8&a)j9tH;9Ko!b(p##oYK>2z6 zM+f<@p{ygSzZsPQ#4j zvG%TCLA(1s+-)I62jw{FgZ@Q#I(5VsHxF$biy@H-`AOp*BH&GN)#|*Tjn?AYLlM;f*lkDZGj%$(K^pAzJs={#Qk(0@Ag?+w4&^b5@@LN`DR(1Xt(#6JVi;%m>) zeUDw1Jp7j<%u+gT=-B*o_+q^R#^9x;ma7_OTV@bWG544FN&1YBz)%K8xxRfa&rnV# zwwKCsJ9kt0*LP#93#)56P=YTL%!udyS|Hq0@f(NXT+XL!9Ai?jUSgghJPqnT@c#fl z)t#bVPd|jOW49;mdSt78Z}@V<@xuxL=puI0JW|$?kQQ_eZh8X9GJ`&i8 z73s*3MSpFP4(;Fp(O7`Mj@UbXoYcN4K6Z~|uGuc*Y*u7GtB)}U(`k`Drn(;@-%++` zcX^gq@flzs{{Ry&%)f{`zNL?9xXYE?nJ#40Jls?BOn79rneFEP09g|hV~l49ujAM8 zr3SqOd5vpgKxx$-NIFvx3R+5909smYD4=0WNk(W8xT1dr^S-YQ)YBZe)qY8L~{`IjpibO#;Jr~d$}JSo&#@rS zhY_%dk<)UhQ;)sn^YV$KKt-1MyxCa~Ddis8y@rpDf)I2vlR;zWW z&kc-B3o6Wic;7`F*BJQ(*K;n|oi$$*w3jo^H^*nY_mU39kMJj;{Do21bldCrn^wBC zvC^&8F$@7jMaFt6ftEb-Pv|Lu&*^tDTU~>33d;kjNdq8as(O{r0f%EtZ|jr-0rNjV>_aba;=`X;1&uk87*C5(f*(m^AB?f(F~_tjK4LQhHn z-nEWbjaD7=O|+XcA(@iqAL4|T*fyx=fz3s3A_x}U?UF!E#hbFgr1SG5#(*!2xh4@cjEsqJ-fh0c<|)_jAqgHyBcR9_=A^lqrI7?m(528bz_K%e4Z{8S z^CPGo^2B%Iv+p6ke+o&dM$wNt?NV4-nT&F%&zg)}{LR2TdwbPgPe+FDLATa~@yB;0 z?F{fn6;R-C$e;`ib?eW6Rd6&PQnV5^#8(hoyo2pfljV;pWc$cMt2Wcl&`8ZkFNiHO za_Mx)TW#D3uPb?Slk>;MzzP2F7z2UUsA^VPBWN0AwwDUJfZVrHDJ7T#!x6acBzEtb zcC{{xsA-yC*nnH>c)Y8niam>wj1$HSVDd#UA2zdX40BtEBog__Gze`_0LSkk3|#H> z>(5bBwxM}#s==gvihEn7y$ukW;HJSK0HG)sgTctg2TF@w)onaV){{kVAfDO}} zRR=1rxaS=A6&>8Sx4NO#44z%-VBTT_gIgs1fHk}`1*D!B@gA8i%M&CkaEizjgC0pElivcJru~ur9_u=L zWVBPqaDj-=B~;_`{c8y$nkHsK%2*X`ta($^eJSf|?URZC)Ry`Qz8yzm%D#1_@MJ^I zLo0Lt0HIV`o}+Dh;GGuSlxw?LWkz1=r;+#`KPsRsnU6|vvR^mK+EdqAhU@gM9x^g=f*kh$2A4sm|oyBs4U>A!0V7l^{+xs^kFW? zl}r8Vq#smT)UBY7!bG-?IQJD2Y*050=by(FksX6K0B}uiS=vi;1anJuB#R;k^2ks* z8OCv%(6-Sdjh;o`OP$0sWDlV2S>vN>iK9%mPuaM)dou<*5ZF#PWA5gZ+N@K@5#i;| z(d&-2(S3plE~757tkQg|k^?t<4_cn(V6wOKr#oW=0@x?7u&y)FIk|1#0~r?b$-Tn- zi=Dpb+NZj-xrW)Kh7USH(maLF<6S(K%?uABK+=^`!BZJ;VVYR&%+|5WVtC7_ZH_Qk z*!$22FLP?}TQs}|+uzs!0M}S6adMJH9il79{fWjpe~orma9qBa!s0lR=c!a;R1kQ< zBhtADSNaz6^?H5+S#B*It%C}I$+?%ck;IQmzZdglXmYFXcC=nG@l9G~k~?ybD-CPj%< zh}0Dz0$Um5BOP%gaylrhH*mC5Z$W|xB9_gWW%ApA7v*AewQ-#K{HmDzJ?H{Y55Wxa zT0~^o8<4F1ah|5V4#8x&(C;)$pasp2Sp##G2l^k-it{}^md8_;$c?c{``Cx~LFaGb z)9|lEhVjgHTI6GNkdXz_kK!1>C+fU@Q~}Rx`=-+D^%&G7meP4m?Sqvy+*xaBa~_>C zsoQ@Z>phv8h#t!9uNO3Qv=o&h{8dN3SK`wIl0p0T|(df z0@-2z0HIX~>7p&Bc&%;ihT%Hzgbo9RT!s8mw*6zzYOUgpIy-wLySK@O8_kc7kv;=2 z`~|k3U}=0uFq28sb^8EM*Y`qxbHTi?Rg;|*(n8%YW6xtHc&_z*jX z{0q=ii?dA%O6td&f=CqbYCk4hXM1VJC54JV7|9*~03NjPYtT;mo@-Wi96Hm*Fm z5VW+EPy?xHD4+mR(o*Ju1r$+01r$+0g{9}xfN6VBFrze?qZyzDqL34sLTCe+rLNEZ z6|SlO06$ir_tF0V3J1e|Q$O+ZY5xFyAMmbDWOwqji!_znABMV_{{WY(LH_^(KjBUP z0EKC({{YY3r~UMQ!j5={DaLlBuF(88)d2qhkE=uf00KYZS2X_s1g4JC-s=8c(nJT9 z0vw4XCki`fCj%a)lad7ndHJh4b%gq+qYa(7EhNkodI8-29M`LVV0o|zr_eNpe0 zPt5J3v zSmX9wfu5hvvwTZ?zr?)-XmJ1Y%4OVVtkj#B=6%3@`cZZHO1WP(XOdshnU-jEH) zJ?nGA@ZDYCO=%6+*==Nnn(}ZtNLQBUuRIJ6xdO0DLXg7D{FOIeJ%sG+SnW_5AVOLcPHMPcGiUXR_Yy8;a)?_p7<4or(`G~^XZ&s zx2q)3v)Z5qz|Tt5mrQH4R1!Qu5J`ybBo5iFCDS~} z$`HXw#|H-=T1X5fu`CB1dQ+0XGmxMXy>M%yv(ciqfgra{k)Yrcl0OqyEwoE`cf3(5 zJclwekGyGt$`~L?#18A{5x5~gF&?<5&m1-v_ z!Yi4ixcg0)*}_UvFWv_85OjVzXVa|!b5iNI4LlxFMdfqm?#}L9E=m| zKp00$+=OzZi~^u!esvTYHca4d0A8`k@2~`&N!eBO5Z|Db0Jod{KqVJv21$}K~g4zZf@Ff9wsOdwsLWt;<{@+ zB`nd^Wtv^`GnSRh5tGn*0niFm(ygPumhmGWXq6QmvUvlb$3fPBIjMA;Rl2uX79%+) z$={9LIosc-r9SG?-ooAPE}bHUa2eR}GH@%Udka^&f>~jWr;m4(%m(Gi=V|&;cGk95 z%{|<)!wTasD+N$<$0y!^FdIx*;%Q2fs0$n}Pac4BXtlJ4?$$e)P?+6vrI4w|(DPj> zjZ!k~Fsd?7wL@h+wxwki<)itKN-~dvGuxr&fH{j|9dBg&CB$AqbRtObUue$d^#`%2 zEv}aSR)kvJM}#f9SoVX+>0Jlf(&aS>#l6ITXWWIQh@!vC)k*uqwtc>pm1`JEHO;Gpx3f0{fX5OA$5G$uTB&b!b72gS`EXn?Qo=#J4teNB zRGv{Bigx6(aXMs%9?JjMt zyt})@cHxN<7{VN9fI#DpnXVJmDA?(LV@V@3O)zO0!I^<8lh9xe2WoO@utRY)>K}aA z-I*AUI$=q`{3_%Y9!>St&)RMz6Yc%ezEV64e1o=npL!+Jt>nLk^_EXMC1hMk!Ek!z zbA$NM236jhX!l8bBnkG2)T&5&fO}UTuG*u^^W>|JR3A_O0AJ}{?xCmK-y~~1LBw(i z5M+!n=y_~ zpqzlbk%L(C$XrZ=Yg9#%JviXkb~1kIiRa*{B~i@bV)-T6#|MHd zuF!6;FSUznZ#U!&8NN}_bLswbT%@V8?2k?l)N%e5+4v|MQE{m~t;E2(TQ88C%I-O4 zBRRnNbBy(;?OLPECq+uKOzbZbbhx(=+bpp=5i4&1#xcRc>*-ngwY2{LWpiqg8FDZs zBmy(fPio$~GDmGI+r{R}s!B#%Zu5=?duFL0GJ8ZbL*&fJ3r1Kr!;VHr2NmWXspp!l zw=8zx`d2q3C7wuuBOtKHasmEhaQthvxwpLAC)*Mb`_9t|Bo*v==xdh0h{Y&%$Ymgk z0MEII&8E991F!F6#sJ6}KHqoK9jmMGo$OPpB!n1mBl4d;iRga@#bH`7g+izdR~SVc zGLw~4>Prw0v}UX5%xte`u*m=)G9pGeGy&f@XslwEKz`AtP7+a$e6qxTgrCm2%?jvg z9x`a0W*KB?Sxb@C}=1WN$2#a+olO!acOzt0ut9}KYuRJ#{m>U;5bmd_#c?XSeHkN=LMk%!H&;dzB6aZ$EG*S4_2Qp0++aHGdpZxc>{{X&^_)>quwbei8 z;@kfEKjB<_$nWK66`C_$r~E5@SN?n3f8R&^DE|P3Z>oRKdvE*b{{V$PWEN)^nk%;d z0EKU<@-A%%{{Vdd0EKV(e?f!6_IKL0rzCA8YkNimu+&}1d`WiN4xOfW zQ$u~1*QaNgI>wz|L+*L}kF`gr>Tqdj?-1Q>q{0G|$a#;-gWnvV_zA3CGU9m-q2mk0 zXT832ZKuPG;d%@Y$bKI5zA3R-bUR%VHj;f+=SgLXFPWY~rzHOXvY>zuzvEbLYK{k2 z)aBK+TZ{Sll4sq4*mMK%7!^usRnjF`l{PLIkK;KfC-fhMTGaI0tupoPF5>d8Bf(_c z2^j#4;QA3%Prq85jL^|_i=7Vb?(EhtJ)&sRNfU98Kg6Tg{A;Q3?YqGfHk&*ve$ff9 zbvXv)vW>rmf4w30`g)PWovMmM62y>K9S0aW{Qm$d>7mr1hr?DHZLUF(wD*UhcicjQ z>=X_?=wNhFT#IISd_Nl-nGf$Z8-?F-IqpF@+CH50tPNJjRi0)ymoiN#>%MKx{Oey& zzjU^EqB~jmc3uuWGuos3JdF8t3JLi@Z2mu5U~-FRbdEMVLo%P0!NF7ND)gFr-XjU6 zk)A`BkhWQm)cV%a+nMzSg<_QLP{sEAzfAhoqhl;>8bxl>mT(*904nwWX5U_?Y(&Oa~(?0cL9VLWI=Yi%C^KI#$ zY5?YjnG}Ib#H6Xpk~&oX0AcyFwzutciJ~(3vWa&&WWsIOy_b%^T9GtRFAc|;DJDIh zUVcu!aqB=F^4N^yV5gfjiZdhaR8f*izyi8Cv{)`$KQ=A?TZd3d7-8O~p3d4UcKc73 zB0nMXFcjyE9MA_j4YErTx?z!kA%{XJv{>$gv{@>SP?Dq!j=1%%tL$*Y0cKE$i_Ulo z1v5_;+J&NA+Bca5nJT~#4>+I>YB&s+Zx`9+0YE-u3@c}_^{L**3g*lxBJNfw1;ny-t5B0OzOE zW%5XeV3gwp!6UD=52P6pmywawcCLp|u)Szg>?ReH%w5K1By7(($A3z+`gv#>5zKDN zKyU#neR-e`M%P0y!igi9Sao&)6Zi_A*FugB_%7w#xh~}6J%^yKqSsD%ZKIA^jMA!r z(z1YD_aq7}bf~R7;*B$zWHKKzLf9v==b-i!0nQB$0di*Y*Un@LTe$*~drLbvi|rD} z8%omgFWk|8dadb`)`oHX)*amPWyA5Q?8Arses(c6lO^Tae0ge=RE+=QfLF8 zy0(hW)$Sxo3Mk3gg^%C>$*E4B3_*etF`Q!=t=p%H%F;`j^07pv-GY|D9q>7&hs(IO zN#oAR6f)&XoF1GWMF4WDW`jz5rm&eLIQ_xFVa_{bd)0L_-oYoIXwYrO&|Eg|zMU(7 z&o`QE@-?*f`%4Y802u8(a0%e!ip{i`O(Uk>Hs|#p<6YF1@X|+@TG7VA+jahCYn%T7 zHdMAtaT>QCVQlgP9-ij9RnyG!THGwsN3?Ee*Bf6WzD9p4=dW*IvApu$Sfk#G_;$ul zNIieu71P+GYFah3!*wJC` zB5tZYKzM0l%gbAQp6#Ak<%H&o`95@Qt|m+4$n4|Fv9$Nq&K1jS-F!QPwe1k z>?XNtiE4{q`-}IuLuZV5Pm`paNqKAk0Un6%(agN6tKZhrw8Z;>I1Z3#QWu0j2Etu= z1~Fm7UZ)U~h}?SUVU`v5{uF#{MIGHIZ`JXg@5t5O+`{cPbJF2wLGXQlhB5Bm8A0Ls z_A51s_Z;}gvwqw2pa+{;TG_NS`gG<#1qUVV2L2QKGJF=bbJv~I;+*)eOVPinN_@zJ znVq-8kt9o&LpzpXCuPq_I#bC9#$mCN+5dj7!E78A_UF?<2df z(o{-KS1X^ltJxO+WBWL_sq7lvZ~GHL0E|G&#u@xqOSs0~sc0tC+^`5*y0`ZwYqMwJ z;_8QyPs6PR_ORx7;y~f3@NsmFuPyE%2FVHBxA*cdYlJt}F<@9{fU$4{nrO{2vM)e_pdUsSwgMInmWN2zp{@#zvfKZWZ zqH{kwf6~!g8WWZ)^IPzH+Q+BAL`Rgc!HCCB8lCP`u?-^>j;n3_BrUuWN!4|h^;WNK z$y9meF4A`e=$Br(&2ybh?j@sIVw;6@Nq?~eVg6--v5GXlX&=ak!cNZe_;57r{0I0l z-7(W#JmFZ;#zNXpz`E$k*Y`FeqH3TWRedo3pfhUT=9m30w{IUHY4^juhfLa@RC(YN z){&lI;y0Z@jO`!!PQP;vt_>jjq=A}}h&A8R2@uPB1Ph_?kgWPSVs&vh)u5hI1154u zcU|JnJw8&n!UqM$aBbO}te=~;FaCsiHG?PJ7Cy{sZ7PST zO==@V;WsQu?=|;Ks8Mja7)e&kbSfv5bEWh#nEiuL`wHqmKz!@*9|N4(F=v4R#)~9^ zDhzR^#@H)j4?g?;`QJdOGSmghe+}3;L`L_S|A^)DISA5r%5bN+Q1M5;;SV1+DZjIV2jy>j2G z_iSnxK5#)L5Ru2@KLA9=K^EE7xAU*9N!SbQ&li+PedOz4g3QTbj*!7;==V`zf5Km_ z^|H4&eMlBL{c)sg&j_xT1GDvF-EI@eaDE)D5vYF7R7H-~q7M0G@V5UQar^os7$LO! z$~{iaYeFjGh!hvWz$ZCyhMU#CU{TlO@=*j`vgEI?yvj<-P5vdt;Q`f3tXN$r zTnm~QLEx`JHIBe&iG)dbdI+p1A=?1~4zKVGtGF+J4Wke=jF&WNe&av4>B_KI|0b#MjmGJz%9)l_cFGA*-x4U6 z8{{~}C}V`h`Go(xSKW3lGh#}aE6oC{hg}rvI>|c(u}za)mysgDKGja|?J*Hp%rjeV z9OPf1^i6DxDl>J~&iZJ-(m~D;mh9G>zUt+^Lp@~AGgk1GQelXzof|yU?x6)F9 zS41n2CX6iZ#0w74;OLp%ASfKg8m~&Ht?NB%&TH zPnL}BT_O-89~4KHtRH~2Zuo^J?8)Ih#|Su}B^F3;5YCqa9GlH2 z5$2i$dN@;7V4%xlWV<-vqbXqe8?*baFL|@i_$XTXg zCL;*zn+J&efH<$Q*Puc)Ogt;JMqFUgh3pH`W#Vn9qcz6AURVl3qscm#NTM>3*x+J` zs(ghKC0S~eaJ9hv+7>s?Z#4NeI++it%RZ3Ovfud*IWZ!~T)1UkXT;tYDQ#ZnD3)Dq zE^&aWjNk0l}HHxH-7@@A4`9*Mk zPzyWqxX^v{t;taEdQ;ldnS{s4fdlW(>JESf;Nv@U_1Y_KZ)mfZ#ct~c#7I#CI>Km&CtQ0}eBmy1j3`Fo+!E0D&_WhMP7*7SwH*xEmgO1QG z>uE~ukvs<1n$K9lKw#3DP^v7q*_}s>V%3%*V-AZd{Yl1E2>}`DN^J^D#xND79yW|% z3?l2v9T@4K0u^!NU&BpM$;{%~2?g)@hIl>;=tXo!QMs(GNAQsZv{VT;3CBCJvKXy9 zK+krcVXPMBd+UWSMwa<1`hHydj8c9aaDKD9<@`D|{mA4Gh}JZusEvRmymoqh9IVo^ zP-~^lAr4cBjSDI5q#z3jk5%rL!zRPX;yx35i`bN~h)(B?fsRzt(skkaGy;1+BiBT2 zi{toNy+w=;&@eLVYUf>J(jGJfM0NWHY^PQk2hK>SiBW4~Ro9OuOARodtRb98y5wc5 zP0Kj5sO_-~^TTU2rivs1qAgfx`louY=pL*vgev8>uKXI}$3^=I>$FDIEs zvd}2g@`P6^q=KxObv{Vu?LlCYM9w^6$YuQj5>E%NnR@7TeCr)2U=I-X+ zYt;$%KxXfr-rG8OS#FDtR?|AMj3}Ex2qUJ9tv4)((!@N_2>{Zi0Fg4mX zIG;aKkMxW(Sq^W(QU!R<9V4wi53kv;_P9ZYQzW$3ShPwo{3i&9>PD46)g)Dl_vvWW z^UNRb>>SHR(vOfrjyR`zj#sX$Nv(*(y!^7=(`e0T%wFYC-G}*W^HuZD)6_NyX!9os zZ+8@@x5c)~4L=^iliw`IkI~rzvDVv_il!d~T;rwIM^h((X%qohgm7-{9Oxof1tQQ( z3merdcvaMo+t%5CQ#{Vy&X{uBVkXjQJika8t8S>kIg0b@bG0cB@2%bXrfez1cai>n zn$R}fHIySvNM#EAwMCZ(VF>bfMS^RcRsb21+Qy0kAF4CE)ur^>ue}3nGsb$KzG0cB-Y9;O;nQ+$%S9mbq<$p21Ek#h>1+zc0 zSPyVwPuX*dp-W2&6i4-BqCH2?UJFfZpYRe1^nh!4By|kp#ote&USOINL?x|Ja=a4c zmWn=8EwjLN=3J*Eia`dTFUMN=AJlnYn-(bzK}7w}3P{5%&bdfrovj$tiMVfyR*SW1wQNqCM#6RTspE@8__oWGcW!w{9kTwIim#eF@ z3|_Tq;`h}*uu087LOhM19?Bu!(+G$QupoEk{oQV2im(6;OloZSyE}ZIRQ$9!v!DKs zxT0p8$ZlR&s5I3c_}N~ROje$B>NBa^J@tq162_061nK!Ng7V)BG8RYdBK~IF$1VtjPFzG&qF z%TBTM%cnc*_^c5=bO&9%?+M1<-kAsm8s}%X=k`NYmWbXp&FAkSMbhhEt}j(5{BgyE zk4gXhxFKDz|JpsZkqsJmiB|qbif|Zx#odISvJ3WQGVY`#FcX@TNDr)P72a#_Hdbg| zcw@I0ge(KgHb)K7Wx$w^)BHOh+%zP3zgo$Bm6;pm&Rs2%VtD+g3}+W+y0r$C851@o zxGd+rIRLTm-fxv?V!h(s;9%{G-P`X2h`NN)5m}HUosWN0Bp~NBVK)auB+{kt4LE@g zO(+bm6zx>@H$lgj`)PYQM@FUw#H12c;s|ISrepzf)r$*tTP?Rfn;3V# zJip|5*mZPe0QxG4W=8XqqMUw{Cuh;GU%jiV8JBFFDOaM1k8H|L*Yad12jbQ35Mu;o zaZ6KcQ%gLeIo1GnnC3lnzFVn;*DPUd(O9C~FMecbX;I*!=a5cYOZerwzW0KkTA{Q- zQYsZJP!!kkoTUJL`C2N^K9i&X#+R(xU%4AosAgikULL3BlS3j^IQ>=xDMmw;$K#wH zc*q&njPDmj!@H_V{5MLcf1U()m+`x8ZDrRJj|VlKePLDo4tH1bt~?9WRJ2 z7heXxDOq$%S8n2kU*FqD#gTR-Oqy2u>Z3V&+?us#W0u808$sq^x6^2y)aEhMEeR~w z)7xIxRixL??(Rptmh~^9yCrJ|yl1F+9G|MJ%Cb!5s4LsjRL63dCWcJR_+c~Jzcw#a z({_4UXPOy2yGU7hJR*P1YQVz&meBH(BYXfAD_?bW7LM4><7q{fyHVO9`x`bAhs8d# z%*aIONH;!~G4ZRqljBgq_G1!-*>OU;72lr~Ztl3jR(U2xYwp@HT^u=$bQLe)zq{o; zLig8(i1WGk9}=B29Dc(J@0&dt$HMne=w-H^Zl=^)_#xAW4Inqn6lcM=vQWr8r8THS zFte{>Ctvxs%O`QWX8FvG>p>M=b1C8ZyIT*DViy} z$c=*@gIzRxrWOJESIfds&F1FF`lZ#Yd<6UWPO%O_KdK8oAZ>WvzMRz}jL3`ETMcX6 ziGc4VT7LCBYboq+5ctyI`R`iJP0q~^4n9GR%#N^i->PeSek@P$K^?FG13E_-@(gk>4(PNwuegDFWuLqp#xw*cyS zsj$M*Ym0i%0Wt()cs%=H5*2v2ZM`j__bg6A$8?8+A^71BPW0hhX zOwRb=ccCcCjs;&^uDkUS;HQmm2-(>6%t7%S0@|dd66GVFpEkCrq|lL>O{|3R;hV{epfPYul0G%p+u@lLbX{U z0C8-ZWHDxP=W<9)ZuE=@gD=u{QSTH`U^4ZAB#;8)lk1*X?Zc6)4)B5V>9#!&+k2Nd zYs2ddMRMlj3>T=uh%u3K7a z^fX^$K278TtCG(h^f7%KEQPw|oSEm!9@q93SO#^5{>t?qV4d>7ur27D3fRH0r0J_kTOz$V*`Gv(_hD@pVEfUF!_a^vQXADxpSSby4r5TuaDP z64~gYNCx#l{!f!*1d_eZDZ^d02EnB2#$&T&C_ne!E7emU+-^`A%dG^+8{fX34%VMZ z?MXT0k417H@ff-sCp}F-UaD(K+IeI@`x=>bxo-6rmZgD8@vbIJuW1D5f;!pjo}BO9 z6ratu%n1=;V)y?8)M%P6B#xztUFk2YO9&DdBvDJ6{V0TmMn~LlD?Yd|Ev^HCc<1c2 zur|LX|LO_QRVji-g2nW4Wo*s6-7t`Hw2B$0U`~i@v~V{d0=0si18JgX2s`3nhXZtAHe$UW5{$jnid+ zcgqh5BW63#a@c;!J~r|pq~Gy})UzM&B{kb2nMeLd-}L4&)r~I9m-;E!E&R@pksFVLdfv;8fO@p#}qp~xV@d;4+lFR zf|7%T0=tqD+e`SSUD=HMCONBSkCTF1gjUsm@Q_@k+hOWx6}rvp(GUwQyByQ3;O=5G zWPsb9Ps>6qp$)=IOHRiLVxYg07Y&J~FNKb#{}<>3dCS-?{)B=&eITJj8mYyI^I!kk z{2SY}A!A$I%=;U6wyXh2pms`Pj|CzYPD&krd=M!^#y$u<$^P%d_8iHHhPilg<@P?c zHO`iJC7!RteZscS(r(XIAVJxEluZ#`ds0T1M13e?cBq)PGuOZ`E=sHL2Y~f$aIeRxpQUFFEf7lX*!eumf|I&RYvlws3CH>*|bTZK+kdOg&{eL&O6} zk+_{+jiVL4yBI-i#`H1YC(%cD0j`8DKBxg+m99o~16K&3(QjF?#e@X+*jWAf2I$)f zJ|pwYtmn<{k;&^HU0W8h@8+?|QvL!de_yl|t4{Q~TfWw?UMXcQTnvxW^klcIsXpP* zAJEg~g+d;chfl~DTboxNgtS!=Y0~g4Sm=#Bp*6+Md^}HDTKA%g=HJ*Ii`Vlv$Ayi( z77rSpJdRJ8rY|YsWGttpDWa33c|ARe(u&-@J(Tmg!le*|FsR5@A+Pe1rW{@3KCD~b zXWv(+Vc@f-?LN<4P|ZrIPlDYE1Xl3c?~MpdZ|QJoQxhD65pb!-sl1t1M1z{V9IHVQ zd59KbNCxZIOxD0~(rZ4;B^`FU8~N-f<=a8h-@8au5G1XcfegvAg3Y8lAf}QQka~zl z8tK^BkcsVbl|W?&6V#n)xR16L4X7bV-i=a%fWpZYFm@`6ZQIWT4*ZI_Wh4WaSPMMC zuxocl;l3Mq)Wzdg@!OMH77lW+At)sFCS_CohLNcqJ!30AB2hBxko53Ku*y! zfWcTRj0;Zm4g(Xbx098<*?CuQ?zl&#Qd5Lk8TOJ5#&h0R%w5KpJ5Hsb7&EzaY_aK! zcsd*99F=rM-$zO&29b07EwBqpzv%;)y1ya=${fv^wdSO`e%We@dh=Xt_~v+&fEN71 zGqb#{?u-Mk5T6AcRS=+?*D1|SVsRt+%s4|;b=GI#eySQ8WLNz3TW~;XJDzo+hmM-N zAbS3S%$^ISDKHHX@|qTrOWs#*X_t&XBPcQKOSMpuk_P5I2=!#u8*gsDmmWy~Tc4Dk zWk9k2V2>>K;dEP1PkVPkO4%BnW@g!EBmz2846T~| zp$VT&BYwZ6^cUSpZhc@bz`sV;~88 zg(u`1>P}k(Y74oY7O3*XmSBvKCSANm(=`LoM8fpg5jatFfMhR9WKDrTy+#=a7O_ML zAmxUJO7qW>HIroOvhdak)|tUi=}XGyIvwtDH8pF=^hui<2|+ zZi?SyG4pA2TuHdJQJ0T4Q;Cj&{?Wv;99edA(k6L%je*ntO zu{fuF)i>1!{zt0+05LVWT;0nF%sj2a3c;Cb8c-lIL_R^!Q-j9}Ye$dkA=i=xVE-jm zZu`Z@g~MO7<*j~5JDNFtSZJaSNQpf!ASAD`{{_2-B;d7f;R9+;pK8RBNS+}}QiVvo z0AIL`1lchzu`#UZ_1oQLQoi3jzG+HsLEbvGc-&1910(up>NLG|Fro}?CRp%90F zx~QuW&H`21i!F7yrmt0hx*BfwqEGbN;$D05-+@Q+d*d%IZFdM;Gn?R*GM~ASz4_@6 z6oz83n=3(oh6vAG$~61);(F>N=EQ*m>n7O>Il^9#bB&}ts1}r19wTb6CX;rh+ z3g6;0qN2YGy}9)q>Gr`LDsHl!f~#3r8`pr%;zm_p2w}4eizd8)P?ZrG?NGYcoZy^5 zxutshOlDL2Q8lLIqRDUm;}{JU$-vZ1mE;_aq9(GW#@-m}aA{;1et?s38*aX%8A!|C zp>c|_)a8!flb`<}uf=Ja_~f9IHyvv-j-3rQ!D(;=*_&VZ0ky-!UAeVpssH16oXz=t z?pOe_LG}en+l5S9t1VE(dmqQ+>RJsy!VpJ3)bgtSh!E5+vU@S?zJ}n1-eH505?JA+ ze``fFTLwP0LI?4;#oD|cyoMf{Ag~0kO$P{EqNJ=TI%yKNtqJ7pcgNC+UwNPoh`Os4B#A?Hm z%?*Q=q=n&>Bb-NitZ{m^%jFM4a&*R2aICG$14%g?#%>c|(*SP2xU z`RR1+)o7j?zSh}N(c?@y`#-5G3*3U+WqL`l-ok9LL|Bssg56gmJ=*=#DaC((87h5g-Q1A2BIfvN0Kb7?f^(eJx$ny&J*bzKU8py} zrDxF|J1|1bxJN&_(@J=PLjFgW*TXI?Y;SIa=BV6^UB~7oqk4}i$YK~jL2P7C8mL2* z7J*k+XeZ3N=+sqgldx70=}O=i(;j)ujBvbM<8}6;^L>=-peuQ|mSl!HF8C)o;|@Ac zG78i^=Z{v#=gtQ-^aa2K=3R6>+i`6WQ}i zY;Eu4RZ_P-3-FC7(Zse1KcCY1`TbN-1bGV)$(Y=$?J^*R=>*L2(Re~mOQIs6 zO>z4HO+P{A4}=ivvfL74L?nxCJhY>7AZVr(-ke{KJE7Ifv~4*2k>Z%96Lv~FjJm2k?=Z4})J z;CAIgBcPE><`6p*VKgxZuWr^X_2usI9MmP27qji>j{N32Z3bmsuA2gx?UBspJ`-Dx z_HGV)oe7;i=6zYC<-HM&J8J#;*=)P>gXF-y4OO zbFj(u?a$Zht@qhNi#ejY^oOF5*x_oQz;UcUPCx4If!d>OS+*_4;|Ypvca-IAs}Sb>#^cF}5&ytd=e_=kLx3e+J@Q8+{ZM&UBZ0YM{{+H-Ot|{!T1vU8uo;_Jv3c-XN3-i|H&ia*9hQT}bX!bfK#}q?l z-0oL-!3u(8imJAK0o~t!3k~5@9{rugyv*8C&2{AnDO_U_Keq|_u5kOKsu_acE3m(9 z-ITkJ9!dnBN8HsW3{+qwx!FTuF#r;nV4PKxhB@c6yXx~ow890J0*#aAFHlQ#pfOZz zK_Jkh)+t;y0}fOF2vL~ku@IbO*?LQAEYI+sZz%S~ot5MC*1;1O$j7ndM>>bTpYC)s$)G$S?7!ry|nGghnx_%)?N%hDZX`GesoKe4qzI*bHt4W4tbn>tTO_TXL7S`8f==#NsQsu_}XCJ8r)5Bek2JU8{p8iN=ffrL!b`n{4kJEqD~ zRzg&~=bn+6iXZl&hL6Rh`G=Sztjl0=Vp6#ZhpQLagR*K@a|32}McQYs5c-Ml%&wv{ zyDE0BK9!T)P#9}E;6@7t0>7I44r!y@KbgOSVspONi7us2G^Z&u9IlptC?~M-6HQC2ajCG3l(FJOX*hBg@@2RT{R%m}HhOTB#D}j?;vO zb&km|7O#jh<=7eP`b^&-m$cx%H-_ayvQMP_A6E!7nj-GnjF@DDNiP)z=rR2-;rZ^~ z!rA3R9SjA!JBijF+5+{`{C)3e8jC~%{{hfvtf)CAO2@Y}hUBhd$;1#6rOWINu-u6i zT~RE47C$Duwvytw&$NluxWC}Oy>!$pmmyr?NyV$cⓈm2eggOv;cVJlOCKA?C{u= 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Ⓢ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 is a flexible and scalable open-source RL infrastructure designed for +Embodied and Agentic AI. This integration enables **reinforcement learning fine-tuning of Vision-Language-Action +(VLA) models** (e.g., GR00T, OpenVLA) on Isaac Lab simulation tasks. + +The typical workflow follows three stages: + +1. **Data collection** — Collect demonstration data from the Isaac Lab environment (e.g., via teleoperation or scripted policy). +2. **Base model training** — Train a VLA base model (e.g., GR00T) on the collected demonstrations using supervised learning. +3. **RL fine-tuning** — Fine-tune the pretrained VLA model on the Isaac Lab task using RLinf with PPO / Actor-Critic / SAC. + +Overview +~~~~~~~~ + +The RLinf integration allows Isaac Lab users to: + +- Fine-tune pretrained VLA models on Isaac Lab tasks using PPO / Actor-Critic / SAC +- Leverage RLinf's FSDP-based distributed training across multiple GPUs/nodes +- Define observation/action mappings from Isaac Lab to GR00T format via a single YAML config +- Register Isaac Lab tasks into RLinf without modifying RLinf source code + +Architecture +~~~~~~~~~~~~ + +.. code-block:: text + + ┌────────────────────────────────────────────────────────────────┐ + │ RLinf Runner │ + │ (EmbodiedRunner / EvalRunner) │ + ├────────────────┬──────────────────────┬────────────────────────┤ + │ Actor Worker │ Rollout Worker │ Env Worker │ + │ (FSDP) │ (HF Inference) │ (IsaacLab Sim) │ + │ │ │ │ + │ Policy │ Multi-step rollout │ IsaacLabGenericEnv │ + │ Update │ with VLA model │ ├─ _make_env_function │ + │ │ │ ├─ _wrap_obs │ + │ │ │ └─ _wrap_action │ + └────────────────┴──────────────────────┴────────────────────────┘ + +**Data flow:** + +1. ``EnvWorker`` runs Isaac Lab simulation and converts observations to RLinf format +2. ``RolloutWorker`` runs VLA model inference (e.g., GR00T) to produce actions +3. Actions are converted back to Isaac Lab format and stepped in the environment +4. ``ActorWorker`` updates the VLA model with PPO/actor-critic loss via FSDP + +Prerequisites +~~~~~~~~~~~~~ + +- **Isaac Lab** installed and configured +- **Isaac-GR00T** repo (for VLA inference and data transforms) +- A **pretrained VLA checkpoint** in HuggingFace format +- Multi-GPU setup recommended (FSDP requires at least 1 GPU) + +Installation +~~~~~~~~~~~~ + +From the Isaac Lab root directory: + +.. code-block:: bash + + # Install isaaclab_contrib with the RLinf extra + pip install -e "source/isaaclab_contrib[rlinf]" --ignore-requires-python + + # Install Isaac-GR00T (pinned version) + git clone https://github.com/NVIDIA/Isaac-GR00T.git + cd Isaac-GR00T + git checkout 4af2b622892f7dcb5aae5a3fb70bcb02dc217b96 + pip install -e .[base] --no-deps + cd ../ + +Quick Start +~~~~~~~~~~~ + +**Training** — RL fine-tuning of a pretrained VLA model: + +.. code-block:: bash + + python scripts/reinforcement_learning/rlinf/train.py \ + --task Isaac-Assemble-Trocar-G129-Dex3-v0 \ + --config_path source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config \ + --config_name isaaclab_ppo_gr00t_assemble_trocar + +**Evaluation** — Evaluate a trained checkpoint with video recording: + +.. code-block:: bash + + python scripts/reinforcement_learning/rlinf/play.py \ + --task Isaac-Assemble-Trocar-G129-Dex3-Eval-v0 \ + --model_path /path/to/checkpoint \ + --config_path source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config \ + --config_name isaaclab_ppo_gr00t_assemble_trocar \ + --video + +Configuration +~~~~~~~~~~~~~ + +All configuration lives in a **single YAML file** loaded by `Hydra `_. +The key configuration block is the ``env.train.isaaclab`` section, which defines how Isaac Lab observations +are converted to GR00T format: + +.. code-block:: yaml + + isaaclab: &isaaclab_config + task_description: "assemble trocar from tray" + + # IsaacLab → RLinf observation mapping + main_images: "front_camera" + extra_view_images: + - "left_wrist_camera" + - "right_wrist_camera" + states: + - key: "robot_joint_state" + slice: [15, 29] + - key: "robot_dex3_joint_state" + + # GR00T → IsaacLab action conversion + action_mapping: + prefix_pad: 15 + suffix_pad: 0 + +Key Files +~~~~~~~~~ + +.. code-block:: text + + scripts/reinforcement_learning/rlinf/ + ├── README.md # Detailed documentation + ├── train.py # Training entry point + ├── play.py # Evaluation entry point + └── cli_args.py # Shared CLI argument definitions + + source/isaaclab_contrib/isaaclab_contrib/rl/rlinf/ + ├── __init__.py + └── extension.py # Task registration, obs/action conversion + +For detailed configuration options, CLI arguments, and how to add new tasks, +see ``scripts/reinforcement_learning/rlinf/README.md``. diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index a28d129f7027..39536c32883f 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -204,6 +204,8 @@ for the lift-cube environment: +-------------------------+------------------------------+-----------------------------------------------------------------------------+------------------------------+ | |cabi_openarm_uni| | |cabi_openarm_uni-link| | Grasp the handle of a cabinet's drawer and open it with the OpenArm robot | | +-------------------------+------------------------------+-----------------------------------------------------------------------------+------------------------------+ + | |g1_assemble_trocar| | |g1_assemble_trocar-link| | Assemble trocar with a Unitree G1 humanoid robot with Dex3 hands | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+------------------------------+ .. |reach-franka| image:: ../_static/tasks/manipulation/franka_reach.jpg .. |reach-ur10| image:: ../_static/tasks/manipulation/ur10_reach.jpg @@ -228,6 +230,7 @@ for the lift-cube environment: .. |reach_openarm_uni| image:: ../_static/tasks/manipulation/openarm_uni_reach.jpg .. |lift_openarm_uni| image:: ../_static/tasks/manipulation/openarm_uni_lift.jpg .. |cabi_openarm_uni| image:: ../_static/tasks/manipulation/openarm_uni_open_drawer.jpg +.. |g1_assemble_trocar| image:: ../_static/tasks/manipulation/g1_assemble_trocar.jpg .. |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>`__ @@ -261,6 +264,7 @@ for the lift-cube environment: .. |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>`__ +.. |g1_assemble_trocar-link| replace:: `Isaac-Assemble-Trocar-G129-Dex3-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/g129_dex3_env_cfg.py>`__ Contact-rich Manipulation @@ -769,6 +773,11 @@ inferencing, including reading from an already trained checkpoint and disabling - Manager Based - **rsl_rl** (PPO), **rl_games** (PPO), **skrl** (PPO), **sb3** (PPO) - ``newton_mjwarp``, ``physx`` + * - Isaac-Assemble-Trocar-G129-Dex3-v0 + - Isaac-Assemble-Trocar-G129-Dex3-Eval-v0 + - Manager Based + - **rlinf** (PPO) + - * - Isaac-Cart-Double-Pendulum-Direct-v0 - - Direct diff --git a/scripts/reinforcement_learning/rlinf/README.md b/scripts/reinforcement_learning/rlinf/README.md index 4ca96ba4fd2c..725079782f97 100644 --- a/scripts/reinforcement_learning/rlinf/README.md +++ b/scripts/reinforcement_learning/rlinf/README.md @@ -81,7 +81,7 @@ python train.py python train.py --config_name isaaclab_ppo_gr00t_assemble_trocar # Training with task override -python train.py --task Isaac-Assemble-Trocar-G129-Dex3-RLinf-v0 +python train.py --task Isaac-Assemble-Trocar-G129-Dex3-v0 # Training with custom settings python train.py --num_envs 64 --max_epochs 1000 @@ -94,13 +94,13 @@ python train.py --list_tasks ```bash # Evaluate a trained checkpoint -python play.py --model_path /path/to/checkpoint +python play.py --task Isaac-Assemble-Trocar-G129-Dex3-Eval-v0 --model_path /path/to/checkpoint # Evaluate with video recording -python play.py --model_path /path/to/checkpoint --video +python play.py --task Isaac-Assemble-Trocar-G129-Dex3-Eval-v0 --model_path /path/to/checkpoint --video # Evaluate with specific number of environments -python play.py --model_path /path/to/checkpoint --num_envs 8 +python play.py --task Isaac-Assemble-Trocar-G129-Dex3-Eval-v0 --model_path /path/to/checkpoint --num_envs 8 ``` ## Configuration @@ -132,7 +132,7 @@ env: total_num_envs: 4 max_episode_steps: 256 init_params: - id: "Isaac-Assemble-Trocar-G129-Dex3-RLinf-v0" + id: "Isaac-Assemble-Trocar-G129-Dex3-v0" isaaclab: &isaaclab_config # IsaacLab ↔ RLinf mapping (see below) ... eval: diff --git a/source/isaaclab_assets/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst b/source/isaaclab_assets/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst new file mode 100644 index 000000000000..2a79e0a27d50 --- /dev/null +++ b/source/isaaclab_assets/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_assets.robots.unitree.G129_CFG_WITH_DEX3_BASE_FIX` robot configuration + for the Unitree G1 29-DOF with Dex3 hands. diff --git a/source/isaaclab_assets/isaaclab_assets/robots/unitree.py b/source/isaaclab_assets/isaaclab_assets/robots/unitree.py index 7a02c6eff294..8e4f692ca6df 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/unitree.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/unitree.py @@ -21,10 +21,12 @@ """ import isaaclab.sim as sim_utils -from isaaclab.actuators import ActuatorNetMLPCfg, DCMotorCfg, ImplicitActuatorCfg +from isaaclab.actuators import ActuatorNetMLPCfg, DCMotorCfg, IdealPDActuatorCfg, ImplicitActuatorCfg from isaaclab.assets.articulation import ArticulationCfg from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +HEALTHCARE_S3 = "https://omniverse-content-production.s3-us-west-2.amazonaws.com/Assets/Isaac/Healthcare/0.5.0/132c82d" + ## # Configuration - Actuators. ## @@ -609,3 +611,201 @@ damping=0.2, armature=0.001, ) + + +G129_CFG_WITH_DEX3_BASE_FIX = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=f"{HEALTHCARE_S3}/Robots/UnitreeG1/g1_29dof_with_dex3_base_fix/g1_29dof_with_dex3_base_fix.usd", + activate_contact_sensors=False, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + retain_accelerations=False, + linear_damping=0.0, + angular_damping=0.0, + max_linear_velocity=1000.0, + max_angular_velocity=1000.0, + max_depenetration_velocity=1.0, + solver_position_iteration_count=4, + solver_velocity_iteration_count=0, + ), + articulation_props=sim_utils.ArticulationRootPropertiesCfg( + enabled_self_collisions=False, solver_position_iteration_count=4, solver_velocity_iteration_count=0 + ), + ), + prim_path="/World/envs/env_.*/Robot", + init_state=ArticulationCfg.InitialStateCfg( + pos=(0.0, 0.0, 0.75), + joint_pos={ + "left_hip_yaw_joint": 0.0, + "left_hip_roll_joint": 0.0, + "left_hip_pitch_joint": -0.05, + "left_knee_joint": 0.2, + "left_ankle_pitch_joint": -0.15, + "left_ankle_roll_joint": 0.0, + "right_hip_yaw_joint": 0.0, + "right_hip_roll_joint": 0.0, + "right_hip_pitch_joint": -0.05, + "right_knee_joint": 0.2, + "right_ankle_pitch_joint": -0.15, + "right_ankle_roll_joint": 0.0, + "waist_yaw_joint": 0.0, + "waist_roll_joint": 0.0, + "waist_pitch_joint": 0.0, + "left_shoulder_pitch_joint": 0.0, + "left_shoulder_roll_joint": 0.0, + "left_shoulder_yaw_joint": 0.0, + "left_elbow_joint": -0.3, + "left_wrist_roll_joint": 0.0, + "left_wrist_pitch_joint": 0.0, + "left_wrist_yaw_joint": 0.0, + "right_shoulder_pitch_joint": 0.0, + "right_shoulder_roll_joint": 0.0, + "right_shoulder_yaw_joint": 0.0, + "right_elbow_joint": -0.3, + "right_wrist_roll_joint": 0.0, + "right_wrist_pitch_joint": 0.0, + "right_wrist_yaw_joint": 0.0, + "left_hand_index_0_joint": 0.0, + "left_hand_middle_0_joint": 0.0, + "left_hand_thumb_0_joint": 0.0, + "left_hand_index_1_joint": 0.0, + "left_hand_middle_1_joint": 0.0, + "left_hand_thumb_1_joint": 0.0, + "left_hand_thumb_2_joint": 0.0, + "right_hand_index_0_joint": 0.0, + "right_hand_middle_0_joint": 0.0, + "right_hand_thumb_0_joint": 0.0, + "right_hand_index_1_joint": 0.0, + "right_hand_middle_1_joint": 0.0, + "right_hand_thumb_1_joint": 0.0, + "right_hand_thumb_2_joint": 0.0, + }, + joint_vel={".*": 0.0}, + ), + soft_joint_pos_limit_factor=0.9, + actuators={ + "legs": IdealPDActuatorCfg( + joint_names_expr=[ + ".*_hip_yaw_joint", + ".*_hip_roll_joint", + ".*_hip_pitch_joint", + ".*_knee_joint", + ], + effort_limit={ + ".*_hip_yaw_joint": 88.0, + ".*_hip_roll_joint": 88.0, + ".*_hip_pitch_joint": 88.0, + ".*_knee_joint": 139.0, + }, + velocity_limit={ + ".*_hip_yaw_joint": 32.0, + ".*_hip_roll_joint": 32.0, + ".*_hip_pitch_joint": 32.0, + ".*_knee_joint": 20.0, + }, + stiffness={ + ".*_hip_yaw_joint": 150.0, + ".*_hip_roll_joint": 150.0, + ".*_hip_pitch_joint": 150.0, + ".*_knee_joint": 300.0, + }, + damping={ + ".*_hip_yaw_joint": 2.0, + ".*_hip_roll_joint": 2.0, + ".*_hip_pitch_joint": 2.0, + ".*_knee_joint": 4.0, + }, + armature={ + ".*_hip_.*": 0.03, + ".*_knee_joint": 0.03, + }, + ), + "feet": IdealPDActuatorCfg( + joint_names_expr=[".*_ankle_pitch_joint", ".*_ankle_roll_joint"], + stiffness={ + ".*_ankle_pitch_joint": 40.0, + ".*_ankle_roll_joint": 40.0, + }, + damping={ + ".*_ankle_pitch_joint": 2, + ".*_ankle_roll_joint": 2, + }, + effort_limit={ + ".*_ankle_pitch_joint": 50.0, + ".*_ankle_roll_joint": 50.0, + }, + velocity_limit={ + ".*_ankle_pitch_joint": 37.0, + ".*_ankle_roll_joint": 37.0, + }, + armature=0.03, + friction=0.03, + ), + "waist": ImplicitActuatorCfg( + joint_names_expr=["waist_yaw_joint", "waist_roll_joint", "waist_pitch_joint"], + effort_limit=1000.0, + velocity_limit=0.0, + stiffness={"waist_yaw_joint": 10000.0, "waist_roll_joint": 10000.0, "waist_pitch_joint": 10000.0}, + damping={"waist_yaw_joint": 10000.0, "waist_roll_joint": 10000.0, "waist_pitch_joint": 10000.0}, + armature=None, + ), + "arms": IdealPDActuatorCfg( + joint_names_expr=[ + ".*_shoulder_pitch_joint", + ".*_shoulder_roll_joint", + ".*_shoulder_yaw_joint", + ".*_elbow_joint", + ".*_wrist_.*_joint", + ], + effort_limit={ + ".*_shoulder_pitch_joint": 25.0, + ".*_shoulder_roll_joint": 25.0, + ".*_shoulder_yaw_joint": 25.0, + ".*_elbow_joint": 25.0, + ".*_wrist_roll_joint": 25.0, + ".*_wrist_pitch_joint": 5.0, + ".*_wrist_yaw_joint": 5.0, + }, + velocity_limit={ + ".*_shoulder_pitch_joint": 37.0, + ".*_shoulder_roll_joint": 37.0, + ".*_shoulder_yaw_joint": 37.0, + ".*_elbow_joint": 37.0, + ".*_wrist_roll_joint": 37.0, + ".*_wrist_pitch_joint": 22.0, + ".*_wrist_yaw_joint": 22.0, + }, + stiffness={ + ".*_shoulder_pitch_joint": 100.0, + ".*_shoulder_roll_joint": 100.0, + ".*_shoulder_yaw_joint": 40.0, + ".*_elbow_joint": 40.0, + ".*_wrist_.*_joint": 20.0, + }, + damping={ + ".*_shoulder_pitch_joint": 15.0, + ".*_shoulder_roll_joint": 15.0, + ".*_shoulder_yaw_joint": 8.0, + ".*_elbow_joint": 8.0, + ".*_wrist_.*_joint": 4.0, + }, + armature={".*_shoulder_.*": 0.03, ".*_elbow_.*": 0.03, ".*_wrist_.*_joint": 0.03}, + friction=0.03, + ), + "hands": IdealPDActuatorCfg( + joint_names_expr=[ + ".*_hand_.*", + ], + effort_limit=5.0, + velocity_limit=10.0, + stiffness=8.0, + damping=1.5, + armature=0.03, + friction=0.5, + ), + }, +) +"""Configuration for the Unitree G1 29DOF robot with Dex3 hands and fixed base. + +This configuration is designed for high-precision manipulation tasks such as trocar assembly. +""" diff --git a/source/isaaclab_contrib/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst b/source/isaaclab_contrib/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst new file mode 100644 index 000000000000..062bce25b772 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Removed ``_patched_reset`` monkey-patch in RLinf extension; use + ``num_rerenders_on_reset`` env config instead. diff --git a/source/isaaclab_contrib/isaaclab_contrib/rl/rlinf/extension.py b/source/isaaclab_contrib/isaaclab_contrib/rl/rlinf/extension.py index 89368c532109..1defd1a0d503 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/rl/rlinf/extension.py +++ b/source/isaaclab_contrib/isaaclab_contrib/rl/rlinf/extension.py @@ -440,6 +440,19 @@ def __init__(self, cfg, num_envs: int, seed_offset: int, total_num_processes: in """ super().__init__(cfg, num_envs, seed_offset, total_num_processes, worker_info) + def _record_metrics(self, step_reward, terminations, infos): + """Override to use terminations (task completion) for success_once.""" + + episode_info = {} + self.returns += step_reward + self.success_once = self.success_once | terminations.bool() + episode_info["success_once"] = self.success_once.clone() + episode_info["return"] = self.returns.clone() + episode_info["episode_len"] = self.elapsed_steps.clone() + episode_info["reward"] = episode_info["return"] / episode_info["episode_len"] + infos["episode"] = episode_info + return infos + def _make_env_function(self) -> collections.abc.Callable: """Create the environment factory function. @@ -468,6 +481,7 @@ def make_env_isaaclab() -> tuple: isaac_env_cfg.scene.num_envs = self.cfg.init_params.num_envs env = gym.make(self.isaaclab_env_id, cfg=isaac_env_cfg, render_mode="rgb_array").unwrapped + return env, sim_app return make_env_isaaclab @@ -481,7 +495,6 @@ def _wrap_obs(self, obs: dict) -> dict: - ``"extra_view_images"``: ``(B, N, H, W, C)`` — stacked extra cameras. - ``"states"``: ``(B, D)`` — concatenated state vector. - ``"task_descriptions"``: ``list[str]`` — task descriptions. - Config is read from the YAML file via :func:`_get_isaaclab_cfg`. Args: diff --git a/source/isaaclab_tasks/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst b/source/isaaclab_tasks/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst new file mode 100644 index 000000000000..f5d918d3680f --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added ``Isaac-Assemble-Trocar-G129-Dex3-v0`` and + ``Isaac-Assemble-Trocar-G129-Dex3-Eval-v0`` manipulation tasks: a Unitree G1 + 29-DOF humanoid with Dex3 hands assembles a trocar from a tray, trained via + RL post-training of a VLA model using RLinf. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/__init__.py new file mode 100644 index 000000000000..624d02269813 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/__init__.py @@ -0,0 +1,6 @@ +# 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 + +"""Configurations for the assemble trocar environments.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/__init__.py new file mode 100644 index 000000000000..cd8c26c840a5 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/__init__.py @@ -0,0 +1,9 @@ +# 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 .camera_config import CameraBaseCfg, CameraPresets +from .robot_config import G1_29DOF_BODY_JOINT_INDICES, G1_DEX3_JOINT_INDICES, G1RobotPresets + +__all__ = ["G1_29DOF_BODY_JOINT_INDICES", "G1_DEX3_JOINT_INDICES", "G1RobotPresets", "CameraBaseCfg", "CameraPresets"] diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/camera_config.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/camera_config.py new file mode 100644 index 000000000000..405948726034 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/camera_config.py @@ -0,0 +1,131 @@ +# 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 + +""" +public camera configuration +include the basic configuration for different types of cameras, support scene-specific parameter customization +""" + +from collections.abc import Sequence + +import isaaclab.sim as sim_utils +from isaaclab.sensors import CameraCfg +from isaaclab.utils import configclass + + +@configclass +class CameraBaseCfg: + """camera base configuration class + + provide the default configuration for different types of cameras, support scene-specific parameter customization + """ + + @classmethod + def get_camera_config( + cls, + prim_path: str = "/World/envs/env_.*/Robot/d435_link/front_cam", + update_period: float = 0.02, + height: int = 480, + width: int = 640, + focal_length: float = 7.6, + focus_distance: float = 400.0, + horizontal_aperture: float = 20.0, + clipping_range: tuple[float, float] = (0.1, 1.0e5), + pos_offset: tuple[float, float, float] = (0.0, 0.0, 0.0), + rot_offset: tuple[float, float, float, float] = (0.5, -0.5, 0.5, -0.5), + data_types: Sequence[str] | None = None, + ) -> CameraCfg: + """Get a pinhole camera configuration. + + Args: + prim_path: the path of the camera in the scene + update_period: update period (seconds) + height: image height (pixels) + width: image width (pixels) + focal_length: focal length + focus_distance: focus distance + horizontal_aperture: horizontal aperture + clipping_range: clipping range (near clipping plane, far clipping plane) + pos_offset: position offset (x, y, z) + rot_offset: rotation offset quaternion + data_types: data type list + + Returns: + CameraCfg: camera configuration + """ + if data_types is None: + data_types = ("rgb",) + + return CameraCfg( + prim_path=prim_path, + update_period=update_period, + height=height, + width=width, + data_types=list(data_types), + spawn=sim_utils.PinholeCameraCfg( + focal_length=focal_length, + focus_distance=focus_distance, + horizontal_aperture=horizontal_aperture, + clipping_range=clipping_range, + ), + offset=CameraCfg.OffsetCfg(pos=pos_offset, rot=rot_offset, convention="ros"), + ) + + +@configclass +class CameraPresets: + """camera preset configuration collection + + include the common camera configuration preset for different scenes + """ + + @classmethod + def g1_front_camera(cls, **overrides) -> CameraCfg: + params = { + "height": 224, + "width": 224, + "focal_length": 10.5, + "horizontal_aperture": 14.25, # Match original vertical FOV after crop + } + params.update(overrides) + return CameraBaseCfg.get_camera_config(**params) + + @classmethod + def left_dex3_wrist_camera(cls, **overrides) -> CameraCfg: + """left wrist camera configuration""" + params = { + "prim_path": "/World/envs/env_.*/Robot/left_hand_camera_base_link/left_wrist_camera", + "height": 224, + "width": 224, + "update_period": 0.02, + "data_types": ["rgb"], + "focal_length": 12.0, + "focus_distance": 400.0, + "horizontal_aperture": 14.25, # Match original vertical FOV after crop + "clipping_range": (0.1, 1.0e5), + "pos_offset": (-0.04012, -0.07441, 0.15711), + "rot_offset": (0.00539, 0.86024, 0.0424, 0.50809), + } + params.update(overrides) + return CameraBaseCfg.get_camera_config(**params) + + @classmethod + def right_dex3_wrist_camera(cls, **overrides) -> CameraCfg: + """right wrist camera configuration""" + params = { + "prim_path": "/World/envs/env_.*/Robot/right_hand_camera_base_link/right_wrist_camera", + "height": 224, + "width": 224, + "update_period": 0.02, + "data_types": ["rgb"], + "focal_length": 12.0, + "focus_distance": 400.0, + "horizontal_aperture": 14.25, # Match original vertical FOV after crop + "clipping_range": (0.1, 1.0e5), + "pos_offset": (-0.04012, 0.07441, 0.15711), + "rot_offset": (0.00539, 0.86024, 0.0424, 0.50809), + } + params.update(overrides) + return CameraBaseCfg.get_camera_config(**params) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/gr00t_config.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/gr00t_config.py new file mode 100644 index 000000000000..540b0edbc3a0 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/gr00t_config.py @@ -0,0 +1,144 @@ +# 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 + +"""GR00T data configuration for IsaacLab tasks. + +This module defines customizable GR00T data configurations for different +embodiments. Users can create their own data config classes by subclassing +BaseDataConfig or copying/modifying the examples here. + +Example usage in run.sh: + export RLINF_DATA_CONFIG="policy.gr00t_config" + export RLINF_DATA_CONFIG_CLASS="policy.gr00t_config:IsaacLabDataConfig" +""" + +from gr00t.data.dataset import ModalityConfig +from gr00t.data.transform.base import ComposedModalityTransform +from gr00t.data.transform.concat import ConcatTransform +from gr00t.data.transform.state_action import StateActionSinCosTransform, StateActionToTensor, StateActionTransform +from gr00t.data.transform.video import VideoColorJitter, VideoToNumpy, VideoToTensor +from gr00t.experiment.data_config import DATA_CONFIG_MAP, BaseDataConfig +from gr00t.model.transforms import GR00TTransform + + +class IsaacLabDataConfig(BaseDataConfig): + """Generic GR00T data config for IsaacLab tasks with G1 + Dex3.""" + + # Video modality keys (from gr00t_mapping.video in RLINF_OBS_MAP_JSON) + video_keys = [ + "video.left_wrist_view", + "video.right_wrist_view", + "video.room_view", + ] + + # State modality keys (from gr00t_mapping.state in RLINF_OBS_MAP_JSON) + state_keys = [ + "state.left_arm", + "state.right_arm", + "state.left_hand", + "state.right_hand", + ] + + # Action modality keys (output from GR00T model) + action_keys = [ + "action.left_arm", + "action.right_arm", + "action.left_hand", + "action.right_hand", + ] + + # Language annotation key + language_keys = ["annotation.human.task_description"] + + # Observation and action indices + observation_indices = [0] + action_indices = list(range(16)) + + def modality_config(self) -> dict[str, ModalityConfig]: + """Define modality configurations for video, state, action, and language.""" + video_modality = ModalityConfig( + delta_indices=self.observation_indices, + modality_keys=self.video_keys, + ) + + state_modality = ModalityConfig( + delta_indices=self.observation_indices, + modality_keys=self.state_keys, + ) + + action_modality = ModalityConfig( + delta_indices=self.action_indices, + modality_keys=self.action_keys, + ) + + language_modality = ModalityConfig( + delta_indices=self.observation_indices, + modality_keys=self.language_keys, + ) + + return { + "video": video_modality, + "state": state_modality, + "action": action_modality, + "language": language_modality, + } + + def transform(self): + """Define the transform pipeline for processing observations and actions.""" + transforms = [ + # Video transforms + VideoToTensor(apply_to=self.video_keys), + # Disabled: camera already outputs 224×224 via TiledCameraCfg. + # To avoid VideoToTensor size-check errors, either: + # 1. Disable input size validation in VideoToTensor, OR + # 2. Set modality meta height/width to 224 to match actual input. + # Re-enable VideoCrop/VideoResize if camera resolution changes. + # VideoCrop(apply_to=self.video_keys, scale=0.95), + # VideoResize( + # apply_to=self.video_keys, + # height=224, + # width=224, + # interpolation="linear", + # ), + VideoColorJitter( + apply_to=self.video_keys, + brightness=0.3, + contrast=0.4, + saturation=0.5, + hue=0.08, + ), + VideoToNumpy(apply_to=self.video_keys), + # State transforms + StateActionToTensor(apply_to=self.state_keys), + StateActionSinCosTransform(apply_to=self.state_keys), + # Action transforms + StateActionToTensor(apply_to=self.action_keys), + StateActionTransform( + apply_to=self.action_keys, + normalization_modes={key: "min_max" for key in self.action_keys}, + ), + # Concat transforms + ConcatTransform( + video_concat_order=self.video_keys, + state_concat_order=self.state_keys, + action_concat_order=self.action_keys, + ), + # Model-specific transform + GR00TTransform( + state_horizon=len(self.observation_indices), + action_horizon=len(self.action_indices), + max_state_dim=64, + max_action_dim=32, + ), + ] + return ComposedModalityTransform(transforms=transforms) + + +# -------------------------------------------------------------------------- +# Register data configs into GR00T's DATA_CONFIG_MAP +# -------------------------------------------------------------------------- + +# This allows load_data_config("policy.gr00t_config:IsaacLabDataConfig") to work +DATA_CONFIG_MAP["isaaclab_g1_dex3"] = IsaacLabDataConfig() diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/isaaclab_ppo_gr00t_assemble_trocar.yaml b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/isaaclab_ppo_gr00t_assemble_trocar.yaml new file mode 100644 index 000000000000..b130a12a8a53 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/isaaclab_ppo_gr00t_assemble_trocar.yaml @@ -0,0 +1,298 @@ +# 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 + +defaults: + - override hydra/job_logging: stdout + +hydra: + run: + dir: . + output_subdir: null + +cluster: + num_nodes: 1 + component_placement: + actor,env,rollout: all + +runner: + task_type: embodied + logger: + log_path: "../results" + project_name: rlinf + experiment_name: "test_gr00t" + logger_backends: ["tensorboard"] # wandb, swanlab + + max_epochs: 1000 + max_steps: -1 + + only_eval: False + eval_policy_path: null # Optional: .pt file or None, if None, will use the checkpoint in rollout.model.model_path + val_check_interval: -1 + save_interval: 2 + seq_length: 4096 + max_prompt_length: 30 + + resume_dir: null + +algorithm: + normalize_advantages: True + kl_penalty: kl # how to estimate kl divergence: kl or kl_penalty + group_size: 1 + reward_coef: 1.0 + rollout_epoch: 2 + eval_rollout_epoch: 1 # set eval_rollout_epoch > 0 when enable runner.only_eval or runner.val_check_interval > 0 + + reward_type: chunk_level + logprob_type: chunk_level + entropy_type: chunk_level + + update_epoch: 4 + adv_type: gae + loss_type: actor_critic + loss_agg_func: "token-mean" + kl_beta: 0.0 + entropy_bonus: 0 + clip_ratio_high: 0.2 + clip_ratio_low: 0.2 + clip_ratio_c: 3.0 + value_clip: 0.2 + huber_delta: 10.0 + + gamma: 0.99 + gae_lambda: 0.95 + + filter_rewards: False + rewards_lower_bound: 0.1 + rewards_upper_bound: 0.9 + # params for generation + sampling_params: + do_sample: True + temperature_train: 1.0 + temperature_eval: 0.6 + top_k: 50 + top_p: 1.0 + repetition_penalty: 1.0 + add_BOS: False + + # length argument for autoregressive sampling + # max length means max amount of tokens to generate + length_params: + max_new_token: null + max_length: 1024 + min_length: 1 + +# --------------------------------------------------------------------------- +# Environment +# --------------------------------------------------------------------------- +env: + group_name: "EnvGroup" + channel: + name: "env_buffer_list" + queue_name: "obs_buffer" + queue_size: 0 + enable_offload: False + + train: + env_type: isaaclab + total_num_envs: 4 + auto_reset: False + ignore_terminations: False + use_rel_reward: True + seed: 0 + group_size: 1 + reward_coef: 1.0 + use_fixed_reset_state_ids: True + max_steps_per_rollout_epoch: 256 + max_episode_steps: 256 + video_cfg: + save_video: False + info_on_video: True + video_base_dir: ${runner.logger.log_path}/video/train + init_params: + id: "Isaac-Assemble-Trocar-G129-Dex3-v0" + num_envs: null + max_episode_steps: ${env.train.max_episode_steps} + task_description: "assemble trocar from tray" + + # ======================================================================== + # IsaacLab -> RLinf -> GR00T observation/action mapping configuration + # This section defines how IsaacLab observations are converted to GR00T format + # ======================================================================== + isaaclab: &isaaclab_config # YAML anchor for reuse in eval + # Task description for language conditioning + task_description: "assemble trocar from tray" + + # --- IsaacLab -> RLinf observation mapping --- + # main_images: single camera key for main view + main_images: "front_camera" + # extra_view_images: list of camera keys to stack as (B, N, H, W, C) + extra_view_images: + - "left_wrist_camera" + - "right_wrist_camera" + # states: list of state specs with optional slicing + # Each entry can be a string (use full tensor) or dict with "key" and "slice" + states: + - key: "robot_joint_state" + slice: [15, 29] # G129 shoulder joints + - key: "robot_dex3_joint_state" + # slice: null # Use full tensor + + # --- RLinf -> GR00T format conversion --- + gr00t_mapping: + video: + main_images: "video.room_view" + extra_view_images: + - "video.left_wrist_view" + - "video.right_wrist_view" + state: + # Slice concatenated states into GR00T state keys + # Total states: 14 (shoulder) + 14 (dex3) = 28 dims + - gr00t_key: "state.left_arm" + slice: [0, 7] + - gr00t_key: "state.right_arm" + slice: [7, 14] + - gr00t_key: "state.left_hand" + slice: [14, 21] + - gr00t_key: "state.right_hand" + slice: [21, 28] + + # --- GR00T -> IsaacLab action conversion --- + action_mapping: + prefix_pad: 15 # Pad zeros at front for G129 body joints (not controlled) + suffix_pad: 0 + + # --- GR00T model configuration (single source of truth) --- + # actor.model.embodiment_tag and obs_converter_type reference these values via ${} + obs_converter_type: "dex3" + embodiment_tag: "new_embodiment" + embodiment_tag_id: 31 + data_config_class: "gr00t_config:IsaacLabDataConfig" + + eval: + env_type: isaaclab + total_num_envs: 4 + auto_reset: True + ignore_terminations: True + use_rel_reward: True + seed: 0 + group_size: 1 + reward_coef: 1.0 + use_fixed_reset_state_ids: True + max_steps_per_rollout_epoch: 256 + max_episode_steps: 256 + video_cfg: + save_video: True + info_on_video: True + video_base_dir: ${runner.logger.log_path}/video/eval + init_params: + id: "Isaac-Assemble-Trocar-G129-Dex3-Eval-v0" + num_envs: null + max_episode_steps: ${env.eval.max_episode_steps} + task_description: "install trocar from box" + # Reuse IsaacLab config from train section via YAML anchor + isaaclab: *isaaclab_config + +# --------------------------------------------------------------------------- +# Rollout +# --------------------------------------------------------------------------- +rollout: + group_name: "RolloutGroup" + channel: + name: ${env.channel.name} + queue_name: "action_buffer" + queue_size: 0 + mode: "colocate" + backend: "huggingface" + enable_offload: True + pipeline_stage_num: 1 + + model: + model_path: "/mnt/ckpt/g1_install_trocar_sim_box_v3_60_train_bs32_1_gpus_cos_30k_tune_visual/" + precision: ${actor.model.precision} + obs_converter_type: ${env.train.isaaclab.obs_converter_type} + embodiment_tag: ${env.train.isaaclab.embodiment_tag} + +# --------------------------------------------------------------------------- +# Actor +# --------------------------------------------------------------------------- +actor: + group_name: "ActorGroup" + channel: + name: ${env.channel.name} + queue_name: "replay_buffer" + queue_size: 0 + training_backend: "fsdp" + micro_batch_size: 2 + global_batch_size: 4 + seed: 1234 + enable_offload: False + + model: + model_type: "gr00t" + model_path: "/mnt/ckpt/g1_install_trocar_sim_box_v3_60_train_bs32_1_gpus_cos_30k_tune_visual/" + precision: "bf16" + trust_remote_code: True + is_lora: false + action_dim: 28 + num_action_chunks: 1 + denoising_steps: 4 + policy_setup: "widowx_bridge" + obs_converter_type: ${env.train.isaaclab.obs_converter_type} + embodiment_tag: ${env.train.isaaclab.embodiment_tag} + add_value_head: True + rl_head_config: + joint_logprob: False + noise_method: "flow_sde" + ignore_last: False + safe_get_logprob: False + noise_anneal: False + noise_params: [0.7, 0.3, 400] + noise_level: 0.3 + add_value_head: ${actor.model.add_value_head} + chunk_critic_input: False + detach_critic_input: True + disable_dropout: True + use_vlm_value: False + value_vlm_mode: "mean_token" + padding_value: 850 + + optim: + lr: 5e-6 + value_lr: 1e-4 + adam_beta1: 0.9 + adam_beta2: 0.95 + adam_eps: 1.0e-08 + clip_grad: 1.0 + weight_decay: 0.01 + critic_warmup_steps: 0 + + fsdp_config: + strategy: "fsdp" + sharding_strategy: "full_shard" + gradient_checkpointing: False + cpu_offload: False + offload_pin_memory: False + reshard_after_forward: True + enable_gradient_accumulation: True + forward_prefetch: False + limit_all_gathers: False + backward_prefetch: null + use_orig_params: False + use_liger_kernel: False + fsdp_size: -1 + mixed_precision: + param_dtype: ${actor.model.precision} + reduce_dtype: ${actor.model.precision} + buffer_dtype: ${actor.model.precision} + amp: + enabled: False + precision: "bf16" + use_grad_scaler: False + +reward: + use_reward_model: False + +critic: + use_critic_model: False diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py new file mode 100644 index 000000000000..81c60741b784 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py @@ -0,0 +1,147 @@ +# 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 + +"""Robot configuration for the `install_trocar` task. + +This file is intentionally **minimal**: +- Supported robot: **Unitree G1 (29 DOF body)** +- Supported hands: **Dex3** + +The only public entry point expected by the task is +`G1RobotPresets.g1_29dof_dex3_base_fix(...)`. +""" + +import numpy as np + +from isaaclab.assets import ArticulationCfg +from isaaclab.utils import configclass + +from isaaclab_assets.robots.unitree import G129_CFG_WITH_DEX3_BASE_FIX + +# Joint indices in the full robot joint vector for observation extraction. +# Body joints: 29 DOF (legs, waist, arms, wrists) +G1_29DOF_BODY_JOINT_INDICES: list[int] = [ + 0, + 3, + 6, + 9, + 13, + 17, + 1, + 4, + 7, + 10, + 14, + 18, + 2, + 5, + 8, + 11, + 15, + 19, + 21, + 23, + 25, + 27, + 12, + 16, + 20, + 22, + 24, + 26, + 28, +] + +# Dex3 hand joints: 14 DOF (left + right) +G1_DEX3_JOINT_INDICES: list[int] = [31, 37, 41, 30, 36, 29, 35, 34, 40, 42, 33, 39, 32, 38] + +# Default joint positions for the supported setup (G1 29DOF + Dex3). +DEFAULT_JOINT_POS: dict[str, float] = { + # legs + "left_hip_pitch_joint": 0.0, + "left_hip_roll_joint": 0.0, + "left_hip_yaw_joint": 0.0, + "left_knee_joint": 0.0, + "left_ankle_pitch_joint": 0.0, + "left_ankle_roll_joint": 0.0, + "right_hip_pitch_joint": 0.0, + "right_hip_roll_joint": 0.0, + "right_hip_yaw_joint": 0.0, + "right_knee_joint": 0.0, + "right_ankle_pitch_joint": 0.0, + "right_ankle_roll_joint": 0.0, + # waist + "waist_yaw_joint": 0.0, + "waist_roll_joint": 0.0, + "waist_pitch_joint": 0.0, + # arms + "left_shoulder_pitch_joint": -0.754599, + "left_shoulder_roll_joint": 0.550010, + "left_shoulder_yaw_joint": -0.399298, + "left_elbow_joint": 0.278886, + "left_wrist_roll_joint": 0.320559, + "left_wrist_pitch_joint": -0.203525, + "left_wrist_yaw_joint": -0.387435, + "right_shoulder_pitch_joint": -0.340858, + "right_shoulder_roll_joint": -0.186152, + "right_shoulder_yaw_joint": 0.015023, + "right_elbow_joint": -0.777159, + "right_wrist_roll_joint": 0.019805, + "right_wrist_pitch_joint": 1.182285, + "right_wrist_yaw_joint": -0.022848, + # dex3 hands (left) + "left_hand_index_0_joint": -60.0 * np.pi / 180.0, + "left_hand_middle_0_joint": -60.0 * np.pi / 180.0, + "left_hand_thumb_0_joint": 0.0, + "left_hand_index_1_joint": -40.0 * np.pi / 180.0, + "left_hand_middle_1_joint": -40.0 * np.pi / 180.0, + "left_hand_thumb_1_joint": 0.0, + "left_hand_thumb_2_joint": 0.0, + # dexterous hand joint - right hand + "right_hand_index_0_joint": 60.0 * np.pi / 180.0, + "right_hand_middle_0_joint": 60.0 * np.pi / 180.0, + "right_hand_thumb_0_joint": 0.0, + "right_hand_index_1_joint": 40.0 * np.pi / 180.0, + "right_hand_middle_1_joint": 40.0 * np.pi / 180.0, + "right_hand_thumb_1_joint": 0.0, + "right_hand_thumb_2_joint": 0.0, +} + + +def make_g1_29dof_dex3_cfg( + *, + prim_path: str = "/World/envs/env_.*/Robot", + init_pos: tuple[float, float, float] = (-0.15, 0.0, 0.744), + init_rot: tuple[float, float, float, float] = (0, 0, 0.7071, 0.7071), + custom_joint_pos: dict[str, float] | None = None, + base_config: ArticulationCfg = G129_CFG_WITH_DEX3_BASE_FIX, +) -> ArticulationCfg: + """Create the only supported robot articulation cfg for this task.""" + joint_pos = DEFAULT_JOINT_POS.copy() + if custom_joint_pos: + joint_pos.update(custom_joint_pos) + return base_config.replace( + prim_path=prim_path, + init_state=ArticulationCfg.InitialStateCfg( + pos=init_pos, + rot=init_rot, + joint_pos=joint_pos, + joint_vel={".*": 0.0}, + ), + ) + + +@configclass +class G1RobotPresets: + """G1 robot preset configuration collection""" + + @classmethod + def g1_29dof_dex3_base_fix( + cls, + init_pos: tuple[float, float, float] = (-0.15, 0.0, 0.76), + init_rot: tuple[float, float, float, float] = (0, 0, 0.7071, 0.7071), + ) -> ArticulationCfg: + """pick-place task configuration - dex3 hand""" + return make_g1_29dof_dex3_cfg(init_pos=init_pos, init_rot=init_rot) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/g129_dex3_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/g129_dex3_env_cfg.py new file mode 100644 index 000000000000..50e58134f14c --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/g129_dex3_env_cfg.py @@ -0,0 +1,444 @@ +# 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 isaaclab_physx.physics import PhysxCfg + +import isaaclab.envs.mdp as base_mdp +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg +from isaaclab.envs import ManagerBasedRLEnvCfg, ViewerCfg +from isaaclab.managers import EventTermCfg, SceneEntityCfg +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg +from isaaclab.utils import configclass + +from isaaclab_tasks.manager_based.manipulation.assemble_trocar import mdp + +from isaaclab_tasks.manager_based.manipulation.assemble_trocar.config import ( # isort: skip + CameraPresets, + G1RobotPresets, +) + +joint_names = [ + "left_hip_pitch_joint", + "right_hip_pitch_joint", + "left_hip_roll_joint", + "right_hip_roll_joint", + "left_hip_yaw_joint", + "right_hip_yaw_joint", + "left_knee_joint", + "right_knee_joint", + "left_ankle_pitch_joint", + "right_ankle_pitch_joint", + "left_ankle_roll_joint", + "right_ankle_roll_joint", + "waist_yaw_joint", + "waist_roll_joint", + "waist_pitch_joint", + "left_shoulder_pitch_joint", + "left_shoulder_roll_joint", + "left_shoulder_yaw_joint", + "left_elbow_joint", + "left_wrist_roll_joint", + "left_wrist_pitch_joint", + "left_wrist_yaw_joint", + "right_shoulder_pitch_joint", + "right_shoulder_roll_joint", + "right_shoulder_yaw_joint", + "right_elbow_joint", + "right_wrist_roll_joint", + "right_wrist_pitch_joint", + "right_wrist_yaw_joint", + "left_hand_thumb_0_joint", + "left_hand_thumb_1_joint", + "left_hand_thumb_2_joint", + "left_hand_middle_0_joint", + "left_hand_middle_1_joint", + "left_hand_index_0_joint", + "left_hand_index_1_joint", + "right_hand_thumb_0_joint", + "right_hand_thumb_1_joint", + "right_hand_thumb_2_joint", + "right_hand_middle_0_joint", + "right_hand_middle_1_joint", + "right_hand_index_0_joint", + "right_hand_index_1_joint", +] +offset_dict = { + "left_elbow_joint": -0.3, + "right_elbow_joint": -0.3, +} + +HEALTHCARE_S3 = "https://omniverse-content-production.s3-us-west-2.amazonaws.com/Assets/Isaac/Healthcare/0.5.0/132c82d" +USD_ROOT = f"{HEALTHCARE_S3}/Props/LightWheel" + + +@configclass +class AssembleTrocarSceneCfg(InteractiveSceneCfg): + """Scene configuration for the assemble_trocar task (robot + objects + lights).""" + + # humanoid robot configuration + robot: ArticulationCfg = G1RobotPresets.g1_29dof_dex3_base_fix( + init_pos=(-1.84919, 1.94, 0.81168), init_rot=(0.0, 0.0, 0.0, 1.0) + ) + # add camera configuration + front_camera = CameraPresets.g1_front_camera() + left_wrist_camera = CameraPresets.left_dex3_wrist_camera() + right_wrist_camera = CameraPresets.right_dex3_wrist_camera() + + scene = AssetBaseCfg( + prim_path="/World/envs/env_.*/Scene", + spawn=UsdFileCfg( + usd_path=f"{USD_ROOT}/scene03.usd", + ), + ) + + trocar_1 = RigidObjectCfg( + prim_path="/World/envs/env_.*/trocar_1", + spawn=UsdFileCfg( + usd_path=f"{USD_ROOT}/Assets/Trocar002/Trocar002-xform-wo.usd", + collision_props=sim_utils.CollisionPropertiesCfg( + collision_enabled=True, + contact_offset=0.001, + rest_offset=-0.001, + ), + ), + init_state=RigidObjectCfg.InitialStateCfg( + pos=[-1.60202, 1.91362, 0.87183], + rot=[-0.0, 0.70711, 0.70711, 0.0], + ), + ) + + trocar_2 = RigidObjectCfg( + prim_path="/World/envs/env_.*/trocar_2", + spawn=UsdFileCfg( + usd_path=( + f"{USD_ROOT}/Assets/" + "DisposableLaparoscopicPunctureDevice001/" + "DisposableLaparoscopicPunctureDevice005-xform.usd" + ), + rigid_props=sim_utils.RigidBodyPropertiesCfg( + rigid_body_enabled=True, + disable_gravity=False, + ), + ), + init_state=RigidObjectCfg.InitialStateCfg( + rot=[-0.71475, -0.000243, 0.05853, 0.69692], pos=[-1.50635, 1.90997, 0.8631] + ), + ) + tray = ArticulationCfg( + prim_path="/World/envs/env_.*/surgical_tray", + spawn=UsdFileCfg( + usd_path=f"{USD_ROOT}/Assets/SurgicalTray001/SurgicalTray001.usd", + ), + init_state=ArticulationCfg.InitialStateCfg(pos=[-1.54919, 2.03365, 0.84554], rot=[0.0, 0.0, -0.70711, 0.70711]), + actuators={}, # Empty dict for passive articulation (no motors) + ) + + # Lights + light = AssetBaseCfg( + prim_path="/World/light", + spawn=sim_utils.DomeLightCfg( + color=(0.75, 0.75, 0.75), + intensity=1000.0, + ), + ) + + +## +# MDP settings +## +@configclass +class ActionsCfg: + """defines the action configuration related to robot control, using direct joint angle control""" + + joint_pos = mdp.JointPositionActionCfg( + asset_name="robot", + joint_names=joint_names, + scale=1.0, + use_default_offset=False, + offset=offset_dict, + preserve_order=True, + ) + + +@configclass +class ObservationsCfg: + """defines all available observation information""" + + @configclass + class PolicyCfg(ObsGroup): + """policy group observation configuration class + defines all state observation values for policy decision + inherit from ObsGroup base class + """ + + # robot joint state observation + robot_joint_state = ObsTerm(func=mdp.get_robot_body_joint_states) + # dex3 hand joint state observation + robot_dex3_joint_state = ObsTerm(func=mdp.get_robot_dex3_joint_states) + + def __post_init__(self): + """post initialization function + set the basic attributes of the observation group + """ + self.enable_corruption = False # disable observation value corruption + self.concatenate_terms = False # disable observation item connection + + @configclass + class CameraImagesCfg(ObsGroup): + """Observations from the robot's cameras.""" + + front_camera = ObsTerm( + func=base_mdp.image, + params={"sensor_cfg": SceneEntityCfg("front_camera"), "data_type": "rgb", "normalize": False}, + ) + left_wrist_camera = ObsTerm( + func=base_mdp.image, + params={"sensor_cfg": SceneEntityCfg("left_wrist_camera"), "data_type": "rgb", "normalize": False}, + ) + right_wrist_camera = ObsTerm( + func=base_mdp.image, + params={"sensor_cfg": SceneEntityCfg("right_wrist_camera"), "data_type": "rgb", "normalize": False}, + ) + + def __post_init__(self): + self.concatenate_terms = False + + # observation groups + # create policy observation group instance + policy: PolicyCfg = PolicyCfg() + camera_images: CameraImagesCfg = CameraImagesCfg() + + +@configclass +class TerminationsCfg: + """Termination conditions for the environment.""" + + # Time out termination + time_out = DoneTerm(func=mdp.time_out, time_out=True) + + # Task success termination (all stages completed) + task_success = DoneTerm( + func=mdp.task_success_termination, + time_out=False, # This is a success termination, not a failure + params={ + "print_log": False, + "success_stage": 4, + }, + ) + object_drop = DoneTerm( + func=mdp.object_drop_termination, + time_out=True, # Treat as timeout/failure + params={ + "drop_height_threshold": 0.5, # Objects below this Z height are considered dropped + "asset_cfg1": SceneEntityCfg("trocar_1"), + "asset_cfg2": SceneEntityCfg("trocar_2"), + }, + ) + + +@configclass +class RewardsCfg: + """Reward configuration for sparse reward mode. + + Each stage gives 1.0 reward on completion -> Total reward for full task = 4.0 + This ensures clear reward signal for each stage transition. + + ``update_stage`` runs first (weight=0) to advance the task stage before any + reward term reads it, removing implicit ordering dependencies. + """ + + # Stage machine — weight=0, runs before all reward terms to update task stage + update_stage = RewTerm( + func=mdp.update_task_stage, + weight=0.0, + params={ + "asset_cfg1": SceneEntityCfg("trocar_1"), + "asset_cfg2": SceneEntityCfg("trocar_2"), + "table_height": 0.85483, + "lift_threshold": 0.15, + "tip_align_threshold": 0.015, + "insertion_dist_threshold": 0.05, + "insertion_angle_threshold": 0.15, + "placement_x_min": -1.8, + "placement_x_max": -1.4, + "placement_y_min": 1.5, + "placement_y_max": 1.8, + "print_log": False, + }, + ) + + # Stage 0: Lift trocars + lift_trocars = RewTerm( + func=mdp.lift_trocars_reward, + weight=1.0, + params={ + "table_height": 0.85483, + "lift_threshold": 0.15, + "asset_cfg1": SceneEntityCfg("trocar_1"), + "asset_cfg2": SceneEntityCfg("trocar_2"), + "use_sparse_reward": True, + "print_log": False, + }, + ) + + # Stage 1: Tip alignment (find hole) + tip_alignment = RewTerm( + func=mdp.trocar_tip_alignment_reward, + weight=1.0, # Give 1.0 reward when stage 1->2 completes + params={ + "tip_dist_std": 0.02, # Std for tip distance reward shaping + "asset_cfg1": SceneEntityCfg("trocar_1"), + "asset_cfg2": SceneEntityCfg("trocar_2"), + "use_sparse_reward": True, + "print_log": False, + }, + ) + + # Stage 2: Insertion (push in) + insert_trocars = RewTerm( + func=mdp.trocar_insertion_reward, + weight=1.0, # Give 1.0 reward when stage 2->3 completes + params={ + "angle_std": 0.2, # Std for angle alignment reward + "angle_threshold": 0.10, # ~5.7 degrees tolerance for parallelism + "center_dist_std": 0.05, # Std for center distance reward + "asset_cfg1": SceneEntityCfg("trocar_1"), + "asset_cfg2": SceneEntityCfg("trocar_2"), + "use_sparse_reward": True, + "print_log": False, + }, + ) + + # Stage 3: Placement (place in tray) + placement_trocars = RewTerm( + func=mdp.trocar_placement_reward, + weight=1.0, # Give 1.0 reward when stage 3->4 completes + params={ + "x_min": -1.8, + "x_max": -1.4, + "y_min": 1.5, + "y_max": 1.8, + "asset_cfg1": SceneEntityCfg("trocar_1"), + "asset_cfg2": SceneEntityCfg("trocar_2"), + "use_sparse_reward": True, + "print_log": False, + }, + ) + + +@configclass +class EventCfg: + """Event configuration for scene reset.""" + + # Reset scene when episode terminates (timeout or success) + reset_scene = EventTermCfg(func=base_mdp.reset_scene_to_default, mode="reset") + + # Reset task stage tracker when environment resets + reset_task_stage = EventTermCfg(func=mdp.reset_task_stage, mode="reset") + + # Random rotation for tray and trocars + reset_tray_random_rotation = EventTermCfg( + func=mdp.reset_tray_with_random_rotation, + mode="reset", + params={ + "tray_cfg": SceneEntityCfg("tray"), + "trocar_1_cfg": SceneEntityCfg("trocar_1"), + "trocar_2_cfg": SceneEntityCfg("trocar_2"), + "rotation_range": [0, 10], + }, + ) + + +@configclass +class G1AssembleTrocarEnvCfg(ManagerBasedRLEnvCfg): + """Unitree G1 robot assemble trocar environment configuration class + inherits from ManagerBasedRLEnvCfg, defines all configuration parameters for the entire environment + """ + + # scene settings + scene: AssembleTrocarSceneCfg = AssembleTrocarSceneCfg( + num_envs=1, + env_spacing=6.0, + replicate_physics=True, + ) + # viewer settings + viewer: ViewerCfg = ViewerCfg( + eye=(-0.5, 2.4, 1.6), + lookat=(-5.4, 0.2, -1.2), + cam_prim_path="/OmniverseKit_Persp", + ) + # basic settings + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + # MDP settings + terminations: TerminationsCfg = TerminationsCfg() + events: EventCfg = EventCfg() + commands = None + rewards: RewardsCfg = RewardsCfg() + curriculum = None + + num_rerenders_on_reset: int = 1 + + def __post_init__(self): + """Post initialization.""" + # general settings + self.decimation = 4 + self.episode_length_s = 20.0 + # simulation settings + self.sim.dt = 1 / 200 + self.sim.render_interval = self.decimation + self.sim.physics = PhysxCfg(bounce_threshold_velocity=0.01) + self.sim.render.enable_translucency = True + self.sim.render.carb_settings = { + "rtx.raytracing.fractionalCutoutOpacity": True, + } + self.sim.render.rendering_mode = "quality" + self.sim.render.antialiasing_mode = "DLAA" + + +@configclass +class EventCfgFixTrayRotation(EventCfg): + """Event configuration with a deterministic-but-different yaw per env index. + + This is useful for eval with many parallel envs: + - env 0..N-1 get different yaw angles, + - for a fixed global seed, the set of N angles is reproducible across runs/resets. + + Notes: + - Determinism is tied to torch's global seed (set by env reset seed in IsaacLab). + - Angle unit is degrees. + """ + + reset_tray_random_rotation = EventTermCfg( + func=mdp.reset_tray_with_random_rotation, + mode="reset", + params={ + "tray_cfg": SceneEntityCfg("tray"), + "trocar_1_cfg": SceneEntityCfg("trocar_1"), + "trocar_2_cfg": SceneEntityCfg("trocar_2"), + "rotation_range": [0, 10], + "deterministic_per_env": True, + # Use torch.initial_seed() by default to follow the env reset seed. + "deterministic_seed": None, + }, + ) + + +@configclass +class G1AssembleTrocarEvalEnvCfg(G1AssembleTrocarEnvCfg): + """Eval-friendly env cfg. + + This is currently an alias of `G1AssembleTrocarEnvCfg`, but registered under a + separate Gym id for compatibility with RLinf configs. + """ + + # Override events to enforce deterministic per-env tray yaw on every reset. + events: EventCfgFixTrayRotation = EventCfgFixTrayRotation() diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/__init__.py new file mode 100644 index 000000000000..d428ed46f7b4 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/__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 + +"""MDP utilities for the assemble_trocar task.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/events.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/events.py new file mode 100644 index 000000000000..92214471ac09 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/events.py @@ -0,0 +1,253 @@ +# 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 + +"""Custom event functions for pick place surgical environment.""" + +from __future__ import annotations + +import logging +import math +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils.math import quat_apply, quat_mul + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + +logger = logging.getLogger(__name__) + +__all__ = [ + "reset_tray_with_random_rotation", + "reset_robot_to_default_joint_positions", + "reset_task_stage", +] + + +def reset_task_stage( + env: ManagerBasedRLEnv, + env_ids: torch.Tensor, + print_log: bool = False, +) -> None: + """Reset task stage to 0 for specified environments. + + This should be called during environment reset events. + Also resets all locked reward caches to maintain continuity. + + Args: + env: The environment instance + env_ids: Indices of environments to reset + print_log: If True, log debug information. + """ + from .rewards import get_assemble_trocar_state + + s = get_assemble_trocar_state(env) + s.task_stage[env_ids] = 0 + + # Reset dense-reward locked caches + s.lift_reward_locked[env_ids] = 0 + s.tip_reward_locked[env_ids] = 0 + s.insertion_reward_locked[env_ids] = 0 + s.placement_reward_locked[env_ids] = 0 + + # Reset sparse-reward previous-stage trackers + s.prev_stage_lift[env_ids] = 0 + s.prev_stage_tip[env_ids] = 0 + s.prev_stage_insert[env_ids] = 0 + s.prev_stage_place[env_ids] = 0 + + # Reset debug throttle + s.last_debug_print_step = -1 + + if print_log: + logger.debug("Reset task stage for %d environment(s)", len(env_ids)) + + +def reset_tray_with_random_rotation( + env: ManagerBasedRLEnv, + env_ids: torch.Tensor, + tray_cfg: SceneEntityCfg, + trocar_1_cfg: SceneEntityCfg, + trocar_2_cfg: SceneEntityCfg, + rotation_range: tuple[float, float] | float = (-5.0, 5.0), # (min, max) degrees or ±value + deterministic_per_env: bool = False, + deterministic_seed: int | None = None, +): + """Reset tray with random rotation while keeping relative positions of trocars. + + This function: + 1. Applies a random yaw rotation within rotation_range to the tray + 2. Rotates trocar_1 and trocar_2 around the tray center to maintain relative positions + 3. Uses separate pose/velocity writes to ensure instant teleportation (no interpolation) + + Args: + env: The environment instance. + env_ids: The environment indices to reset. + tray_cfg: Scene entity config for the tray. + trocar_1_cfg: Scene entity config for trocar_1. + trocar_2_cfg: Scene entity config for trocar_2. + rotation_range: Rotation angle range in degrees. Can be: + - tuple (min, max): Random rotation between min and max degrees + - float value: Random rotation between -value and +value degrees + Examples: (0, 10), (-5, 15), 5.0 (equivalent to (-5, 5)) + """ + if len(env_ids) == 0: + return + + # Parse rotation_range parameter + if isinstance(rotation_range, (tuple, list)): + # User provided (min, max) range + min_angle_deg, max_angle_deg = rotation_range[0], rotation_range[1] + else: + # User provided single value (symmetric range ±value) + min_angle_deg, max_angle_deg = -rotation_range, rotation_range + + # Get assets + tray = env.scene[tray_cfg.name] + trocar_1 = env.scene[trocar_1_cfg.name] + trocar_2 = env.scene[trocar_2_cfg.name] + + # Get default poses and velocities (local coordinates relative to env origin) + tray_default_pose = tray.data.default_root_pose.torch[env_ids].clone() + trocar_1_default_pose = trocar_1.data.default_root_pose.torch[env_ids].clone() + trocar_2_default_pose = trocar_2.data.default_root_pose.torch[env_ids].clone() + + env_origins = env.scene.env_origins[env_ids] # (num_envs, 3) + + # Convert local coordinate to world coordinate + tray_default_pose[:, :3] += env_origins + trocar_1_default_pose[:, :3] += env_origins + trocar_2_default_pose[:, :3] += env_origins + + # Tray center position (pivot point for rotation) - now in world coordinates + tray_center = tray_default_pose[:, :3] # (num_envs, 3) + + # Generate yaw angles (in radians) + # Convert degrees to radians + min_angle_rad = min_angle_deg * math.pi / 180.0 + max_angle_rad = max_angle_deg * math.pi / 180.0 + + # Generate angles uniformly distributed in [min_angle, max_angle] + if deterministic_per_env: + # Derive a stable "random" number per env id, so each env gets a distinct yaw, + # but it is repeatable across resets/runs given the same seed + env_id. + # + # If deterministic_seed is not provided, we tie it to torch's global seed. + # IsaacLab typically seeds torch during env reset with the provided seed. + if deterministic_seed is None: + deterministic_seed = int(torch.initial_seed()) + u = _deterministic_uniform_0_1_from_ids(env, env_ids, deterministic_seed) # (num_envs,) + else: + u = torch.rand(len(env_ids), device=env.device) + random_yaw = u * (max_angle_rad - min_angle_rad) + min_angle_rad # (num_envs,) + + # Create rotation quaternion for yaw (rotation around Z-axis) + # XYZW: quat = [x, y, z, w] = [0, 0, sin(θ/2), cos(θ/2)] + half_angle = random_yaw / 2.0 + delta_quat = torch.zeros(len(env_ids), 4, device=env.device) + delta_quat[:, 2] = torch.sin(half_angle) # z + delta_quat[:, 3] = torch.cos(half_angle) # w + + # Apply rotation to tray quaternion + tray_new_quat = quat_mul(delta_quat, tray_default_pose[:, 3:7]) + + # Update tray pose + tray_new_pose = tray_default_pose.clone() + tray_new_pose[:, 3:7] = tray_new_quat + + # Rotate trocar positions around tray center + trocar_1_relative_pos = trocar_1_default_pose[:, :3] - tray_center + trocar_2_relative_pos = trocar_2_default_pose[:, :3] - tray_center + + # Rotate relative positions using the delta quaternion + trocar_1_new_relative_pos = quat_apply(delta_quat, trocar_1_relative_pos) + trocar_2_new_relative_pos = quat_apply(delta_quat, trocar_2_relative_pos) + + # New absolute poses + trocar_1_new_pose = trocar_1_default_pose.clone() + trocar_2_new_pose = trocar_2_default_pose.clone() + + trocar_1_new_pose[:, :3] = tray_center + trocar_1_new_relative_pos + trocar_2_new_pose[:, :3] = tray_center + trocar_2_new_relative_pos + + # Also rotate trocar orientations + trocar_1_new_pose[:, 3:7] = quat_mul(delta_quat, trocar_1_default_pose[:, 3:7]) + trocar_2_new_pose[:, 3:7] = quat_mul(delta_quat, trocar_2_default_pose[:, 3:7]) + + zero_velocity = torch.zeros(len(env_ids), 6, device=env.device) # [lin_vel(3), ang_vel(3)] + + tray.write_root_pose_to_sim_index(root_pose=tray_new_pose, env_ids=env_ids) + trocar_1.write_root_pose_to_sim_index(root_pose=trocar_1_new_pose, env_ids=env_ids) + trocar_2.write_root_pose_to_sim_index(root_pose=trocar_2_new_pose, env_ids=env_ids) + + tray.write_root_velocity_to_sim_index(root_velocity=zero_velocity, env_ids=env_ids) + trocar_1.write_root_velocity_to_sim_index(root_velocity=zero_velocity, env_ids=env_ids) + trocar_2.write_root_velocity_to_sim_index(root_velocity=zero_velocity, env_ids=env_ids) + + +def _deterministic_uniform_0_1_from_ids( + env: ManagerBasedRLEnv, + ids: torch.Tensor, + seed: int, +) -> torch.Tensor: + """Deterministically map env ids -> floats in [0, 1) via a seeded lookup table. + + We generate a length-(env.num_envs) random table with a local torch.Generator + seeded by `seed`, then return table[ids]. This is deterministic and avoids + uint64 bitwise ops (which may not be supported on CPU). + """ + device = env.device + num_envs = int(env.num_envs) + seed = int(seed) + + cache = getattr(env, "_deterministic_u_table_cache", None) + cache_key = (seed, num_envs, str(device)) + if cache is None or cache.get("key") != cache_key: + gen = torch.Generator(device=device) + gen.manual_seed(seed & 0xFFFFFFFFFFFFFFFF) + u_table = torch.rand((num_envs,), generator=gen, device=device, dtype=torch.float32) + cache = {"key": cache_key, "u_table": u_table} + setattr(env, "_deterministic_u_table_cache", cache) + + return cache["u_table"][ids] + + +def reset_robot_to_default_joint_positions( + env: ManagerBasedRLEnv, + env_ids: torch.Tensor, + robot_cfg: SceneEntityCfg, +): + """Reset robot joint positions directly to default values. + + This function directly writes joint positions and velocities to the simulation, + bypassing the PD controller. This prevents the "drive to target" behavior + that causes arms to swing from 0 position to the target position. + + Args: + env: The environment instance. + env_ids: The environment indices to reset. + robot_cfg: Scene entity config for the robot. + """ + if len(env_ids) == 0: + return + + # Get robot asset + robot = env.scene[robot_cfg.name] + + # Get default joint positions and velocities + default_joint_pos = robot.data.default_joint_pos.torch[env_ids].clone() + default_joint_vel = robot.data.default_joint_vel.torch[env_ids].clone() + + # Directly write joint state to simulation (bypasses PD controller) + robot.write_joint_position_to_sim_index(position=default_joint_pos, env_ids=env_ids) + robot.write_joint_velocity_to_sim_index(velocity=default_joint_vel, env_ids=env_ids) + + # Also reset root pose and velocity + default_root_pose = robot.data.default_root_pose.torch[env_ids].clone() + default_root_vel = robot.data.default_root_vel.torch[env_ids].clone() + robot.write_root_pose_to_sim_index(root_pose=default_root_pose, env_ids=env_ids) + robot.write_root_velocity_to_sim_index(root_velocity=default_root_vel, env_ids=env_ids) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/observations.py new file mode 100644 index 000000000000..06c037ba3d68 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/observations.py @@ -0,0 +1,119 @@ +# 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 + +""" +G1 29DOF (body) + Dex3 joint state helpers for the assemble_trocar task. + +Notes: +- DDS has been removed (simulation-only observations). +- These functions are designed to be used as Isaac Lab observation terms. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab_tasks.manager_based.manipulation.assemble_trocar.config import ( + G1_29DOF_BODY_JOINT_INDICES, + G1_DEX3_JOINT_INDICES, +) + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + + +# Observation cache: index tensors + preallocated output buffers (body joints) +_body_obs_cache = { + "device": None, + "batch": None, + "idx_t": None, + "idx_batch": None, + "pos_buf": None, + "vel_buf": None, + "torque_buf": None, + "combined_buf": None, +} + + +def get_robot_body_joint_states(env: ManagerBasedRLEnv) -> torch.Tensor: + """Return body joint states as a single tensor: [pos(29) | vel(29) | torque(29)].""" + robot_data = env.scene["robot"].data + joint_pos = robot_data.joint_pos.torch + joint_vel = robot_data.joint_vel.torch + joint_torque = robot_data.applied_torque.torch + device = joint_pos.device + batch = joint_pos.shape[0] + + global _body_obs_cache + if _body_obs_cache["device"] != device or _body_obs_cache["idx_t"] is None: + _body_obs_cache["idx_t"] = torch.tensor(G1_29DOF_BODY_JOINT_INDICES, dtype=torch.long, device=device) + _body_obs_cache["device"] = device + _body_obs_cache["batch"] = None + + idx_t = _body_obs_cache["idx_t"] + n = idx_t.numel() + + if _body_obs_cache["batch"] != batch or _body_obs_cache["idx_batch"] is None: + _body_obs_cache["idx_batch"] = idx_t.unsqueeze(0).expand(batch, n) + _body_obs_cache["pos_buf"] = torch.empty(batch, n, device=device, dtype=joint_pos.dtype) + _body_obs_cache["vel_buf"] = torch.empty(batch, n, device=device, dtype=joint_pos.dtype) + _body_obs_cache["torque_buf"] = torch.empty(batch, n, device=device, dtype=joint_pos.dtype) + _body_obs_cache["combined_buf"] = torch.empty(batch, n * 3, device=device, dtype=joint_pos.dtype) + _body_obs_cache["batch"] = batch + + idx_batch = _body_obs_cache["idx_batch"] + pos_buf = _body_obs_cache["pos_buf"] + vel_buf = _body_obs_cache["vel_buf"] + torque_buf = _body_obs_cache["torque_buf"] + combined_buf = _body_obs_cache["combined_buf"] + + torch.gather(joint_pos, 1, idx_batch, out=pos_buf) + torch.gather(joint_vel, 1, idx_batch, out=vel_buf) + torch.gather(joint_torque, 1, idx_batch, out=torque_buf) + + combined_buf[:, 0:n].copy_(pos_buf) + combined_buf[:, n : 2 * n].copy_(vel_buf) + combined_buf[:, 2 * n : 3 * n].copy_(torque_buf) + return combined_buf + + +# Observation cache: index tensors + preallocated output buffers (Dex3 hand joints) +_dex3_obs_cache = { + "device": None, + "batch": None, + "idx_t": None, + "idx_batch": None, + "pos_buf": None, +} + + +def get_robot_dex3_joint_states(env: ManagerBasedRLEnv) -> torch.Tensor: + """Return Dex3 joint positions [batch, 14].""" + joint_pos = env.scene["robot"].data.joint_pos.torch + device = joint_pos.device + batch = joint_pos.shape[0] + + global _dex3_obs_cache + if _dex3_obs_cache["device"] != device or _dex3_obs_cache["idx_t"] is None: + _dex3_obs_cache["idx_t"] = torch.tensor(G1_DEX3_JOINT_INDICES, dtype=torch.long, device=device) + _dex3_obs_cache["device"] = device + _dex3_obs_cache["batch"] = None + + idx_t = _dex3_obs_cache["idx_t"] + n = idx_t.numel() + + if _dex3_obs_cache["batch"] != batch or _dex3_obs_cache["idx_batch"] is None: + _dex3_obs_cache["idx_batch"] = idx_t.unsqueeze(0).expand(batch, n) + _dex3_obs_cache["pos_buf"] = torch.empty(batch, n, device=device, dtype=joint_pos.dtype) + _dex3_obs_cache["batch"] = batch + + idx_batch = _dex3_obs_cache["idx_batch"] + pos_buf = _dex3_obs_cache["pos_buf"] + + torch.gather(joint_pos, 1, idx_batch, out=pos_buf) + + return pos_buf diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/rewards.py new file mode 100644 index 000000000000..504d9caba67d --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/rewards.py @@ -0,0 +1,634 @@ +# 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 +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import torch + +from isaaclab.assets import RigidObject +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils.math import quat_apply + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + +logger = logging.getLogger(__name__) + +__all__ = [ + "AssembleTrocarState", + "update_task_stage", + "lift_trocars_reward", + "trocar_tip_alignment_reward", + "trocar_insertion_reward", + "trocar_placement_reward", +] + + +@dataclass +class AssembleTrocarState: + """Namespaced task state for the assemble-trocar environment. + + Holds per-env stage tracking, reward caches, and debug bookkeeping. + Attached to the env as ``env.assemble_trocar_state`` and initialised + lazily on first access via :func:`get_assemble_trocar_state`. + + Stage semantics: + 0 - Initial (need to lift) + 1 - Lifted (need to find hole / tip alignment) + 2 - Hole found (need to insert / push in) + 3 - Inserted (need to place) + 4 - Placed (task complete) + """ + + task_stage: torch.Tensor = field(default_factory=lambda: torch.empty(0)) + # Sparse-reward previous-stage trackers (one per reward term) + prev_stage_lift: torch.Tensor = field(default_factory=lambda: torch.empty(0)) + prev_stage_tip: torch.Tensor = field(default_factory=lambda: torch.empty(0)) + prev_stage_insert: torch.Tensor = field(default_factory=lambda: torch.empty(0)) + prev_stage_place: torch.Tensor = field(default_factory=lambda: torch.empty(0)) + # Dense-reward locked caches + lift_reward_locked: torch.Tensor = field(default_factory=lambda: torch.empty(0)) + tip_reward_locked: torch.Tensor = field(default_factory=lambda: torch.empty(0)) + insertion_reward_locked: torch.Tensor = field(default_factory=lambda: torch.empty(0)) + placement_reward_locked: torch.Tensor = field(default_factory=lambda: torch.empty(0)) + # Cached tip offsets (populated on first call to get_trocar_tip_position) + tip_offset_trocar_1: torch.Tensor | None = None + tip_offset_trocar_2: torch.Tensor | None = None + # Debug throttle + last_debug_print_step: int = -1 + + +def get_assemble_trocar_state(env: ManagerBasedRLEnv) -> AssembleTrocarState: + """Get or lazily initialise the :class:`AssembleTrocarState` on *env*.""" + if not hasattr(env, "assemble_trocar_state"): + s = AssembleTrocarState( + task_stage=torch.zeros(env.num_envs, dtype=torch.long, device=env.device), + prev_stage_lift=torch.zeros(env.num_envs, dtype=torch.long, device=env.device), + prev_stage_tip=torch.zeros(env.num_envs, dtype=torch.long, device=env.device), + prev_stage_insert=torch.zeros(env.num_envs, dtype=torch.long, device=env.device), + prev_stage_place=torch.zeros(env.num_envs, dtype=torch.long, device=env.device), + lift_reward_locked=torch.zeros(env.num_envs, device=env.device), + tip_reward_locked=torch.zeros(env.num_envs, device=env.device), + insertion_reward_locked=torch.zeros(env.num_envs, device=env.device), + placement_reward_locked=torch.zeros(env.num_envs, device=env.device), + ) + env.assemble_trocar_state = s + return env.assemble_trocar_state + + +def get_task_stage(env: ManagerBasedRLEnv) -> torch.Tensor: + """Return the current per-env task stage tensor.""" + return get_assemble_trocar_state(env).task_stage + + +def should_print_debug(env: ManagerBasedRLEnv, print_interval: int = 50, print_log: bool = True) -> bool: + """Check if debug info should be logged based on episode step counter.""" + if not print_log: + return False + if not hasattr(env, "episode_length_buf"): + return False + + current_step = env.episode_length_buf[0].item() + if current_step == 0 or current_step % print_interval != 0: + return False + + state = get_assemble_trocar_state(env) + if state.last_debug_print_step == current_step: + return False + + state.last_debug_print_step = current_step + return True + + +def update_task_stage( + env: ManagerBasedRLEnv, + asset_cfg1: SceneEntityCfg, + asset_cfg2: SceneEntityCfg, + table_height: float = 0.85483, + lift_threshold: float = 0.05, + tip_align_threshold: float = 0.015, + insertion_dist_threshold: float = 0.03, + insertion_angle_threshold: float = 0.15, + placement_x_min: float = -1.8, + placement_x_max: float = -1.4, + placement_y_min: float = 1.5, + placement_y_max: float = 1.8, + placement_z_min: float = 0.9, + print_log: bool = False, +) -> torch.Tensor: + """Update task stage based on current state. + + This function checks conditions and advances stages automatically. + Once a stage is completed, it never goes back. + Returns a zero-valued tensor (num_envs,) so it can be used as a + weight=0 reward term to run before the actual reward terms. + """ + state = get_assemble_trocar_state(env) + stage = state.task_stage + + obj1: RigidObject = env.scene[asset_cfg1.name] + obj2: RigidObject = env.scene[asset_cfg2.name] + + pos1 = obj1.data.root_pos_w.torch + pos2 = obj2.data.root_pos_w.torch + quat1 = obj1.data.root_quat_w.torch + quat2 = obj2.data.root_quat_w.torch + # Store old stage to detect changes (BEFORE any stage transitions) + old_stage = stage.clone() + + # Stage 0 -> 1: Check if lifted + target_z = table_height + lift_threshold + is_lifted_1 = pos1[:, 2] > target_z + is_lifted_2 = pos2[:, 2] > target_z + both_lifted = is_lifted_1 & is_lifted_2 + stage = torch.where((stage == 0) & both_lifted, torch.ones_like(stage), stage) + + # Stage 1 -> 2: Check if tips are aligned (hole found) + # Get tip positions + tip_pos1 = get_trocar_tip_position(env, asset_cfg1) + tip_pos2 = get_trocar_tip_position(env, asset_cfg2) + tip_dist = torch.norm(tip_pos1 - tip_pos2, dim=-1) + + # Tip alignment success + tip_aligned = tip_dist < tip_align_threshold + stage = torch.where((stage == 1) & tip_aligned, torch.full_like(stage, 2), stage) + + # Stage 2 -> 3: Check if inserted (parallel + center close) + # Get center distance + center_dist = torch.norm(pos1 - pos2, dim=-1) + + # Check alignment + target_axis1 = torch.tensor([0.0, 0.0, -1.0], device=env.device).repeat(env.num_envs, 1) + target_axis2 = torch.tensor([0.0, 0.0, -1.0], device=env.device).repeat(env.num_envs, 1) + axis1 = quat_apply(quat1, target_axis1) + axis2 = quat_apply(quat2, target_axis2) + dot_prod = torch.sum(axis1 * axis2, dim=-1) + abs_dot = torch.clamp(torch.abs(dot_prod), max=1.0) + angle = torch.acos(abs_dot) + + # Insertion success: parallel + center close + is_parallel = angle < insertion_angle_threshold + center_close = center_dist < insertion_dist_threshold + is_inserted = is_parallel & center_close + + stage = torch.where((stage == 2) & is_inserted, torch.full_like(stage, 3), stage) + + # Stage 3 -> 4: Check if placed in target zone + # Get environment origins to handle multi-env spatial offsets + env_origins = env.scene.env_origins # shape: (num_envs, 3) + + # Adjust target zone relative to each environment's origin + curr_x_min = env_origins[:, 0] + min(placement_x_min, placement_x_max) # (num_envs,) + curr_x_max = env_origins[:, 0] + max(placement_x_min, placement_x_max) + curr_y_min = env_origins[:, 1] + min(placement_y_min, placement_y_max) + curr_y_max = env_origins[:, 1] + max(placement_y_min, placement_y_max) + + in_zone_1 = ( + (pos1[:, 0] >= curr_x_min) + & (pos1[:, 0] <= curr_x_max) + & (pos1[:, 1] >= curr_y_min) + & (pos1[:, 1] <= curr_y_max) + & (pos1[:, 2] < placement_z_min) + ) + in_zone_2 = ( + (pos2[:, 0] >= curr_x_min) + & (pos2[:, 0] <= curr_x_max) + & (pos2[:, 1] >= curr_y_min) + & (pos2[:, 1] <= curr_y_max) + & (pos2[:, 2] < placement_z_min) + ) + both_in_zone = in_zone_1 & in_zone_2 + stage = torch.where((stage == 3) & both_in_zone, torch.full_like(stage, 4), stage) + + # Print stage transitions (AFTER all stage transitions - always print when stage changes) + if print_log and (stage != old_stage).any(): + for env_id in range(env.num_envs): + if stage[env_id] != old_stage[env_id]: + logger.debug("Env %d: Stage %d → %d", env_id, old_stage[env_id].item(), stage[env_id].item()) + + state.task_stage = stage + return torch.zeros(env.num_envs, device=env.device) + + +def lift_trocars_reward( + env: ManagerBasedRLEnv, + table_height: float = 0.85483, + lift_threshold: float = 0.05, + asset_cfg1: SceneEntityCfg = SceneEntityCfg("trocar_1"), + asset_cfg2: SceneEntityCfg = SceneEntityCfg("trocar_2"), + use_sparse_reward: bool = True, + print_log: bool = False, +) -> torch.Tensor: + """Reward for lifting both trocars above the table. + + Only active in Stage 0. Once completed, this reward is locked at the achieved value. + + Args: + use_sparse_reward: If True, only give reward (1.0) when stage transitions from 0->1. + If False, give continuous reward based on current state. + print_log: If True, log debug information. + """ + s = get_assemble_trocar_state(env) + stage = s.task_stage + + obj1: RigidObject = env.scene[asset_cfg1.name] + obj2: RigidObject = env.scene[asset_cfg2.name] + + pos1 = obj1.data.root_pos_w.torch + pos2 = obj2.data.root_pos_w.torch + target_z = table_height + lift_threshold + + is_lifted_1 = pos1[:, 2] > target_z + is_lifted_2 = pos2[:, 2] > target_z + both_lifted = is_lifted_1 & is_lifted_2 + + if use_sparse_reward: + stage_just_completed = (s.prev_stage_lift == 0) & (stage >= 1) + reward = torch.where( + stage_just_completed, + torch.ones(env.num_envs, device=env.device) / env.step_dt, + torch.zeros(env.num_envs, device=env.device), + ) + s.prev_stage_lift = stage.clone() + else: + current_reward = both_lifted.float() + s.lift_reward_locked = torch.where( + (stage >= 1) & (s.lift_reward_locked == 0), + current_reward, + s.lift_reward_locked, + ) + reward = torch.where(stage == 0, current_reward, s.lift_reward_locked) + + if should_print_debug(env, print_log=print_log): + mode_str = "Sparse" if use_sparse_reward else "Dense" + logger.debug( + " Stage: %d | Lift (%s): %.2f | z1: %.3f | z2: %.3f", + stage[0].item(), + mode_str, + reward[0].item(), + pos1[0, 2], + pos2[0, 2], + ) + + return reward + + +def get_trocar_tip_position( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("trocar_1"), +) -> torch.Tensor: + """Get trocar tip position (White_pos or Red_pos) in world coordinates. + + Calculates tip world position using trocar root's dynamic position and rotation, + plus the tip's relative offset. + + Args: + env: Environment instance + asset_cfg: Trocar asset configuration (trocar_1 or trocar_2) + + Returns: + torch.Tensor: Shape (num_envs, 3) - Position in world coordinates + """ + from pxr import Gf, Usd, UsdGeom + + import isaaclab.utils.math as math_utils + + # Cache the tip offset to avoid recalculating every step. + # The local offset from root to tip is a static geometric property of the USD + # asset and is identical across all replicated envs. We read it once from env_0's + # USD prim, then apply it per-env at runtime using each env's dynamic root pose. + s = get_assemble_trocar_state(env) + cache_attr = f"tip_offset_{asset_cfg.name}" + tip_offset_local = getattr(s, cache_attr, None) + + if tip_offset_local is None: + usd_stage = env.scene.stage + + if asset_cfg.name == "trocar_1": + tip_path = "/World/envs/env_0/trocar_1/Trocar002/White_pos" + root_path = "/World/envs/env_0/trocar_1" + elif asset_cfg.name == "trocar_2": + tip_path = "/World/envs/env_0/trocar_2/DisposableLaparoscopicPunctureDevice001/Red_pos" + root_path = "/World/envs/env_0/trocar_2" + else: + raise ValueError(f"Invalid asset configuration: {asset_cfg.name}") + + tip_prim = usd_stage.GetPrimAtPath(tip_path) + root_prim = usd_stage.GetPrimAtPath(root_path) + + if not tip_prim.IsValid(): + logger.warning("Tip prim not found at %s, using zero offset", tip_path) + tip_offset_local = torch.zeros(3, dtype=torch.float32, device=env.device) + else: + tip_xform = UsdGeom.Xformable(tip_prim) + root_xform = UsdGeom.Xformable(root_prim) + + tip_world_transform = tip_xform.ComputeLocalToWorldTransform(Usd.TimeCode.Default()) + root_world_transform = root_xform.ComputeLocalToWorldTransform(Usd.TimeCode.Default()) + + tip_world_pos = tip_world_transform.ExtractTranslation() + root_world_pos = root_world_transform.ExtractTranslation() + + root_rotation_mat = root_world_transform.ExtractRotationMatrix() + root_rotation_quat = root_rotation_mat.ExtractRotation().GetQuat() + + tip_offset_world = Gf.Vec3d( + tip_world_pos[0] - root_world_pos[0], + tip_world_pos[1] - root_world_pos[1], + tip_world_pos[2] - root_world_pos[2], + ) + + root_quat_inv = root_rotation_quat.GetInverse() + tip_offset_local_gf = root_quat_inv.Transform(tip_offset_world) + + tip_offset_local = torch.tensor( + [tip_offset_local_gf[0], tip_offset_local_gf[1], tip_offset_local_gf[2]], + dtype=torch.float32, + device=env.device, + ) + + logger.debug("Cached tip offset for %s: %s", asset_cfg.name, tip_offset_local) + + setattr(s, cache_attr, tip_offset_local) + + obj: RigidObject = env.scene[asset_cfg.name] + root_pos_w = obj.data.root_pos_w.torch # Shape: (num_envs, 3) + root_quat_w = obj.data.root_quat_w.torch # Shape: (num_envs, 4) XYZW + + tip_offset_local_batch = tip_offset_local.unsqueeze(0).repeat(env.num_envs, 1) + + tip_offset_world = math_utils.quat_apply(root_quat_w, tip_offset_local_batch) + tip_pos_world = root_pos_w + tip_offset_world + + return tip_pos_world # Shape: (num_envs, 3) + + +def trocar_tip_alignment_reward( + env: ManagerBasedRLEnv, + tip_dist_std: float = 0.02, # Std for tip distance reward + asset_cfg1: SceneEntityCfg = SceneEntityCfg("trocar_1"), + asset_cfg2: SceneEntityCfg = SceneEntityCfg("trocar_2"), + use_sparse_reward: bool = True, + print_log: bool = False, +) -> torch.Tensor: + """Reward for aligning trocar tips (Stage 1: Finding the hole). + + Reward based on tip distance - encourages bringing tips close together. + + Only active in Stage 1. Once completed (stage >= 2), this reward is locked at the achieved value. + + Args: + env: Environment instance + tip_dist_std: Standard deviation for tip distance reward shaping + asset_cfg1: Configuration for trocar 1 + asset_cfg2: Configuration for trocar 2 + use_sparse_reward: If True, only give reward (1.0) when stage >= 2. + If False, give continuous reward based on tip distance. + print_log: If True, print debug information. + + Returns: + torch.Tensor: Reward tensor (num_envs,) + """ + s = get_assemble_trocar_state(env) + stage = s.task_stage + + tip_pos1 = get_trocar_tip_position(env, asset_cfg1) + tip_pos2 = get_trocar_tip_position(env, asset_cfg2) + tip_dist = torch.norm(tip_pos1 - tip_pos2, dim=-1) + + if use_sparse_reward: + stage_just_completed = (s.prev_stage_tip == 1) & (stage >= 2) + reward = torch.where( + stage_just_completed, + torch.ones(env.num_envs, device=env.device) / env.step_dt, + torch.zeros(env.num_envs, device=env.device), + ) + s.prev_stage_tip = stage.clone() + else: + tip_reward = torch.exp(-torch.square(tip_dist) / (2 * tip_dist_std**2)) + s.tip_reward_locked = torch.where( + (stage >= 2) & (s.tip_reward_locked == 0), + tip_reward, + s.tip_reward_locked, + ) + reward = torch.where( + stage < 1, + torch.zeros(env.num_envs, device=env.device), + torch.where(stage == 1, tip_reward, s.tip_reward_locked), + ) + + # Debug info + if should_print_debug(env, print_log=print_log) and stage[0].item() == 1: + mode_str = "Sparse" if use_sparse_reward else "Dense" + logger.debug( + " Stage 1 (Find Hole, %s): tip_pos_1=(%.3f, %.3f, %.3f)" + " | tip_pos_2=(%.3f, %.3f, %.3f) | tip_d=%.4f | reward=%.3f", + mode_str, + tip_pos1[0, 0], + tip_pos1[0, 1], + tip_pos1[0, 2], + tip_pos2[0, 0], + tip_pos2[0, 1], + tip_pos2[0, 2], + tip_dist[0].item(), + reward[0].item(), + ) + + return reward + + +def trocar_insertion_reward( + env: ManagerBasedRLEnv, + angle_std: float = 0.2, # Std for angle alignment reward + angle_threshold: float = 0.15, # Tolerance for parallelism (radians) + center_dist_std: float = 0.05, # Std for center distance reward + asset_cfg1: SceneEntityCfg = SceneEntityCfg("trocar_1"), + asset_cfg2: SceneEntityCfg = SceneEntityCfg("trocar_2"), + use_sparse_reward: bool = True, + print_log: bool = False, +) -> torch.Tensor: + """Reward for inserting trocar_2 into trocar_1 (Stage 2: Pushing in). + + Reward based on: + 1. Orientation alignment (parallelism) + 2. Center distance (pushing in) + + Only active in Stage 2. Once completed (stage >= 3), this reward is locked at the achieved value. + + Args: + env: Environment instance + angle_std: Standard deviation for angle reward shaping + angle_threshold: Angle threshold for parallelism (radians) + center_dist_std: Standard deviation for center distance reward shaping + asset_cfg1: Configuration for trocar 1 + asset_cfg2: Configuration for trocar 2 + use_sparse_reward: If True, only give reward (1.0) when stage >= 3. + If False (default), give continuous reward based on alignment and distance. + print_log: If True, print debug information. + Returns: + torch.Tensor: Reward tensor (num_envs,) + """ + s = get_assemble_trocar_state(env) + stage = s.task_stage + + obj1: RigidObject = env.scene[asset_cfg1.name] + obj2: RigidObject = env.scene[asset_cfg2.name] + + pos1 = obj1.data.root_pos_w.torch + quat1 = obj1.data.root_quat_w.torch + pos2 = obj2.data.root_pos_w.torch + quat2 = obj2.data.root_quat_w.torch + center_dist = torch.norm(pos1 - pos2, dim=-1) + + target_axis1 = torch.tensor([0.0, 0.0, -1.0], device=env.device).repeat(env.num_envs, 1) + target_axis2 = torch.tensor([0.0, 0.0, -1.0], device=env.device).repeat(env.num_envs, 1) + + axis1 = quat_apply(quat1, target_axis1) + axis2 = quat_apply(quat2, target_axis2) + + dot_prod = torch.sum(axis1 * axis2, dim=-1) + abs_dot = torch.clamp(torch.abs(dot_prod), max=1.0) + angle = torch.acos(abs_dot) + is_parallel = angle < angle_threshold + + if use_sparse_reward: + stage_just_completed = (s.prev_stage_insert == 2) & (stage >= 3) + reward = torch.where( + stage_just_completed, + torch.ones(env.num_envs, device=env.device) / env.step_dt, + torch.zeros(env.num_envs, device=env.device), + ) + s.prev_stage_insert = stage.clone() + else: + excess_angle = torch.clamp(angle - angle_threshold, min=0.0) + align_reward = torch.exp(-torch.square(excess_angle) / (2 * angle_std**2)) + center_reward = torch.exp(-torch.square(center_dist) / (2 * center_dist_std**2)) + center_reward = torch.where(is_parallel, center_reward, torch.zeros_like(center_reward)) + insertion_reward = align_reward * center_reward + + s.insertion_reward_locked = torch.where( + (stage >= 3) & (s.insertion_reward_locked == 0), + insertion_reward, + s.insertion_reward_locked, + ) + reward = torch.where( + stage < 2, + torch.zeros(env.num_envs, device=env.device), + torch.where(stage == 2, insertion_reward, s.insertion_reward_locked), + ) + + # Debug info + if should_print_debug(env, print_log=print_log) and stage[0].item() == 2: + mode_str = "Sparse" if use_sparse_reward else "Dense" + logger.debug( + " Stage 2 (Push In, %s): angle=%.3f | center_d=%.4f | is_parallel=%s | reward=%.3f", + mode_str, + angle[0].item(), + center_dist[0].item(), + is_parallel[0].item(), + reward[0].item(), + ) + + return reward + + +def trocar_placement_reward( + env: ManagerBasedRLEnv, + x_min: float = -1.8, + x_max: float = -1.4, + y_min: float = 1.5, + y_max: float = 1.8, + z_min: float = 0.9, + asset_cfg1: SceneEntityCfg = SceneEntityCfg("trocar_1"), + asset_cfg2: SceneEntityCfg = SceneEntityCfg("trocar_2"), + use_sparse_reward: bool = True, + print_log: bool = False, +) -> torch.Tensor: + """Reward for placing both trocars in the target tray region (Stage 3). + + Only active in Stage 3. Once completed (stage >= 4), this reward is locked at the achieved value. + + Args: + env: Environment instance + x_min, x_max: X bounds of target zone (relative to env origin) + y_min, y_max: Y bounds of target zone (relative to env origin) + z_min: Z threshold (below this is considered placed) + asset_cfg1: Configuration for trocar 1 + asset_cfg2: Configuration for trocar 2 + use_sparse_reward: If True, only give reward (1.0) when stage >= 4. + If False (default), give continuous reward based on placement status. + print_log: If True, print debug information. + + Returns: + torch.Tensor: Reward tensor (num_envs,) + """ + s = get_assemble_trocar_state(env) + stage = s.task_stage + + obj1: RigidObject = env.scene[asset_cfg1.name] + obj2: RigidObject = env.scene[asset_cfg2.name] + + pos1 = obj1.data.root_pos_w.torch + pos2 = obj2.data.root_pos_w.torch + env_origins = env.scene.env_origins + + curr_x_min = env_origins[:, 0] + min(x_min, x_max) + curr_x_max = env_origins[:, 0] + max(x_min, x_max) + curr_y_min = env_origins[:, 1] + min(y_min, y_max) + curr_y_max = env_origins[:, 1] + max(y_min, y_max) + + in_zone_1 = ( + (pos1[:, 0] >= curr_x_min) + & (pos1[:, 0] <= curr_x_max) + & (pos1[:, 1] >= curr_y_min) + & (pos1[:, 1] <= curr_y_max) + & (pos1[:, 2] < z_min) + ) + in_zone_2 = ( + (pos2[:, 0] >= curr_x_min) + & (pos2[:, 0] <= curr_x_max) + & (pos2[:, 1] >= curr_y_min) + & (pos2[:, 1] <= curr_y_max) + & (pos2[:, 2] < z_min) + ) + both_in_zone = in_zone_1 & in_zone_2 + + if use_sparse_reward: + stage_just_completed = (s.prev_stage_place == 3) & (stage >= 4) + reward = torch.where( + stage_just_completed, + torch.ones(env.num_envs, device=env.device) / env.step_dt, + torch.zeros(env.num_envs, device=env.device), + ) + s.prev_stage_place = stage.clone() + else: + placement_reward = both_in_zone.float() + s.placement_reward_locked = torch.where( + (stage >= 4) & (s.placement_reward_locked == 0), + placement_reward, + s.placement_reward_locked, + ) + reward = torch.where( + stage < 3, + torch.zeros(env.num_envs, device=env.device), + torch.where(stage == 3, placement_reward, s.placement_reward_locked), + ) + + # Debug info + if should_print_debug(env, print_log=print_log) and stage[0].item() == 3: + mode_str = "Sparse" if use_sparse_reward else "Dense" + logger.debug( + " Stage 3 (Placement, %s): in_zone=%s | z1=%.3f | z2=%.3f", + mode_str, + both_in_zone[0].item(), + pos1[0, 2], + pos2[0, 2], + ) + + return reward diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/terminations.py new file mode 100644 index 000000000000..12b70ae473bd --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/terminations.py @@ -0,0 +1,80 @@ +# 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 +from typing import TYPE_CHECKING + +import torch + +from isaaclab.assets import RigidObject +from isaaclab.managers import SceneEntityCfg + +from .rewards import get_task_stage + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + +logger = logging.getLogger(__name__) + + +def object_drop_termination( + env: ManagerBasedRLEnv, + drop_height_threshold: float = 0.5, + asset_cfg1: SceneEntityCfg = SceneEntityCfg("trocar_1"), + asset_cfg2: SceneEntityCfg = SceneEntityCfg("trocar_2"), + print_log: bool = False, +) -> torch.Tensor: + """Termination function that triggers when objects drop below threshold. + + This can be used as an alternative to auto-reset, marking the episode as terminated + so the training framework handles the reset. + + Args: + env: The environment instance + drop_height_threshold: Height below which objects are considered dropped + asset_cfg1: Configuration for first trocar + asset_cfg2: Configuration for second trocar + print_log: If True, print debug information. + Returns: + Boolean tensor indicating which environments should terminate due to drops + """ + # Get rigid objects + obj1: RigidObject = env.scene[asset_cfg1.name] + obj2: RigidObject = env.scene[asset_cfg2.name] + + # Get positions + pos1 = obj1.data.root_pos_w.torch + pos2 = obj2.data.root_pos_w.torch + # Check if either object has dropped + dropped_1 = pos1[:, 2] < drop_height_threshold + dropped_2 = pos2[:, 2] < drop_height_threshold + + dropped = dropped_1 | dropped_2 + + if print_log and dropped.any(): + logger.debug("Drop termination triggered for %d environment(s)", dropped.sum().item()) + + return dropped + + +def task_success_termination( + env: ManagerBasedRLEnv, + success_stage: int = 4, + print_log: bool = False, +) -> torch.Tensor: + """Termination condition: task is complete when stage reaches 4. + + Returns: + torch.Tensor: Boolean tensor indicating which environments should terminate (num_envs,) + """ + stage = get_task_stage(env) + task_complete = stage >= success_stage + + if print_log and task_complete.any(): + logger.info("Task completed in %d environment(s)!", task_complete.sum().item()) + + return task_complete From d9bdc7f43270a1f91752d745afa875a27fecabfb Mon Sep 17 00:00:00 2001 From: vidurv-nvidia Date: Mon, 11 May 2026 20:01:55 -0700 Subject: [PATCH 040/133] Refactors the doc on Schema for Lab 3.0, and adds MuJoCo gravcomp (#5276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds Newton-native and MuJoCo-specific schema cfg classes to `isaaclab_newton.sim.schemas`, following the base/subclass framework from #5275. All new cfgs use the per-declaring-class MRO routing in `_apply_namespaced_schemas` — no backend-specific branching in any writer. Depends on #5275. ## New cfgs ### MuJoCo (Newton MuJoCo kernel, `mjc:*` namespace) | Class | Field | USD attribute | Applied schema | |---|---|---|---| | `MujocoRigidBodyPropertiesCfg` | `gravcomp` | `mjc:gravcomp` | None (raw attr) | | `MujocoJointDrivePropertiesCfg` | `actuatorgravcomp` | `mjc:actuatorgravcomp` | `MjcJointAPI` | Body-level `gravcomp` must be set for joint-level `actuatorgravcomp` to have any effect. The spawner auto-enables `MujocoRigidBodyPropertiesCfg(gravcomp=1.0)` when joint-level actuator gravcomp is requested without body-level gravcomp. ### Newton-native (`newton:*` namespace) | Class | Fields | USD attributes | Applied schema | |---|---|---|---| | `NewtonCollisionPropertiesCfg` | `contact_margin`, `contact_gap` | `newton:contactMargin`, `newton:contactGap` | `NewtonCollisionAPI` | | `NewtonMeshCollisionPropertiesCfg` | `max_hull_vertices` | `newton:maxHullVertices` | `NewtonMeshCollisionAPI` | | `NewtonMaterialPropertiesCfg` | `torsional_friction`, `rolling_friction` | `newton:torsionalFriction`, `newton:rollingFriction` | `NewtonMaterialAPI` | | `NewtonArticulationRootPropertiesCfg` | `self_collision_enabled` | `newton:selfCollisionEnabled` | `NewtonArticulationRootAPI` | ## Design constraints Same single-cfg-per-spawner-slot rule as #5275. Newton cfgs subclass the same base classes as PhysX cfgs; each declares `_usd_namespace`/`_usd_applied_schema` (ClassVar) and fields that auto-camelCase to their USD attr names. Per-declaring-class MRO routing handles mixed PhysX+Newton cfg hierarchies correctly. ## Field renames (with deprecation aliases through 5.0) | Old | New | Reason | |---|---|---| | `gravity_compensation_scale` | `gravcomp` | Single word identity: `gravcomp` → `mjc:gravcomp` | | `gravity_compensation` | `actuatorgravcomp` | Single word identity: `actuatorgravcomp` → `mjc:actuatorgravcomp` | ## Type of change - New feature (non-breaking) Forwarding shims on `isaaclab.sim.schemas` keep existing imports working. Deprecation aliases keep old field names working through 5.0. ## Test plan - [x] MuJoCo tests: `mjc:gravcomp` / `mjc:actuatorgravcomp` written when set, not written when None - [x] Newton collision, material, articulation-root: attrs written, schemas applied only when non-None - [x] Deprecation alias tests for renamed fields - [x] `test_schemas.py` 46/46 pass — no regressions - [x] Pre-commit clean ## Supersedes Together with #5275, supersedes #4847 and #5203. --------- Co-authored-by: Kelly Guo Co-authored-by: Antoine RICHARD --- docs/source/api/index.rst | 2 + docs/source/api/lab/isaaclab.sim.schemas.rst | 94 ++++- .../isaaclab_newton.sim.schemas.rst | 88 ++++ .../lab_physx/isaaclab_physx.sim.schemas.rst | 117 +++++- .../migration/migrating_to_isaaclab_3-0.rst | 161 +++++++ docs/source/overview/core-concepts/index.rst | 1 + .../overview/core-concepts/schema_cfgs.rst | 392 ++++++++++++++++++ .../vidur-add-mujoco-gravcomp.minor.rst | 11 + source/isaaclab/isaaclab/sim/__init__.py | 30 +- source/isaaclab/isaaclab/sim/__init__.pyi | 22 + .../isaaclab/isaaclab/sim/schemas/__init__.py | 28 +- .../isaaclab/sim/schemas/__init__.pyi | 18 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 30 +- .../isaaclab/sim/schemas/schemas_cfg.py | 36 +- .../sim/spawners/from_files/from_files.py | 19 + .../sim/spawners/materials/__init__.py | 2 +- .../materials/physics_materials_cfg.py | 2 +- .../vidur-add-newton-schemas.minor.rst | 22 + .../isaaclab_newton/sim/schemas/__init__.py | 17 + .../isaaclab_newton/sim/schemas/__init__.pyi | 15 + .../sim/schemas/schemas_cfg.py | 230 ++++++++++ .../test/sim/test_newton_schemas.py | 269 ++++++++++++ 22 files changed, 1565 insertions(+), 41 deletions(-) create mode 100644 docs/source/api/lab_newton/isaaclab_newton.sim.schemas.rst create mode 100644 docs/source/overview/core-concepts/schema_cfgs.rst create mode 100644 source/isaaclab/changelog.d/vidur-add-mujoco-gravcomp.minor.rst create mode 100644 source/isaaclab_newton/changelog.d/vidur-add-newton-schemas.minor.rst create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py create mode 100644 source/isaaclab_newton/test/sim/test_newton_schemas.py diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index f296c74310c6..bab5f025a78e 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -161,6 +161,7 @@ The following modules are available in the ``isaaclab_newton`` extension: renderers scene_data_providers sensors + sim.schemas .. toctree:: :hidden: @@ -171,6 +172,7 @@ The following modules are available in the ``isaaclab_newton`` extension: lab_newton/isaaclab_newton.renderers lab_newton/isaaclab_newton.scene_data_providers lab_newton/isaaclab_newton.sensors + lab_newton/isaaclab_newton.sim.schemas isaaclab_ov extension --------------------- diff --git a/docs/source/api/lab/isaaclab.sim.schemas.rst b/docs/source/api/lab/isaaclab.sim.schemas.rst index 263e3152e596..bb0651c83ec2 100644 --- a/docs/source/api/lab/isaaclab.sim.schemas.rst +++ b/docs/source/api/lab/isaaclab.sim.schemas.rst @@ -1,18 +1,31 @@ -isaaclab.sim.schemas +isaaclab.sim.schemas ==================== .. automodule:: isaaclab.sim.schemas - .. rubric:: Classes + .. rubric:: Solver-common base classes + + These base classes carry the universal-physics fields that every backend honors. + They live in core ``isaaclab`` and have no backend dependency. For backend-specific + knobs, use the matching subclass in :mod:`isaaclab_physx.sim.schemas` or + :mod:`isaaclab_newton.sim.schemas`. See :doc:`/source/overview/core-concepts/schema_cfgs` + for the full design. .. autosummary:: - ArticulationRootPropertiesCfg - RigidBodyPropertiesCfg - CollisionPropertiesCfg + ArticulationRootBaseCfg + RigidBodyBaseCfg + CollisionBaseCfg + JointDriveBaseCfg + MeshCollisionBaseCfg MassPropertiesCfg - JointDrivePropertiesCfg - FixedTendonPropertiesCfg + + .. rubric:: Mesh collision approximations (USD-only, no PhysX schema) + + .. autosummary:: + + BoundingCubePropertiesCfg + BoundingSpherePropertiesCfg .. rubric:: Functions @@ -28,22 +41,33 @@ define_mass_properties modify_mass_properties modify_joint_drive_properties + define_mesh_collision_properties + modify_mesh_collision_properties modify_fixed_tendon_properties + modify_spatial_tendon_properties + +.. currentmodule:: isaaclab.sim.schemas Articulation Root ----------------- -.. autoclass:: ArticulationRootPropertiesCfg +.. autoclass:: ArticulationRootBaseCfg :members: :exclude-members: __init__ .. autofunction:: define_articulation_root_properties .. autofunction:: modify_articulation_root_properties +For PhysX-specific articulation properties (self-collisions, TGS solver iterations, +sleep/stabilization thresholds), see +:class:`~isaaclab_physx.sim.schemas.PhysxArticulationRootPropertiesCfg`. For +Newton-native self-collisions, see +:class:`~isaaclab_newton.sim.schemas.NewtonArticulationRootPropertiesCfg`. + Rigid Body ---------- -.. autoclass:: RigidBodyPropertiesCfg +.. autoclass:: RigidBodyBaseCfg :members: :exclude-members: __init__ @@ -51,16 +75,26 @@ Rigid Body .. autofunction:: modify_rigid_body_properties .. autofunction:: activate_contact_sensors +For PhysX-specific rigid body properties (damping, max velocities, solver iterations, +sleep/stabilization), see :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyPropertiesCfg`. +For MuJoCo-specific gravity compensation, see +:class:`~isaaclab_newton.sim.schemas.MujocoRigidBodyPropertiesCfg`. + Collision --------- -.. autoclass:: CollisionPropertiesCfg +.. autoclass:: CollisionBaseCfg :members: :exclude-members: __init__ .. autofunction:: define_collision_properties .. autofunction:: modify_collision_properties +For PhysX torsional patch friction, see +:class:`~isaaclab_physx.sim.schemas.PhysxCollisionPropertiesCfg`. For Newton-native +contact margin/gap, see +:class:`~isaaclab_newton.sim.schemas.NewtonCollisionPropertiesCfg`. + Mass ---- @@ -74,20 +108,52 @@ Mass Joint Drive ----------- -.. autoclass:: JointDrivePropertiesCfg +.. autoclass:: JointDriveBaseCfg :members: :exclude-members: __init__ .. autofunction:: modify_joint_drive_properties -Fixed Tendon ------------- +For PhysX-specific drive properties, see +:class:`~isaaclab_physx.sim.schemas.PhysxJointDrivePropertiesCfg`. For MuJoCo +actuator gravity compensation, see +:class:`~isaaclab_newton.sim.schemas.MujocoJointDrivePropertiesCfg`. + +Mesh Collision +-------------- -.. autoclass:: FixedTendonPropertiesCfg +.. autoclass:: MeshCollisionBaseCfg :members: :exclude-members: __init__ +.. autoclass:: BoundingCubePropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: BoundingSpherePropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autofunction:: define_mesh_collision_properties +.. autofunction:: modify_mesh_collision_properties + +For PhysX cooking schemas (convex hull / decomposition / triangle mesh / SDF), +see the ``Physx*PropertiesCfg`` family in :mod:`isaaclab_physx.sim.schemas`. +For Newton hull-vertex limit, see +:class:`~isaaclab_newton.sim.schemas.NewtonMeshCollisionPropertiesCfg`. + +Tendon +------ + .. autofunction:: modify_fixed_tendon_properties +.. autofunction:: modify_spatial_tendon_properties + +Tendon cfg classes are PhysX-only and live in +:mod:`isaaclab_physx.sim.schemas` +(:class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg`, +:class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonPropertiesCfg`). Deformable Body --------------- diff --git a/docs/source/api/lab_newton/isaaclab_newton.sim.schemas.rst b/docs/source/api/lab_newton/isaaclab_newton.sim.schemas.rst new file mode 100644 index 000000000000..f0dde258fe8c --- /dev/null +++ b/docs/source/api/lab_newton/isaaclab_newton.sim.schemas.rst @@ -0,0 +1,88 @@ +isaaclab_newton.sim.schemas +=========================== + +.. automodule:: isaaclab_newton.sim.schemas + + Newton-targeted schema configuration classes. Each cfg below extends a + solver-common base in :mod:`isaaclab.sim.schemas` with Newton-namespaced + attributes (``newton:*``) or solver-specific attributes (``mjc:*`` for + Newton's MuJoCo solver). MuJoCo cfgs subclass their Newton counterpart + because MuJoCo is one of Newton's solver options. + + See :doc:`/source/overview/core-concepts/schema_cfgs` for the design and + when to use each class. + + .. rubric:: Newton-targeted (family roots) + + .. autosummary:: + + NewtonRigidBodyPropertiesCfg + NewtonJointDrivePropertiesCfg + NewtonCollisionPropertiesCfg + NewtonMeshCollisionPropertiesCfg + NewtonMaterialPropertiesCfg + NewtonArticulationRootPropertiesCfg + + .. rubric:: MuJoCo-solver-specific + + .. autosummary:: + + MujocoRigidBodyPropertiesCfg + MujocoJointDrivePropertiesCfg + +.. currentmodule:: isaaclab_newton.sim.schemas + +Rigid Body +---------- + +.. autoclass:: NewtonRigidBodyPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: MujocoRigidBodyPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Joint Drive +----------- + +.. autoclass:: NewtonJointDrivePropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: MujocoJointDrivePropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Collision +--------- + +.. autoclass:: NewtonCollisionPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: NewtonMeshCollisionPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Material +-------- + +.. autoclass:: NewtonMaterialPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Articulation Root +----------------- + +.. autoclass:: NewtonArticulationRootPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ diff --git a/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst b/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst index 40fb3addb275..305269068991 100644 --- a/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst +++ b/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst @@ -3,7 +3,49 @@ isaaclab_physx.sim.schemas .. automodule:: isaaclab_physx.sim.schemas - .. rubric:: Classes + PhysX-specific schema configuration classes. Each cfg below extends a + solver-common base in :mod:`isaaclab.sim.schemas` with PhysX-namespaced + attributes (``physx*:*``) and applies the corresponding ``Physx*API`` + applied schema. See :doc:`/source/overview/core-concepts/schema_cfgs` + for the design. + + .. rubric:: Rigid body and joint drive + + .. autosummary:: + + PhysxRigidBodyPropertiesCfg + PhysxJointDrivePropertiesCfg + + .. rubric:: Collision + + .. autosummary:: + + PhysxCollisionPropertiesCfg + + .. rubric:: Articulation root + + .. autosummary:: + + PhysxArticulationRootPropertiesCfg + + .. rubric:: Mesh collision (PhysX cooking) + + .. autosummary:: + + PhysxConvexHullPropertiesCfg + PhysxConvexDecompositionPropertiesCfg + PhysxTriangleMeshPropertiesCfg + PhysxTriangleMeshSimplificationPropertiesCfg + PhysxSDFMeshPropertiesCfg + + .. rubric:: Tendon + + .. autosummary:: + + PhysxFixedTendonPropertiesCfg + PhysxSpatialTendonPropertiesCfg + + .. rubric:: Deformable body .. autosummary:: @@ -18,6 +60,79 @@ isaaclab_physx.sim.schemas .. currentmodule:: isaaclab_physx.sim.schemas +Rigid Body +---------- + +.. autoclass:: PhysxRigidBodyPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Joint Drive +----------- + +.. autoclass:: PhysxJointDrivePropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Collision +--------- + +.. autoclass:: PhysxCollisionPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Articulation Root +----------------- + +.. autoclass:: PhysxArticulationRootPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Mesh Collision (PhysX cooking) +------------------------------- + +.. autoclass:: PhysxConvexHullPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: PhysxConvexDecompositionPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: PhysxTriangleMeshPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: PhysxTriangleMeshSimplificationPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: PhysxSDFMeshPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Tendon +------ + +.. autoclass:: PhysxFixedTendonPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: PhysxSpatialTendonPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + Deformable Body --------------- diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index be5703ab1261..d660aa6e62ae 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -98,6 +98,167 @@ The following classes have been moved to ``isaaclab_physx``: installation steps are required. +.. _schemas-cfg-refactor: + +Schema Configuration Class Refactor +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In Isaac Lab 3.0, the spawner schema cfg classes are split into solver-common +**base classes** (in ``isaaclab.sim.schemas``) and **backend-specific subclasses** +in ``isaaclab_physx.sim.schemas`` and ``isaaclab_newton.sim.schemas``. This makes +the same asset cfg portable across PhysX and Newton backends, and adds slots +for backend-specific asset-level knobs (e.g., MuJoCo gravity compensation). + +For the full design, see :ref:`schema-cfgs`. + +**Class moves and renames** + +The following 2.x class names are kept as deprecated aliases. They forward to +the new location and will be removed in 4.0. + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Isaac Lab 2.x + - Isaac Lab 3.0 + * - ``RigidBodyPropertiesCfg`` + - :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg` (solver-common fields) + + :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyPropertiesCfg` (PhysX-specific) + * - ``JointDrivePropertiesCfg`` + - :class:`~isaaclab.sim.schemas.JointDriveBaseCfg` + + :class:`~isaaclab_physx.sim.schemas.PhysxJointDrivePropertiesCfg` + * - ``CollisionPropertiesCfg`` + - :class:`~isaaclab.sim.schemas.CollisionBaseCfg` + + :class:`~isaaclab_physx.sim.schemas.PhysxCollisionPropertiesCfg` + * - ``ArticulationRootPropertiesCfg`` + - :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` + + :class:`~isaaclab_physx.sim.schemas.PhysxArticulationRootPropertiesCfg` + * - ``RigidBodyMaterialCfg`` + - :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` + + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` + * - ``MeshCollisionPropertiesCfg`` family (``ConvexHullPropertiesCfg``, + ``ConvexDecompositionPropertiesCfg``, ``TriangleMeshPropertiesCfg``, + ``TriangleMeshSimplificationPropertiesCfg``, ``SDFMeshPropertiesCfg``) + - :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg` + + ``Physx*PropertiesCfg`` family in :mod:`isaaclab_physx.sim.schemas` + * - ``FixedTendonPropertiesCfg``, ``SpatialTendonPropertiesCfg`` + - :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg`, + :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonPropertiesCfg` + +**Code migration** + +Existing 2.x code continues to work via the deprecation aliases (with a +``DeprecationWarning``; removed in 4.0): + +.. code-block:: python + + # Isaac Lab 2.x + import isaaclab.sim as sim_utils + rigid_props = sim_utils.RigidBodyPropertiesCfg(disable_gravity=True, linear_damping=0.1) + +Recommended 3.0 pattern when targeting PhysX: + +.. code-block:: python + + # Isaac Lab 3.0 — PhysX backend + from isaaclab_physx.sim.schemas import PhysxRigidBodyPropertiesCfg + rigid_props = PhysxRigidBodyPropertiesCfg(disable_gravity=True, linear_damping=0.1) + +Backend-portable 3.0 pattern (universal-physics fields only): + +.. code-block:: python + + # Isaac Lab 3.0 — backend-portable + from isaaclab.sim.schemas import RigidBodyBaseCfg + rigid_props = RigidBodyBaseCfg(rigid_body_enabled=True, disable_gravity=True) + +**Field renames on** ``JointDriveBaseCfg`` + +Two cfg fields were renamed so their snake_case names map identity-style to the +USD camelCase attribute names. The old names remain as deprecated dataclass +fields on :class:`~isaaclab.sim.schemas.JointDriveBaseCfg` (so +``dataclasses.fields()`` still sees them) and are forwarded to the new fields +in ``__post_init__`` with a ``DeprecationWarning``. Setting **both** the old +and new field on the same instance is silent — the canonical (new) field +wins; the old field's value is discarded after the warning. Both aliases are +scheduled for removal in 4.0. + +.. list-table:: + :header-rows: 1 + :widths: 35 35 30 + + * - Isaac Lab 2.x + - Isaac Lab 3.0 + - USD attribute (unchanged) + * - :attr:`~isaaclab.sim.schemas.JointDriveBaseCfg.max_velocity` + - :attr:`~isaaclab.sim.schemas.JointDriveBaseCfg.max_joint_velocity` + - ``physxJoint:maxJointVelocity`` + * - :attr:`~isaaclab.sim.schemas.JointDriveBaseCfg.max_effort` + - :attr:`~isaaclab.sim.schemas.JointDriveBaseCfg.max_force` + - ``drive::physics:maxForce`` + +Isaac Lab 2.x style still works (emits ``DeprecationWarning``; removed in 4.0): + +.. code-block:: python + + import isaaclab.sim as sim_utils + sim_utils.JointDrivePropertiesCfg(max_effort=80.0, max_velocity=5.0) + +Recommended 3.0 pattern, backend-portable: + +.. code-block:: python + + from isaaclab.sim.schemas import JointDriveBaseCfg + JointDriveBaseCfg(max_force=80.0, max_joint_velocity=5.0) + +Recommended 3.0 pattern, PhysX-targeted: + +.. code-block:: python + + from isaaclab_physx.sim.schemas import PhysxJointDrivePropertiesCfg + PhysxJointDrivePropertiesCfg(max_force=80.0, max_joint_velocity=5.0) + +**New Newton and MuJoCo cfg classes** + +For the Newton backend (and Newton's MuJoCo solver), new cfg classes are +available under :mod:`isaaclab_newton.sim.schemas`: + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - Class + - Use case + * - :class:`~isaaclab_newton.sim.schemas.NewtonCollisionPropertiesCfg` + - ``newton:contactMargin`` / ``newton:contactGap`` via ``NewtonCollisionAPI`` + * - :class:`~isaaclab_newton.sim.schemas.NewtonMeshCollisionPropertiesCfg` + - ``newton:maxHullVertices`` via ``NewtonMeshCollisionAPI`` + * - :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg` + - ``newton:torsionalFriction`` / ``newton:rollingFriction`` via ``NewtonMaterialAPI`` + * - :class:`~isaaclab_newton.sim.schemas.NewtonArticulationRootPropertiesCfg` + - ``newton:selfCollisionEnabled`` via ``NewtonArticulationRootAPI`` + * - :class:`~isaaclab_newton.sim.schemas.MujocoRigidBodyPropertiesCfg` + - ``mjc:gravcomp`` (body-level gravity compensation, MuJoCo solver only) + * - :class:`~isaaclab_newton.sim.schemas.MujocoJointDrivePropertiesCfg` + - ``mjc:actuatorgravcomp`` via ``MjcJointAPI`` (joint-level routing) + +The MuJoCo cfgs subclass their Newton parent because MuJoCo is one of Newton's +solver options. + +.. note:: + + Spawners auto-enable body-level gravity compensation when joint-level + ``actuatorgravcomp=True`` is requested but no Mujoco rigid-body cfg is + provided — without ``gravcomp`` on the bodies, ``actuatorgravcomp`` is a + no-op (no forces to route). To override, pass an explicit + ``MujocoRigidBodyPropertiesCfg`` in ``rigid_props``. See + :ref:`schema-cfgs-gravcomp` for details. + +For complete tables of which fields live on which class and where each lands in +USD, see :ref:`schema-cfgs`. + + Renaming of ``XformPrimView`` to ``FrameView`` ----------------------------------------------- diff --git a/docs/source/overview/core-concepts/index.rst b/docs/source/overview/core-concepts/index.rst index 10fdf8935fbc..052d7080eb67 100644 --- a/docs/source/overview/core-concepts/index.rst +++ b/docs/source/overview/core-concepts/index.rst @@ -8,6 +8,7 @@ This section we introduce core concepts in Isaac Lab. multi_backend_architecture + schema_cfgs task_workflows actuators sensors/index.rst diff --git a/docs/source/overview/core-concepts/schema_cfgs.rst b/docs/source/overview/core-concepts/schema_cfgs.rst new file mode 100644 index 000000000000..4fd547e79f16 --- /dev/null +++ b/docs/source/overview/core-concepts/schema_cfgs.rst @@ -0,0 +1,392 @@ +.. _schema-cfgs: + +Schema Configuration Classes +============================ + +Isaac Lab's spawners author USD physics attributes onto prims via a layered set of +configuration classes. The layering separates **universal-physics** parameters +from **backend-specific** parameters, so the same asset cfg can be authored once +and target any backend that supports it. + +This page explains the class hierarchy, when to use each tier, and how parameters +route to the underlying USD attributes. + +Migrating from 2.x? See :ref:`schemas-cfg-refactor` in the 3.0 migration guide. + +.. contents:: + :local: + :depth: 2 + +Quick example +------------- + +Add MuJoCo (MJC) gravity compensation to an articulated asset: + +.. code-block:: python + + import isaaclab.sim as sim_utils + from isaaclab_newton.sim.schemas import ( + MujocoRigidBodyPropertiesCfg, + MujocoJointDrivePropertiesCfg, + ) + + spawn = sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/Franka/franka_instanceable.usd", + rigid_props=MujocoRigidBodyPropertiesCfg(gravcomp=1.0), + joint_drive_props=MujocoJointDrivePropertiesCfg(actuatorgravcomp=True), + ) + +The Mujoco-specific fields land under ``mjc:*`` on the prim; any +``RigidBodyBaseCfg`` / ``JointDriveBaseCfg`` fields you set on the same instance +land under ``physics:*``. See :ref:`schema-cfgs-mixed` for the full routing rules. + +Class hierarchy +--------------- + +For each property group (rigid body, joint drive, collision, articulation root, +material, mesh collision), Isaac Lab defines a single base class in core +``isaaclab.sim.schemas`` and one subclass per backend in the corresponding +extension package: + +.. code-block:: text + + isaaclab.sim.schemas + ├── RigidBodyBaseCfg + │ ├── isaaclab_physx.sim.schemas.PhysxRigidBodyPropertiesCfg + │ └── isaaclab_newton.sim.schemas.NewtonRigidBodyPropertiesCfg + │ └── isaaclab_newton.sim.schemas.MujocoRigidBodyPropertiesCfg + │ + ├── JointDriveBaseCfg + │ ├── isaaclab_physx.sim.schemas.PhysxJointDrivePropertiesCfg + │ └── isaaclab_newton.sim.schemas.NewtonJointDrivePropertiesCfg + │ └── isaaclab_newton.sim.schemas.MujocoJointDrivePropertiesCfg + │ + ├── CollisionBaseCfg + │ ├── isaaclab_physx.sim.schemas.PhysxCollisionPropertiesCfg + │ └── isaaclab_newton.sim.schemas.NewtonCollisionPropertiesCfg + │ + ├── ArticulationRootBaseCfg + │ ├── isaaclab_physx.sim.schemas.PhysxArticulationRootPropertiesCfg + │ └── isaaclab_newton.sim.schemas.NewtonArticulationRootPropertiesCfg + │ + ├── MeshCollisionBaseCfg + │ ├── isaaclab_physx.sim.schemas.{PhysxConvexHull, PhysxConvexDecomposition, + │ │ PhysxTriangleMesh, PhysxSDFMesh, ...}PropertiesCfg + │ └── isaaclab_newton.sim.schemas.NewtonMeshCollisionPropertiesCfg + │ (also inherits NewtonCollisionPropertiesCfg — multi-namespace) + │ + └── isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg + ├── isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg + └── isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg + +:class:`~isaaclab_newton.sim.schemas.NewtonMeshCollisionPropertiesCfg` uses +multiple inheritance: it extends both +:class:`~isaaclab_newton.sim.schemas.NewtonCollisionPropertiesCfg` (for +``contact_margin`` / ``contact_gap``) and +:class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg` (for +``mesh_approximation_name``). This is the textbook case for the per-declaring- +class MRO routing described under :ref:`schema-cfgs-mixed` — each inherited +field is written under the namespace of the class that declared it. + +The hierarchy is **single-rooted per spawner slot**: every spawner has a single +field for each property group (``rigid_props``, ``joint_drive_props``, +``collision_props``, etc.), and Python's polymorphism allows any subclass to be +passed where the base type is expected. + +When to use which class +----------------------- + +The choice depends on which backends you target and which fields you need. + +**Use a base class** (``RigidBodyBaseCfg``, ``JointDriveBaseCfg``, etc.) + when you only need universal-physics fields and you want your asset cfg to be + backend-portable. Importing the base class does not pull in + :mod:`isaaclab_physx` or :mod:`isaaclab_newton`. + +**Use a PhysX subclass** (``PhysxRigidBodyPropertiesCfg``, etc.) + when your asset uses PhysX-specific knobs (per-body damping, TGS solver + iterations, sleep / stabilization thresholds, torsional patch friction, + compliant-contact materials, etc.) and you target the PhysX backend. Inherits + all base-class fields, so you can set both universal and PhysX fields on the + same instance. + +**Use a Newton subclass** (``NewtonRigidBodyPropertiesCfg``, etc.) + when you target Newton and need Newton-native attributes + (``newton:contactMargin``, ``newton:torsionalFriction``, + ``newton:selfCollisionEnabled``, etc.). The empty Newton base classes + (``NewtonRigidBodyPropertiesCfg``, ``NewtonJointDrivePropertiesCfg``) reserve + the ``newton:*`` namespace for future native fields and act as the parent for + solver-specific subclasses. + +**Use a MuJoCo subclass** (``MujocoRigidBodyPropertiesCfg``, ``MujocoJointDrivePropertiesCfg``) + when you specifically use Newton's **MuJoCo** solver and need MuJoCo-only + knobs (gravity compensation via ``mjc:gravcomp`` / + ``mjc:actuatorgravcomp``). Inherits from the Newton base, so + ``isinstance(cfg, NewtonRigidBodyPropertiesCfg)`` is True. + +What parameters live where +-------------------------- + +.. note:: + + The tables below summarize which fields live on which cfg classes. The + canonical source is the auto-generated API reference — see + :doc:`/source/api/lab/isaaclab.sim.schemas`, + :doc:`/source/api/lab_physx/isaaclab_physx.sim.schemas`, and + :doc:`/source/api/lab_newton/isaaclab_newton.sim.schemas`, which render + the cfg class docstrings directly. Treat these tables as a navigation aid; + if they drift from the source, the API docs win. + +Universal physics (declared on the base class) +"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" + +Lives on the **base class**. Most fields write to ``physics:*`` (the standard +``UsdPhysics.*API`` namespace), but a small set of "exception" fields are +declared on the base for backend-portability yet route to a non-``physics:*`` +namespace because that is the only USD path honored today (e.g., +``disable_gravity`` writes ``physxRigidBody:disableGravity`` because both PhysX +and Newton's importer consume the PhysX attribute). The "USD attribute" column +below is the actual emitted attribute, not the namespace family. + +.. list-table:: + :header-rows: 1 + :widths: 30 35 35 + + * - Base class + - Field + - USD attribute + * - ``RigidBodyBaseCfg`` + - ``rigid_body_enabled``, ``kinematic_enabled`` + - ``physics:rigidBodyEnabled``, ``physics:kinematicEnabled`` + * - ``RigidBodyBaseCfg`` + - ``disable_gravity`` + - ``physxRigidBody:disableGravity`` (per-body on PhysX; scene-level partial honor on Newton) + * - ``CollisionBaseCfg`` + - ``collision_enabled`` + - ``physics:collisionEnabled`` + * - ``CollisionBaseCfg`` + - ``contact_offset``, ``rest_offset`` + - ``physxCollision:contactOffset``, ``physxCollision:restOffset`` (Newton consumes via PhysX bridge) + * - ``ArticulationRootBaseCfg`` + - ``articulation_enabled`` + - ``physxArticulation:articulationEnabled`` + * - ``ArticulationRootBaseCfg`` + - ``fix_root_link`` + - synthesizes ``UsdPhysics.FixedJoint`` (writer-side, not a USD attribute) + * - ``JointDriveBaseCfg`` + - ``drive_type``, ``max_force``, ``stiffness``, ``damping`` + - ``drive::physics:type/maxForce/stiffness/damping`` + * - ``JointDriveBaseCfg`` + - ``max_joint_velocity`` + - ``physxJoint:maxJointVelocity`` (sole USD path; Newton consumes via PhysX bridge today) + * - ``JointDriveBaseCfg`` + - ``ensure_drives_exist`` + - writer-side only — when ``True``, ensures any drive with ``stiffness=0`` and + ``damping=0`` gets a minimal ``stiffness=1e-3`` so backends like Newton recognize + the joint as actively driven; not a USD attribute on its own + * - ``MassPropertiesCfg`` + - ``mass``, ``density`` + - ``physics:mass``, ``physics:density`` + * - ``RigidBodyMaterialBaseCfg`` + - ``static_friction``, ``dynamic_friction``, ``restitution`` + - ``physics:staticFriction``, ``physics:dynamicFriction``, ``physics:restitution`` + * - ``MeshCollisionBaseCfg`` + - ``mesh_approximation_name`` + - ``physics:approximation`` + +PhysX-specific (``physx*:*`` namespace, ``Physx*API`` schemas) +"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" + +Lives on the PhysX subclass. Only authored when the user opts in by setting the +field on a PhysX cfg. + +.. list-table:: + :header-rows: 1 + :widths: 35 35 30 + + * - PhysX subclass + - Fields (selection) + - USD namespace / schema + * - ``PhysxRigidBodyPropertiesCfg`` + - ``linear_damping``, ``angular_damping``, ``max_linear_velocity``, ``max_angular_velocity``, ``solver_position_iteration_count``, ``sleep_threshold``, ``enable_gyroscopic_forces``, … + - ``physxRigidBody:*`` / ``PhysxRigidBodyAPI`` + * - ``PhysxJointDrivePropertiesCfg`` + - (currently empty; reserved for future PhysX-only drive knobs) + - ``physxJoint:*`` / ``PhysxJointAPI`` + * - ``PhysxCollisionPropertiesCfg`` + - ``torsional_patch_radius``, ``min_torsional_patch_radius`` + - ``physxCollision:*`` / ``PhysxCollisionAPI`` + * - ``PhysxArticulationRootPropertiesCfg`` + - ``enabled_self_collisions``, ``solver_position_iteration_count``, ``sleep_threshold``, ``stabilization_threshold`` + - ``physxArticulation:*`` / ``PhysxArticulationAPI`` + * - ``PhysxRigidBodyMaterialCfg`` + - ``compliant_contact_stiffness``, ``compliant_contact_damping``, ``friction_combine_mode``, ``restitution_combine_mode`` + - ``physxMaterial:*`` / ``PhysxMaterialAPI`` + * - ``PhysxConvexHullPropertiesCfg`` (and other mesh-cooking subclasses) + - ``hull_vertex_limit``, ``min_thickness``, … + - ``physxConvexHullCollision:*`` / ``PhysxConvexHullCollisionAPI`` + +Newton-targeted (``newton:*`` namespace, ``Newton*API`` schemas) +"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" + +Lives on the Newton subclass. Authored only when the user opts in. + +.. list-table:: + :header-rows: 1 + :widths: 35 35 30 + + * - Newton subclass + - Fields + - USD namespace / schema + * - ``NewtonRigidBodyPropertiesCfg`` + - (empty — reserved for future Newton-native rigid-body fields) + - ``newton:*`` + * - ``NewtonJointDrivePropertiesCfg`` + - (empty — reserved for future Newton-native joint-drive fields) + - ``newton:*`` + * - ``NewtonCollisionPropertiesCfg`` + - ``contact_margin``, ``contact_gap`` + - ``newton:*`` / ``NewtonCollisionAPI`` + * - ``NewtonMeshCollisionPropertiesCfg`` + - ``max_hull_vertices`` + - ``newton:*`` / ``NewtonMeshCollisionAPI`` + * - ``NewtonMaterialPropertiesCfg`` + - ``torsional_friction``, ``rolling_friction`` + - ``newton:*`` / ``NewtonMaterialAPI`` + * - ``NewtonArticulationRootPropertiesCfg`` + - ``self_collision_enabled`` + - ``newton:*`` / ``NewtonArticulationRootAPI`` + +MuJoCo-solver-specific (``mjc:*`` namespace) +"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" + +Lives on a MuJoCo subclass that extends a Newton subclass. Only consumed when +running Newton's MuJoCo solver. + +.. list-table:: + :header-rows: 1 + :widths: 35 25 40 + + * - MuJoCo subclass + - Field + - USD attribute / schema + * - ``MujocoRigidBodyPropertiesCfg`` + - ``gravcomp`` + - ``mjc:gravcomp`` (raw attribute, no applied schema) + * - ``MujocoJointDrivePropertiesCfg`` + - ``actuatorgravcomp`` + - ``mjc:actuatorgravcomp`` via ``MjcJointAPI`` + +.. note:: + + The two MuJoCo rows differ in their USD applied-schema requirement: + ``mjc:actuatorgravcomp`` is part of the registered ``MjcJointAPI`` applied + schema (so the writer calls ``prim.AddAppliedSchema("MjcJointAPI")`` when + the field is non-None). ``mjc:gravcomp`` has no registered Mjc applied + schema for body-level gravity compensation, so the writer authors it as a + raw USD attribute. Newton's MuJoCo solver consumes both via the same + resolver path; the schema-application difference is purely a USD-side + detail. + +.. _schema-cfgs-mixed: + +Mixed-namespace authoring on a single instance +---------------------------------------------- + +Because each cfg field is routed to its **declaring class's** namespace (not +the instance's class), a subclass instance can author attributes across multiple +namespaces on the same prim. For example: + +.. code-block:: python + + from isaaclab_newton.sim.schemas import MujocoRigidBodyPropertiesCfg + + cfg = MujocoRigidBodyPropertiesCfg( + rigid_body_enabled=True, # declared on RigidBodyBaseCfg → physics:rigidBodyEnabled + disable_gravity=True, # declared on RigidBodyBaseCfg (exception) → physxRigidBody:disableGravity + gravcomp=1.0, # declared on MujocoRigidBodyPropertiesCfg → mjc:gravcomp + ) + +The writer applies each field to the namespace of the class where the field is +declared. The applied schemas (``PhysxRigidBodyAPI`` for ``disable_gravity``, +none for the Mjc raw attribute) are added only when the corresponding +fields are non-None. + +Spawner usage +------------- + +Spawners (``UsdFileCfg``, ``MeshCuboidCfg``, ``MeshSphereCfg``, …) accept +the base class type for each slot and use polymorphism to dispatch to the +correct subclass at write time: + +.. code-block:: python + + import isaaclab.sim as sim_utils + from isaaclab_physx.sim.schemas import PhysxRigidBodyPropertiesCfg + from isaaclab_newton.sim.schemas import MujocoJointDrivePropertiesCfg + + spawn = sim_utils.UsdFileCfg( + usd_path="...", + rigid_props=PhysxRigidBodyPropertiesCfg(disable_gravity=True, linear_damping=0.1), + joint_drive_props=MujocoJointDrivePropertiesCfg( + drive_type="acceleration", + stiffness=10.0, + damping=0.1, + actuatorgravcomp=True, + ), + ) + +.. _schema-cfgs-gravcomp: + +Gravity compensation (MuJoCo solver) +"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" + +Gravity compensation has two halves and you typically need both: + +* **Body-level**: + :attr:`~isaaclab_newton.sim.schemas.MujocoRigidBodyPropertiesCfg.gravcomp` + on each rigid body (writes ``mjc:gravcomp``). This is what *computes* the + compensation force. +* **Joint-level**: + :attr:`~isaaclab_newton.sim.schemas.MujocoJointDrivePropertiesCfg.actuatorgravcomp` + on each joint (writes ``mjc:actuatorgravcomp``). This routes the compensation + force through the actuator channel (``qfrc_actuator``) so it counts against + ``actuatorfrcrange``; otherwise it goes to ``qfrc_passive``. + +``actuatorgravcomp=True`` alone is a no-op — without body-level ``gravcomp`` +there are no forces to route. To prevent this footgun, the spawner +**auto-enables** ``MujocoRigidBodyPropertiesCfg(gravcomp=1.0)`` whenever +``joint_drive_props`` is a Mujoco cfg with ``actuatorgravcomp=True`` and +``rigid_props`` is not already a Mujoco cfg. If you want a different +``gravcomp`` value (or want to disable the auto-enable), pass an explicit +``MujocoRigidBodyPropertiesCfg`` in ``rigid_props``. + +Naming convention +----------------- + +Cfg field names use ``snake_case``; the writer converts them to ``camelCase`` +USD attribute names (``contact_margin`` → ``newton:contactMargin``). For +single-token fields (``gravcomp``, ``actuatorgravcomp``), the conversion is +identity, which matches MuJoCo's lowercase convention. + +Field renames preserve backward compatibility via deprecation aliases. Two such +aliases live on ``JointDriveBaseCfg`` today: + +* ``max_velocity`` → ``max_joint_velocity`` (USD attribute is ``physxJoint:maxJointVelocity``) +* ``max_effort`` → ``max_force`` (USD attribute is ``drive::physics:maxForce``) + +The old names remain as real dataclass fields (so ``dataclasses.fields()`` +sees them), defaulting to ``None``. ``__post_init__`` runs +``_deprecate_field_alias`` which, when the old field is set: emits a +``DeprecationWarning``, copies the value into the canonical field if the +canonical is ``None``, then nulls the old field. Setting **both** in the same +constructor is silent — the canonical wins; the old name's value is discarded. +Both aliases are scheduled for removal in 4.0. + +See also +-------- + +* :doc:`/source/migration/migrating_to_isaaclab_3-0` — migration guide +* :doc:`/source/api/lab/isaaclab.sim.schemas` — solver-common base class API +* :doc:`/source/api/lab_physx/isaaclab_physx.sim.schemas` — PhysX subclass API +* :doc:`/source/api/lab_newton/isaaclab_newton.sim.schemas` — Newton/MuJoCo subclass API diff --git a/source/isaaclab/changelog.d/vidur-add-mujoco-gravcomp.minor.rst b/source/isaaclab/changelog.d/vidur-add-mujoco-gravcomp.minor.rst new file mode 100644 index 000000000000..15ec19098dfa --- /dev/null +++ b/source/isaaclab/changelog.d/vidur-add-mujoco-gravcomp.minor.rst @@ -0,0 +1,11 @@ +Added +^^^^^ + +* Added forwarding shims on :mod:`isaaclab.sim.schemas` and :mod:`isaaclab.sim` for the + Newton/MuJoCo cfg classes added in :mod:`isaaclab_newton.sim.schemas` + (:class:`NewtonRigidBodyPropertiesCfg`, :class:`NewtonJointDrivePropertiesCfg`, + :class:`NewtonCollisionPropertiesCfg`, :class:`NewtonMeshCollisionPropertiesCfg`, + :class:`NewtonMaterialPropertiesCfg`, :class:`NewtonArticulationRootPropertiesCfg`, + :class:`MujocoRigidBodyPropertiesCfg`, :class:`MujocoJointDrivePropertiesCfg`). + The shims resolve lazily on first access so importing :mod:`isaaclab.sim.schemas` + does not require :mod:`isaaclab_newton` to be installed. diff --git a/source/isaaclab/isaaclab/sim/__init__.py b/source/isaaclab/isaaclab/sim/__init__.py index 9c140a2507cf..e1458e7873f3 100644 --- a/source/isaaclab/isaaclab/sim/__init__.py +++ b/source/isaaclab/isaaclab/sim/__init__.py @@ -69,6 +69,20 @@ _PHYSX_FORWARDS = _PHYSX_FORWARDS_SCHEMAS | _PHYSX_FORWARDS_MATERIALS +# Names that moved out of this package into ``isaaclab_newton.sim.schemas``. +# Resolved lazily on first access so importing ``isaaclab.sim`` does not +# require ``isaaclab_newton`` to be installed. +_NEWTON_FORWARDS = frozenset({ + "MujocoRigidBodyPropertiesCfg", + "MujocoJointDrivePropertiesCfg", + "NewtonRigidBodyPropertiesCfg", + "NewtonJointDrivePropertiesCfg", + "NewtonCollisionPropertiesCfg", + "NewtonMeshCollisionPropertiesCfg", + "NewtonMaterialPropertiesCfg", + "NewtonArticulationRootPropertiesCfg", +}) + def __getattr__(name): if name in _PHYSX_FORWARDS_SCHEMAS: @@ -78,7 +92,7 @@ def __getattr__(name): raise ImportError( f"'isaaclab.sim.{name}' has moved to 'isaaclab_physx.sim.schemas'." " Install the isaaclab_physx extension or update your import. This forwarding" - " shim is scheduled for removal in 5.0." + " shim is scheduled for removal in 4.0." ) from e return getattr(_physx_cfg, name) if name in _PHYSX_FORWARDS_MATERIALS: @@ -88,11 +102,21 @@ def __getattr__(name): raise ImportError( f"'isaaclab.sim.{name}' has moved to 'isaaclab_physx.sim.spawners.materials'." " Install the isaaclab_physx extension or update your import. This forwarding" - " shim is scheduled for removal in 5.0." + " shim is scheduled for removal in 4.0." ) from e return getattr(_physx_mat_cfg, name) + if name in _NEWTON_FORWARDS: + try: + from isaaclab_newton.sim.schemas import schemas_cfg as _newton_cfg + except ImportError as e: + raise ImportError( + f"'isaaclab.sim.{name}' has moved to 'isaaclab_newton.sim.schemas'." + " Install the isaaclab_newton extension or update your import. This forwarding" + " shim is scheduled for removal in 4.0." + ) from e + return getattr(_newton_cfg, name) return _stub_getattr(name) def __dir__(): - return sorted(set(_stub_dir()) | _PHYSX_FORWARDS) + return sorted(set(_stub_dir()) | _PHYSX_FORWARDS | _NEWTON_FORWARDS) diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index e1d9f535a207..2d5edfdf921f 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -43,6 +43,16 @@ __all__ = [ "JointDriveBaseCfg", "MassPropertiesCfg", "MeshCollisionPropertiesCfg", + "MujocoJointDrivePropertiesCfg", + "MujocoRigidBodyPropertiesCfg", + "NewtonArticulationRootPropertiesCfg", + "NewtonCollisionPropertiesCfg", + "NewtonJointDrivePropertiesCfg", + "NewtonMaterialPropertiesCfg", + "NewtonMeshCollisionPropertiesCfg", + "NewtonRigidBodyPropertiesCfg", + "PhysxJointDrivePropertiesCfg", + "PhysxRigidBodyPropertiesCfg", "RigidBodyBaseCfg", "SDFMeshPropertiesCfg", "SpatialTendonPropertiesCfg", @@ -209,12 +219,24 @@ from .schemas import ( JointDriveBaseCfg, MassPropertiesCfg, MeshCollisionPropertiesCfg, + PhysxJointDrivePropertiesCfg, + PhysxRigidBodyPropertiesCfg, RigidBodyBaseCfg, SDFMeshPropertiesCfg, SpatialTendonPropertiesCfg, TriangleMeshPropertiesCfg, TriangleMeshSimplificationPropertiesCfg, ) + +# Forwarded to isaaclab_newton.sim.schemas via __getattr__ shim +MujocoJointDrivePropertiesCfg = ... +MujocoRigidBodyPropertiesCfg = ... +NewtonArticulationRootPropertiesCfg = ... +NewtonCollisionPropertiesCfg = ... +NewtonJointDrivePropertiesCfg = ... +NewtonMaterialPropertiesCfg = ... +NewtonMeshCollisionPropertiesCfg = ... +NewtonRigidBodyPropertiesCfg = ... from .spawners import ( SpawnerCfg, RigidObjectSpawnerCfg, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.py b/source/isaaclab/isaaclab/sim/schemas/__init__.py index 2692196d4829..223627d4e523 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.py +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.py @@ -67,6 +67,20 @@ "PhysxSpatialTendonPropertiesCfg", }) +# Names that moved out of this module into ``isaaclab_newton.sim.schemas``. +# Resolved lazily on first access so importing ``isaaclab.sim.schemas`` does +# not require ``isaaclab_newton`` to be installed. +_NEWTON_FORWARDS = frozenset({ + "MujocoRigidBodyPropertiesCfg", + "MujocoJointDrivePropertiesCfg", + "NewtonRigidBodyPropertiesCfg", + "NewtonJointDrivePropertiesCfg", + "NewtonCollisionPropertiesCfg", + "NewtonMeshCollisionPropertiesCfg", + "NewtonMaterialPropertiesCfg", + "NewtonArticulationRootPropertiesCfg", +}) + def __getattr__(name): if name in _PHYSX_FORWARDS: @@ -76,11 +90,21 @@ def __getattr__(name): raise ImportError( f"'isaaclab.sim.schemas.{name}' has moved to 'isaaclab_physx.sim.schemas'." " Install the isaaclab_physx extension or update your import. This forwarding" - " shim is scheduled for removal in 5.0." + " shim is scheduled for removal in 4.0." ) from e return getattr(_physx_cfg, name) + if name in _NEWTON_FORWARDS: + try: + from isaaclab_newton.sim.schemas import schemas_cfg as _newton_cfg + except ImportError as e: + raise ImportError( + f"'isaaclab.sim.schemas.{name}' has moved to 'isaaclab_newton.sim.schemas'." + " Install the isaaclab_newton extension or update your import. This forwarding" + " shim is scheduled for removal in 4.0." + ) from e + return getattr(_newton_cfg, name) return _stub_getattr(name) def __dir__(): - return sorted(set(_stub_dir()) | _PHYSX_FORWARDS) + return sorted(set(_stub_dir()) | _PHYSX_FORWARDS | _NEWTON_FORWARDS) diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index 9a90ed0d810d..fd0c882a5f35 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -28,6 +28,14 @@ __all__ = [ "JointDriveBaseCfg", "MassPropertiesCfg", "MeshCollisionBaseCfg", + "MujocoJointDrivePropertiesCfg", + "MujocoRigidBodyPropertiesCfg", + "NewtonArticulationRootPropertiesCfg", + "NewtonCollisionPropertiesCfg", + "NewtonJointDrivePropertiesCfg", + "NewtonMaterialPropertiesCfg", + "NewtonMeshCollisionPropertiesCfg", + "NewtonRigidBodyPropertiesCfg", "RigidBodyBaseCfg", ] @@ -60,3 +68,13 @@ from .schemas_cfg import ( MeshCollisionBaseCfg, RigidBodyBaseCfg, ) + +# Forwarded to isaaclab_newton.sim.schemas via __getattr__ shim +MujocoJointDrivePropertiesCfg = ... +MujocoRigidBodyPropertiesCfg = ... +NewtonArticulationRootPropertiesCfg = ... +NewtonCollisionPropertiesCfg = ... +NewtonJointDrivePropertiesCfg = ... +NewtonMaterialPropertiesCfg = ... +NewtonMeshCollisionPropertiesCfg = ... +NewtonRigidBodyPropertiesCfg = ... diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 70f129413e5b..b7a74e27ff25 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -416,14 +416,14 @@ def define_rigid_body_properties(prim_path: str, cfg: schemas_cfg.RigidBodyBaseC def modify_rigid_body_properties( prim_path: str, cfg: schemas_cfg.RigidBodyBaseCfg, stage: Usd.Stage | None = None ) -> bool: - """Modify PhysX parameters for a rigid body prim. + """Modify parameters for a rigid body prim. - A `rigid body`_ is a single body that can be simulated by PhysX. It can be either dynamic or kinematic. - A dynamic body responds to forces and collisions. A `kinematic body`_ can be moved by the user, but does not - respond to forces. They are similar to having static bodies that can be moved around. + A `rigid body`_ is a single body that can be simulated by a physics engine. It can be either dynamic + or kinematic. A dynamic body responds to forces and collisions. A `kinematic body`_ can be moved by + the user, but does not respond to forces. - The schema comprises of attributes that belong to the `RigidBodyAPI`_ and `PhysxRigidBodyAPI`_. - schemas. The latter contains the PhysX parameters for the rigid body. + Solver-common properties (from `RigidBodyAPI`_) are always written. Solver-specific properties are + written based on the cfg subclass metadata (``_usd_namespace``, ``_usd_applied_schema``). .. note:: This function is decorated with :func:`apply_nested` that sets the properties to all the prims @@ -432,11 +432,13 @@ def modify_rigid_body_properties( .. _rigid body: https://nvidia-omniverse.github.io/PhysX/physx/5.4.1/docs/RigidBodyOverview.html .. _kinematic body: https://openusd.org/release/wp_rigid_body_physics.html#kinematic-bodies .. _RigidBodyAPI: https://openusd.org/dev/api/class_usd_physics_rigid_body_a_p_i.html - .. _PhysxRigidBodyAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_rigid_body_a_p_i.html Args: prim_path: The prim path to the rigid body. - cfg: The configuration for the rigid body. + cfg: The configuration for the rigid body. Accepts + :class:`~schemas_cfg.RigidBodyBaseCfg` for solver-common properties, + :class:`~schemas_cfg.PhysxRigidBodyPropertiesCfg` for PhysX properties, or + :class:`~schemas_cfg.MujocoRigidBodyPropertiesCfg` for Newton (MuJoCo) properties. stage: The stage where to find the prim. Defaults to None, in which case the current stage is used. @@ -727,15 +729,14 @@ def activate_contact_sensors(prim_path: str, threshold: float = 0.0, stage: Usd. def modify_joint_drive_properties( prim_path: str, cfg: schemas_cfg.JointDriveBaseCfg, stage: Usd.Stage | None = None ) -> bool: - """Modify PhysX parameters for a joint prim. + """Modify parameters for a joint prim. This function checks if the input prim is a prismatic or revolute joint and applies the joint drive schema on it. If the joint is a tendon (i.e., it has the `PhysxTendonAxisAPI`_ schema applied on it), then the joint drive schema is not applied. - Based on the configuration, this method modifies the properties of the joint drive. These properties are - based on the `UsdPhysics.DriveAPI`_ schema. For more information on the properties, please refer to the - official documentation. + Solver-common properties (from `UsdPhysics.DriveAPI`_) are always written. Solver-specific properties + are written based on the cfg subclass metadata (``_usd_namespace``, ``_usd_applied_schema``). .. caution:: @@ -748,7 +749,10 @@ def modify_joint_drive_properties( Args: prim_path: The prim path where to apply the joint drive schema. - cfg: The configuration for the joint drive. + cfg: The configuration for the joint drive. Accepts + :class:`~schemas_cfg.JointDriveBaseCfg` for solver-common properties, + :class:`~schemas_cfg.PhysxJointDrivePropertiesCfg` for PhysX properties, or + :class:`~schemas_cfg.MujocoJointDrivePropertiesCfg` for Newton (MuJoCo) properties. stage: The stage where to find the prim. Defaults to None, in which case the current stage is used. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index eae040435429..e54d71ca9d76 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -44,6 +44,19 @@ } ) +_NEWTON_FORWARDS = frozenset( + { + "MujocoRigidBodyPropertiesCfg", + "MujocoJointDrivePropertiesCfg", + "NewtonRigidBodyPropertiesCfg", + "NewtonJointDrivePropertiesCfg", + "NewtonCollisionPropertiesCfg", + "NewtonMeshCollisionPropertiesCfg", + "NewtonMaterialPropertiesCfg", + "NewtonArticulationRootPropertiesCfg", + } +) + def __getattr__(name): if name in _PHYSX_FORWARDS: @@ -54,9 +67,20 @@ def __getattr__(name): f"'isaaclab.sim.schemas.schemas_cfg.{name}' has moved to" " 'isaaclab_physx.sim.schemas.schemas_cfg'. Install the isaaclab_physx" " extension or update your import. This forwarding shim is scheduled for" - " removal in 5.0." + " removal in 4.0." ) from e return getattr(_physx_cfg, name) + if name in _NEWTON_FORWARDS: + try: + from isaaclab_newton.sim.schemas import schemas_cfg as _newton_cfg + except ImportError as e: + raise ImportError( + f"'isaaclab.sim.schemas.schemas_cfg.{name}' has moved to" + " 'isaaclab_newton.sim.schemas.schemas_cfg'. Install the isaaclab_newton" + " extension or update your import. This forwarding shim is scheduled for" + " removal in 4.0." + ) from e + return getattr(_newton_cfg, name) raise AttributeError(f"module 'isaaclab.sim.schemas.schemas_cfg' has no attribute {name!r}") @@ -71,7 +95,7 @@ def _deprecate_field_alias(cfg, alias: str, canonical: str) -> None: if value is None: return warnings.warn( - f"'{alias}' is deprecated; use '{canonical}' instead. The alias is scheduled for removal in 5.0.", + f"'{alias}' is deprecated; use '{canonical}' instead. The alias is scheduled for removal in 4.0.", DeprecationWarning, stacklevel=3, ) @@ -367,7 +391,7 @@ def __post_init__(self): Use :attr:`max_force` instead. The cfg field is renamed so its snake_case name maps identity-style to the USD camelCase attribute (``maxForce`` on ``UsdPhysics.DriveAPI``). The alias is forwarded to - :attr:`max_force` in :meth:`__post_init__` and will be removed in 5.0. + :attr:`max_force` in :meth:`__post_init__` and will be removed in 4.0. """ stiffness: float | None = None @@ -421,7 +445,7 @@ def __post_init__(self): Use :attr:`max_joint_velocity` instead. The cfg field is renamed so its snake_case name maps identity-style to the USD camelCase attribute (``physxJoint:maxJointVelocity``). The alias is forwarded to - :attr:`max_joint_velocity` in :meth:`__post_init__` and will be removed in 5.0. + :attr:`max_joint_velocity` in :meth:`__post_init__` and will be removed in 4.0. """ @@ -468,7 +492,7 @@ def __getattr__(self, name: str): """ if name == "usd_api": warnings.warn( - "'usd_api' attribute is deprecated and will be removed in 5.0. Use class-level" + "'usd_api' attribute is deprecated and will be removed in 4.0. Use class-level" " metadata via getattr(cfg, '_usd_applied_schema').", DeprecationWarning, stacklevel=2, @@ -479,7 +503,7 @@ def __getattr__(self, name: str): return "MeshCollisionAPI" if schema is not None else None if name == "physx_api": warnings.warn( - "'physx_api' attribute is deprecated and will be removed in 5.0. Use class-level" + "'physx_api' attribute is deprecated and will be removed in 4.0. Use class-level" " metadata via getattr(cfg, '_usd_applied_schema').", DeprecationWarning, stacklevel=2, diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index 892d2e78b387..18ffcaf37c98 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -363,6 +363,25 @@ def _spawn_from_usd_file( # note: these are only for setting low-level simulation properties. all others should be set or are # and overridden by the articulation/actuator properties. if cfg.joint_drive_props is not None: + # auto-enable body-level gravcomp if joint-level actuator gravcomp is requested + # without it — actuatorgravcomp has no effect since there are no forces to route. + # Only auto-populates when the user did not already set ``gravcomp`` themselves; + # an explicit ``MujocoRigidBodyPropertiesCfg(gravcomp=0.5)`` is preserved as-is. + from isaaclab_newton.sim.schemas.schemas_cfg import MujocoJointDrivePropertiesCfg, MujocoRigidBodyPropertiesCfg + + body_gravcomp_unset = ( + not isinstance(cfg.rigid_props, MujocoRigidBodyPropertiesCfg) or cfg.rigid_props.gravcomp is None + ) + if ( + isinstance(cfg.joint_drive_props, MujocoJointDrivePropertiesCfg) + and cfg.joint_drive_props.actuatorgravcomp + and body_gravcomp_unset + ): + logger.info( + "Joint-level actuator gravity compensation requires body-level gravcomp." + " Auto-setting MujocoRigidBodyPropertiesCfg(gravcomp=1.0)." + ) + schemas.modify_rigid_body_properties(prim_path, MujocoRigidBodyPropertiesCfg(gravcomp=1.0)) schemas.modify_joint_drive_properties(prim_path, cfg.joint_drive_props) # define deformable body properties, or modify if deformable body API is present (PhysX only) diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py index ae4049c25d21..3158625219c5 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py @@ -73,7 +73,7 @@ def __getattr__(name): raise ImportError( f"'isaaclab.sim.spawners.materials.{name}' has moved to" " 'isaaclab_physx.sim.spawners.materials'. Install the isaaclab_physx extension" - " or update your import. This forwarding shim is scheduled for removal in 5.0." + " or update your import. This forwarding shim is scheduled for removal in 4.0." ) from e return getattr(_physx_cfg, name) return _stub_getattr(name) diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py index 86437285ee9e..0c9a7be478e8 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py @@ -27,7 +27,7 @@ def __getattr__(name): f"'isaaclab.sim.spawners.materials.physics_materials_cfg.{name}' has moved to" " 'isaaclab_physx.sim.spawners.materials.physics_materials_cfg'. Install the" " isaaclab_physx extension or update your import. This forwarding shim is scheduled" - " for removal in 5.0." + " for removal in 4.0." ) from e return getattr(_physx_mat_cfg, name) raise AttributeError(f"module 'isaaclab.sim.spawners.materials.physics_materials_cfg' has no attribute {name!r}") diff --git a/source/isaaclab_newton/changelog.d/vidur-add-newton-schemas.minor.rst b/source/isaaclab_newton/changelog.d/vidur-add-newton-schemas.minor.rst new file mode 100644 index 000000000000..84eb5ce846a1 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidur-add-newton-schemas.minor.rst @@ -0,0 +1,22 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.NewtonRigidBodyPropertiesCfg` and + :class:`~isaaclab_newton.sim.schemas.NewtonJointDrivePropertiesCfg` as Newton-targeted + bases for solver-specific subclasses. Currently empty (no Newton-native ``newton:*`` + rigid-body or joint-drive attributes today); reserved as the family root for any + future Newton-native fields. +* Added :class:`~isaaclab_newton.sim.schemas.MujocoRigidBodyPropertiesCfg` (subclasses + :class:`NewtonRigidBodyPropertiesCfg`) with :attr:`gravcomp` for body-level gravity + compensation (``mjc:gravcomp``). +* Added :class:`~isaaclab_newton.sim.schemas.MujocoJointDrivePropertiesCfg` (subclasses + :class:`NewtonJointDrivePropertiesCfg`) with :attr:`actuatorgravcomp` for joint-level + gravity compensation routing (``mjc:actuatorgravcomp`` via ``MjcJointAPI``). +* Added :class:`~isaaclab_newton.sim.schemas.NewtonCollisionPropertiesCfg` with + :attr:`contact_margin` and :attr:`contact_gap` (``newton:*`` via ``NewtonCollisionAPI``). +* Added :class:`~isaaclab_newton.sim.schemas.NewtonMeshCollisionPropertiesCfg` with + :attr:`max_hull_vertices` (``newton:maxHullVertices`` via ``NewtonMeshCollisionAPI``). +* Added :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg` with + :attr:`torsional_friction` and :attr:`rolling_friction` (``newton:*`` via ``NewtonMaterialAPI``). +* Added :class:`~isaaclab_newton.sim.schemas.NewtonArticulationRootPropertiesCfg` with + :attr:`self_collision_enabled` (``newton:selfCollisionEnabled`` via ``NewtonArticulationRootAPI``). diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py new file mode 100644 index 000000000000..80f943ad46ee --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -0,0 +1,17 @@ +# 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 and MuJoCo simulation schema configuration classes.""" + +from .schemas_cfg import ( + MujocoJointDrivePropertiesCfg, + MujocoRigidBodyPropertiesCfg, + NewtonArticulationRootPropertiesCfg, + NewtonCollisionPropertiesCfg, + NewtonJointDrivePropertiesCfg, + NewtonMaterialPropertiesCfg, + NewtonMeshCollisionPropertiesCfg, + NewtonRigidBodyPropertiesCfg, +) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi new file mode 100644 index 000000000000..6dae7b8cf2b7 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -0,0 +1,15 @@ +# 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 .schemas_cfg import ( + MujocoJointDrivePropertiesCfg, + MujocoRigidBodyPropertiesCfg, + NewtonArticulationRootPropertiesCfg, + NewtonCollisionPropertiesCfg, + NewtonJointDrivePropertiesCfg, + NewtonMaterialPropertiesCfg, + NewtonMeshCollisionPropertiesCfg, + NewtonRigidBodyPropertiesCfg, +) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py new file mode 100644 index 000000000000..f0065daa867f --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -0,0 +1,230 @@ +# 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 + +from typing import ClassVar + +from isaaclab.sim.schemas.schemas_cfg import ( + ArticulationRootBaseCfg, + CollisionBaseCfg, + JointDriveBaseCfg, + MeshCollisionBaseCfg, + RigidBodyBaseCfg, +) +from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg +from isaaclab.utils import configclass + + +@configclass +class NewtonRigidBodyPropertiesCfg(RigidBodyBaseCfg): + """Newton-targeted rigid body properties. + + Base class for cfgs that author rigid-body attributes consumed by any of + Newton's solver options (MuJoCo, XPBD, Featherstone, Semi-implicit, Kamino). + Newton has no native ``newton:*`` rigid-body attributes today, so this class + is currently empty — solver-specific subclasses (e.g., + :class:`MujocoRigidBodyPropertiesCfg`) carry the actual fields. + + The ``newton:`` namespace is reserved here so future Newton-native + rigid-body fields can be added without an API change. + + See :meth:`~isaaclab.sim.schemas.modify_rigid_body_properties` for more information. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = {} + + +@configclass +class MujocoRigidBodyPropertiesCfg(NewtonRigidBodyPropertiesCfg): + """MuJoCo-solver-specific rigid body properties. + + Extends :class:`NewtonRigidBodyPropertiesCfg` with body-level gravity + compensation, consumed only when running Newton's MuJoCo solver. + + See :meth:`~isaaclab.sim.schemas.modify_rigid_body_properties` for more information. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "mjc" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = {} + + gravcomp: float | None = None + """Gravity compensation scale for the body [dimensionless]. + + ``0.0`` = no compensation; ``1.0`` = full compensation. + Written to ``mjc:gravcomp`` on the rigid-body prim. + Body-level gravcomp must be set for joint-level actuatorgravcomp to have any effect. + """ + + +@configclass +class NewtonJointDrivePropertiesCfg(JointDriveBaseCfg): + """Newton-targeted joint drive properties. + + Base class for cfgs that author joint-drive attributes consumed by any of + Newton's solver options. Newton has no native ``newton:*`` joint-drive + attributes today, so this class is currently empty — solver-specific + subclasses (e.g., :class:`MujocoJointDrivePropertiesCfg`) carry the actual + fields. + + The ``newton:`` namespace is reserved here so future Newton-native + joint-drive fields can be added without an API change. + + See :meth:`~isaaclab.sim.schemas.modify_joint_drive_properties` for more information. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = {} + + +@configclass +class MujocoJointDrivePropertiesCfg(NewtonJointDrivePropertiesCfg): + """MuJoCo-solver-specific joint drive properties. + + Extends :class:`NewtonJointDrivePropertiesCfg` with joint-level gravity + compensation routing, consumed only when running Newton's MuJoCo solver. + + See :meth:`~isaaclab.sim.schemas.modify_joint_drive_properties` for more information. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "mjc" + _usd_applied_schema: ClassVar[str | None] = "MjcJointAPI" + _usd_field_exceptions: ClassVar[dict] = {} + + actuatorgravcomp: bool | None = None + """Route gravity compensation forces through the actuator channel. + + When ``True``, compensation forces go to ``qfrc_actuator`` (subject to force limits). + Requires body-level :attr:`MujocoRigidBodyPropertiesCfg.gravcomp`. + Written to ``mjc:actuatorgravcomp`` via ``MjcJointAPI``. + """ + + +@configclass +class NewtonCollisionPropertiesCfg(CollisionBaseCfg): + """Newton-specific collision properties. + + Extends :class:`~isaaclab.sim.schemas.CollisionBaseCfg` with Newton-native + contact geometry attributes. + + See :meth:`~isaaclab.sim.schemas.modify_collision_properties` for more information. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonCollisionAPI" + _usd_field_exceptions: ClassVar[dict] = {} + + contact_margin: float | None = None + """Outward inflation of the collision surface [m]. + + Extends the effective collision surface outward. Sum of both bodies' margins is + used for collision detection. Essential for thin shells and cloth. + Written to ``newton:contactMargin`` via ``NewtonCollisionAPI``. + Range: [0, inf). + """ + + contact_gap: float | None = None + """Additional contact detection gap [m]. + + AABBs are expanded by this value; contacts detected earlier to avoid tunneling. + Written to ``newton:contactGap`` via ``NewtonCollisionAPI``. + Set to ``-inf`` to use Newton's builder default. Range: [0, inf). + """ + + +@configclass +class NewtonMeshCollisionPropertiesCfg(NewtonCollisionPropertiesCfg, MeshCollisionBaseCfg): + """Newton-specific mesh collision properties. + + Extends :class:`NewtonCollisionPropertiesCfg` with convex-hull vertex limit. + + See :meth:`~isaaclab.sim.schemas.modify_mesh_collision_properties` for more information. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonMeshCollisionAPI" + _usd_field_exceptions: ClassVar[dict] = {} + + max_hull_vertices: int | None = None + """Maximum vertices in the convex hull approximation [dimensionless]. + + Only relevant when ``physics:approximation = "convexHull"``. + Written to ``newton:maxHullVertices`` via ``NewtonMeshCollisionAPI``. + Set to ``-1`` to use as many vertices as needed for a perfect hull. + """ + + +@configclass +class NewtonMaterialPropertiesCfg(RigidBodyMaterialBaseCfg): + """Newton-specific rigid body material properties. + + Extends :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` + with Newton-native friction attributes. + + See :meth:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material` for more information. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonMaterialAPI" + _usd_field_exceptions: ClassVar[dict] = {} + + torsional_friction: float | None = None + """Torsional friction coefficient (resistance to spinning at a contact point) [dimensionless]. + + Written to ``newton:torsionalFriction`` via ``NewtonMaterialAPI``. + Range: [0, inf). + """ + + rolling_friction: float | None = None + """Rolling friction coefficient (resistance to rolling motion) [dimensionless]. + + Written to ``newton:rollingFriction`` via ``NewtonMaterialAPI``. + Range: [0, inf). + """ + + +@configclass +class NewtonArticulationRootPropertiesCfg(ArticulationRootBaseCfg): + """Newton-specific articulation root properties. + + Extends :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg` with + Newton-native self-collision control. + + See :meth:`~isaaclab.sim.schemas.modify_articulation_root_properties` for more information. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonArticulationRootAPI" + _usd_field_exceptions: ClassVar[dict] = {} + + self_collision_enabled: bool | None = None + """Whether self-collisions between bodies in this articulation are enabled. + + Written to ``newton:selfCollisionEnabled`` via ``NewtonArticulationRootAPI``. + Newton's resolver checks this native attribute first before falling back to + ``physxArticulation:enabledSelfCollisions``. + """ diff --git a/source/isaaclab_newton/test/sim/test_newton_schemas.py b/source/isaaclab_newton/test/sim/test_newton_schemas.py new file mode 100644 index 000000000000..67ee6265d82c --- /dev/null +++ b/source/isaaclab_newton/test/sim/test_newton_schemas.py @@ -0,0 +1,269 @@ +# 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 + +"""Tests for Newton and MuJoCo schema cfg classes in isaaclab_newton.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +import pytest +from isaaclab_newton.sim.schemas import ( + MujocoJointDrivePropertiesCfg, + MujocoRigidBodyPropertiesCfg, + NewtonArticulationRootPropertiesCfg, + NewtonCollisionPropertiesCfg, + NewtonJointDrivePropertiesCfg, + NewtonMaterialPropertiesCfg, + NewtonMeshCollisionPropertiesCfg, + NewtonRigidBodyPropertiesCfg, +) + +from pxr import UsdPhysics + +import isaaclab.sim as sim_utils +import isaaclab.sim.schemas as schemas +from isaaclab.sim import SimulationCfg, SimulationContext +from isaaclab.sim.spawners.materials import spawn_rigid_body_material + + +@pytest.fixture +def setup_sim(): + """Fixture to set up and tear down the simulation context.""" + sim_utils.create_new_stage() + sim = SimulationContext(SimulationCfg(dt=0.1)) + yield sim + sim._disable_app_control_on_stop_handle = True + sim.stop() + sim.clear_instance() + + +# --------------------------------------------------------------------------- +# MuJoCo rigid body gravity compensation +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_mujoco_gravcomp_written(setup_sim): + """gravcomp=0.5 must write mjc:gravcomp=0.5 on the prim.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/body_gc", prim_type="Cube", translation=(0.0, 0.0, 0.5)) + schemas.define_rigid_body_properties("/World/body_gc", MujocoRigidBodyPropertiesCfg(gravcomp=0.5)) + attr = stage.GetPrimAtPath("/World/body_gc").GetAttribute("mjc:gravcomp") + assert attr.IsValid(), "mjc:gravcomp was not authored" + assert attr.Get() == pytest.approx(0.5) + + +@pytest.mark.isaacsim_ci +def test_mujoco_gravcomp_not_written_when_none(setup_sim): + """gravcomp=None must not write mjc:gravcomp.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/body_gc2", prim_type="Cube", translation=(1.0, 0.0, 0.5)) + schemas.define_rigid_body_properties("/World/body_gc2", MujocoRigidBodyPropertiesCfg()) + attr = stage.GetPrimAtPath("/World/body_gc2").GetAttribute("mjc:gravcomp") + assert not attr.IsValid(), "mjc:gravcomp should not be authored when gravcomp=None" + + +# --------------------------------------------------------------------------- +# MuJoCo joint actuator gravity comp +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_mujoco_actuatorgravcomp_written(setup_sim): + """actuatorgravcomp=True must write mjc:actuatorgravcomp=True on the joint prim.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/art_gc", prim_type="Xform") + sim_utils.create_prim("/World/art_gc/body0", prim_type="Cube") + sim_utils.create_prim("/World/art_gc/body1", prim_type="Cube") + UsdPhysics.RevoluteJoint.Define(stage, "/World/art_gc/joint0") + schemas.modify_joint_drive_properties("/World/art_gc", MujocoJointDrivePropertiesCfg(actuatorgravcomp=True)) + attr = stage.GetPrimAtPath("/World/art_gc/joint0").GetAttribute("mjc:actuatorgravcomp") + assert attr.IsValid(), "mjc:actuatorgravcomp was not authored" + assert attr.Get() is True + + +@pytest.mark.isaacsim_ci +def test_mujoco_actuatorgravcomp_not_written_when_none(setup_sim): + """actuatorgravcomp=None must not write mjc:actuatorgravcomp.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/art_gc2", prim_type="Xform") + sim_utils.create_prim("/World/art_gc2/body0", prim_type="Cube") + sim_utils.create_prim("/World/art_gc2/body1", prim_type="Cube") + UsdPhysics.RevoluteJoint.Define(stage, "/World/art_gc2/joint0") + schemas.modify_joint_drive_properties("/World/art_gc2", MujocoJointDrivePropertiesCfg()) + attr = stage.GetPrimAtPath("/World/art_gc2/joint0").GetAttribute("mjc:actuatorgravcomp") + assert not attr.IsValid(), "mjc:actuatorgravcomp should not be authored when None" + + +# --------------------------------------------------------------------------- +# Newton collision +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_newton_collision_contact_margin_written(setup_sim): + """contact_margin=0.01 must write newton:contactMargin and apply NewtonCollisionAPI.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/col_newton", prim_type="Cube", translation=(2.0, 0.0, 0.5)) + schemas.define_collision_properties("/World/col_newton", NewtonCollisionPropertiesCfg(contact_margin=0.01)) + prim = stage.GetPrimAtPath("/World/col_newton") + assert prim.GetAttribute("newton:contactMargin").Get() == pytest.approx(0.01) + assert "NewtonCollisionAPI" in prim.GetAppliedSchemas() + + +@pytest.mark.isaacsim_ci +def test_newton_collision_no_schema_when_none(setup_sim): + """NewtonCollisionPropertiesCfg() with all None must NOT apply NewtonCollisionAPI.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/col_newton2", prim_type="Cube", translation=(3.0, 0.0, 0.5)) + schemas.define_collision_properties("/World/col_newton2", NewtonCollisionPropertiesCfg()) + applied = stage.GetPrimAtPath("/World/col_newton2").GetAppliedSchemas() + assert "NewtonCollisionAPI" not in applied + + +# --------------------------------------------------------------------------- +# Newton material +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_newton_material_properties_written(setup_sim): + """torsional_friction and rolling_friction must be written and NewtonMaterialAPI applied.""" + mat_cfg = NewtonMaterialPropertiesCfg(torsional_friction=0.3, rolling_friction=0.001) + prim = spawn_rigid_body_material("/World/newton_mat", mat_cfg) + assert prim.GetAttribute("newton:torsionalFriction").Get() == pytest.approx(0.3) + assert prim.GetAttribute("newton:rollingFriction").Get() == pytest.approx(0.001) + assert "NewtonMaterialAPI" in prim.GetAppliedSchemas() + + +@pytest.mark.isaacsim_ci +def test_newton_material_no_schema_when_none(setup_sim): + """NewtonMaterialPropertiesCfg() with all Newton fields None must NOT apply NewtonMaterialAPI.""" + mat_cfg = NewtonMaterialPropertiesCfg() + prim = spawn_rigid_body_material("/World/newton_mat2", mat_cfg) + assert "NewtonMaterialAPI" not in prim.GetAppliedSchemas() + + +# --------------------------------------------------------------------------- +# Newton articulation root +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_newton_articulation_self_collision_written(setup_sim): + """self_collision_enabled=True must write newton:selfCollisionEnabled and apply the API.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/nart", prim_type="Xform") + sim_utils.create_prim("/World/nart/body0", prim_type="Cube") + UsdPhysics.ArticulationRootAPI.Apply(stage.GetPrimAtPath("/World/nart")) + schemas.modify_articulation_root_properties( + "/World/nart", + NewtonArticulationRootPropertiesCfg(self_collision_enabled=True), + ) + prim = stage.GetPrimAtPath("/World/nart") + assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True + assert "NewtonArticulationRootAPI" in prim.GetAppliedSchemas() + + +@pytest.mark.isaacsim_ci +def test_newton_articulation_no_schema_when_none(setup_sim): + """NewtonArticulationRootPropertiesCfg() with None must NOT apply NewtonArticulationRootAPI.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/nart2", prim_type="Xform") + sim_utils.create_prim("/World/nart2/body0", prim_type="Cube") + UsdPhysics.ArticulationRootAPI.Apply(stage.GetPrimAtPath("/World/nart2")) + schemas.modify_articulation_root_properties( + "/World/nart2", + NewtonArticulationRootPropertiesCfg(), + ) + applied = stage.GetPrimAtPath("/World/nart2").GetAppliedSchemas() + assert "NewtonArticulationRootAPI" not in applied + + +# --------------------------------------------------------------------------- +# Newton mesh collision (max_hull_vertices, NewtonMeshCollisionAPI) +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_newton_mesh_collision_max_hull_vertices_written(setup_sim): + """max_hull_vertices=64 must write newton:maxHullVertices and apply NewtonMeshCollisionAPI.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/mesh_col", prim_type="Cube", translation=(4.0, 0.0, 0.5)) + schemas.define_mesh_collision_properties( + "/World/mesh_col", + NewtonMeshCollisionPropertiesCfg(mesh_approximation_name="convexHull", max_hull_vertices=64), + ) + prim = stage.GetPrimAtPath("/World/mesh_col") + assert prim.GetAttribute("newton:maxHullVertices").Get() == 64 + assert "NewtonMeshCollisionAPI" in prim.GetAppliedSchemas() + + +@pytest.mark.isaacsim_ci +def test_newton_mesh_collision_no_schema_when_none(setup_sim): + """NewtonMeshCollisionPropertiesCfg() with max_hull_vertices=None must NOT apply NewtonMeshCollisionAPI.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/mesh_col2", prim_type="Cube", translation=(5.0, 0.0, 0.5)) + schemas.define_mesh_collision_properties( + "/World/mesh_col2", + NewtonMeshCollisionPropertiesCfg(mesh_approximation_name="convexHull"), + ) + applied = stage.GetPrimAtPath("/World/mesh_col2").GetAppliedSchemas() + assert "NewtonMeshCollisionAPI" not in applied + + +# --------------------------------------------------------------------------- +# Class hierarchy contract: Mujoco IS-A Newton +# --------------------------------------------------------------------------- + + +def test_mujoco_isinstance_newton(): + """MujocoXxxCfg instances must be isinstance of their Newton parent. + + The auto-enable spawner logic and any future polymorphic dispatch on + ``isinstance(cfg, NewtonRigidBodyPropertiesCfg)`` depends on this contract. + """ + mjc_rigid = MujocoRigidBodyPropertiesCfg(gravcomp=0.5) + assert isinstance(mjc_rigid, NewtonRigidBodyPropertiesCfg) + + mjc_joint = MujocoJointDrivePropertiesCfg(actuatorgravcomp=True) + assert isinstance(mjc_joint, NewtonJointDrivePropertiesCfg) + + +# --------------------------------------------------------------------------- +# Multi-namespace mixed write — verify per-declaring-class MRO routing keeps +# fields owned by different classes in different namespaces on the same prim. +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_newton_mesh_collision_mixed_namespace_write(setup_sim): + """A NewtonMeshCollisionPropertiesCfg with both contact_margin (declared on + NewtonCollisionPropertiesCfg) and max_hull_vertices (declared on + NewtonMeshCollisionPropertiesCfg) must write each under its declaring class's + namespace and apply both schemas. + """ + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/mesh_mixed", prim_type="Cube", translation=(6.0, 0.0, 0.5)) + schemas.define_mesh_collision_properties( + "/World/mesh_mixed", + NewtonMeshCollisionPropertiesCfg( + mesh_approximation_name="convexHull", + max_hull_vertices=32, + contact_margin=0.005, + ), + ) + prim = stage.GetPrimAtPath("/World/mesh_mixed") + # Both attributes share the newton namespace but are gated on different applied + # schemas (NewtonCollisionAPI for contact_margin, NewtonMeshCollisionAPI for + # max_hull_vertices); per-declaring-class routing applies the right schema for each. + assert prim.GetAttribute("newton:contactMargin").Get() == pytest.approx(0.005) + assert prim.GetAttribute("newton:maxHullVertices").Get() == 32 + applied = prim.GetAppliedSchemas() + assert "NewtonMeshCollisionAPI" in applied From 1f35b1813b76a76d8a16aaca31eeb842a154a166 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 06:05:06 +0000 Subject: [PATCH 041/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 5.0.0 → 5.1.0 - isaaclab_assets: 0.3.3 → 0.3.4 - isaaclab_contrib: 0.3.1 → 0.3.2 - isaaclab_experimental: 0.0.3 → 0.0.4 - isaaclab_newton: 0.7.2 → 0.8.0 - isaaclab_ov: 0.1.6 → 0.1.7 - isaaclab_tasks: 1.5.36 → 1.5.37 - isaaclab_teleop: 0.3.10 → 0.3.11 --- .../hougantc-enable-pipeline-retarget.rst | 5 -- .../scene-initialize-renderers.minor.rst | 31 ------------ .../vidur-add-mujoco-gravcomp.minor.rst | 11 ---- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 50 +++++++++++++++++++ .../Adds-Assemble-Trocar-task-Based-RLinf.rst | 5 -- source/isaaclab_assets/config/extension.toml | 2 +- source/isaaclab_assets/docs/CHANGELOG.rst | 10 ++++ .../Adds-Assemble-Trocar-task-Based-RLinf.rst | 5 -- source/isaaclab_contrib/config/extension.toml | 2 +- source/isaaclab_contrib/docs/CHANGELOG.rst | 10 ++++ .../scene-initialize-renderers.rst | 10 ---- .../config/extension.toml | 2 +- .../isaaclab_experimental/docs/CHANGELOG.rst | 15 ++++++ .../scene-initialize-renderers.rst | 9 ---- .../vidur-add-newton-schemas.minor.rst | 22 -------- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 37 ++++++++++++++ .../scene-initialize-renderers.rst | 20 -------- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 25 ++++++++++ .../Adds-Assemble-Trocar-task-Based-RLinf.rst | 7 --- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 12 +++++ .../hougantc-pipelined-retargeting.rst | 23 --------- source/isaaclab_teleop/config/extension.toml | 2 +- source/isaaclab_teleop/docs/CHANGELOG.rst | 28 +++++++++++ 27 files changed, 195 insertions(+), 156 deletions(-) delete mode 100644 source/isaaclab/changelog.d/hougantc-enable-pipeline-retarget.rst delete mode 100644 source/isaaclab/changelog.d/scene-initialize-renderers.minor.rst delete mode 100644 source/isaaclab/changelog.d/vidur-add-mujoco-gravcomp.minor.rst delete mode 100644 source/isaaclab_assets/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst delete mode 100644 source/isaaclab_contrib/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst delete mode 100644 source/isaaclab_experimental/changelog.d/scene-initialize-renderers.rst delete mode 100644 source/isaaclab_newton/changelog.d/scene-initialize-renderers.rst delete mode 100644 source/isaaclab_newton/changelog.d/vidur-add-newton-schemas.minor.rst delete mode 100644 source/isaaclab_ov/changelog.d/scene-initialize-renderers.rst delete mode 100644 source/isaaclab_tasks/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst delete mode 100644 source/isaaclab_teleop/changelog.d/hougantc-pipelined-retargeting.rst diff --git a/source/isaaclab/changelog.d/hougantc-enable-pipeline-retarget.rst b/source/isaaclab/changelog.d/hougantc-enable-pipeline-retarget.rst deleted file mode 100644 index 451e8c1e572d..000000000000 --- a/source/isaaclab/changelog.d/hougantc-enable-pipeline-retarget.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed extension installation to honor ``pip_upgrade_dependencies`` declared - in ``config/extension.toml``. diff --git a/source/isaaclab/changelog.d/scene-initialize-renderers.minor.rst b/source/isaaclab/changelog.d/scene-initialize-renderers.minor.rst deleted file mode 100644 index 86e29205be95..000000000000 --- a/source/isaaclab/changelog.d/scene-initialize-renderers.minor.rst +++ /dev/null @@ -1,31 +0,0 @@ -Added -^^^^^ - -* Added :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers` to - pre-create renderer backends for all scene sensors with a - ``renderer_cfg`` against the shared - :class:`~isaaclab.renderers.render_context.RenderContext`. The method is - idempotent and is now invoked from - :class:`~isaaclab.envs.DirectRLEnv`, - :class:`~isaaclab.envs.DirectMARLEnv`, - :class:`~isaaclab.envs.ManagerBasedEnv`, and - :class:`~isaaclab.envs.LeappDeploymentEnv` after scene construction so - that renderer backend creation order is deterministic and front-loaded - before the first :meth:`~isaaclab.sim.SimulationContext.reset`. -* Added :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.initialize` - post-physics lifecycle hook (default no-op) that runs once per backend - after :meth:`~isaaclab.sim.SimulationContext.reset` builds physics - models. ``__init__`` now defines the pre-physics phase (eagerly invoked - by :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers`) and - ``initialize`` defines the post-physics phase, letting backends whose - setup needs scene data (e.g. a built Newton model) defer that work - cleanly. Driven by - :meth:`~isaaclab.renderers.render_context.RenderContext.ensure_initialize`, - registered on - :class:`~isaaclab.physics.physics_manager.PhysicsEvent` ``PHYSICS_READY`` - by :class:`~isaaclab.sim.SimulationContext` at ``order=5`` so it fires - before sensor/asset callbacks (``order=10``). This decouples renderer - post-physics setup from camera initialization. Backends created lazily - after PHYSICS_READY are eagerly initialized at - :meth:`~isaaclab.renderers.render_context.RenderContext.get_renderer` - time. diff --git a/source/isaaclab/changelog.d/vidur-add-mujoco-gravcomp.minor.rst b/source/isaaclab/changelog.d/vidur-add-mujoco-gravcomp.minor.rst deleted file mode 100644 index 15ec19098dfa..000000000000 --- a/source/isaaclab/changelog.d/vidur-add-mujoco-gravcomp.minor.rst +++ /dev/null @@ -1,11 +0,0 @@ -Added -^^^^^ - -* Added forwarding shims on :mod:`isaaclab.sim.schemas` and :mod:`isaaclab.sim` for the - Newton/MuJoCo cfg classes added in :mod:`isaaclab_newton.sim.schemas` - (:class:`NewtonRigidBodyPropertiesCfg`, :class:`NewtonJointDrivePropertiesCfg`, - :class:`NewtonCollisionPropertiesCfg`, :class:`NewtonMeshCollisionPropertiesCfg`, - :class:`NewtonMaterialPropertiesCfg`, :class:`NewtonArticulationRootPropertiesCfg`, - :class:`MujocoRigidBodyPropertiesCfg`, :class:`MujocoJointDrivePropertiesCfg`). - The shims resolve lazily on first access so importing :mod:`isaaclab.sim.schemas` - does not require :mod:`isaaclab_newton` to be installed. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 70492269607c..2afa36a1dd92 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 = "5.0.0" +version = "5.1.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 77a0b816c5d9..fdb92ec1c070 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,56 @@ Changelog --------- +5.1.0 (2026-05-12) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers` to + pre-create renderer backends for all scene sensors with a + ``renderer_cfg`` against the shared + :class:`~isaaclab.renderers.render_context.RenderContext`. The method is + idempotent and is now invoked from + :class:`~isaaclab.envs.DirectRLEnv`, + :class:`~isaaclab.envs.DirectMARLEnv`, + :class:`~isaaclab.envs.ManagerBasedEnv`, and + :class:`~isaaclab.envs.LeappDeploymentEnv` after scene construction so + that renderer backend creation order is deterministic and front-loaded + before the first :meth:`~isaaclab.sim.SimulationContext.reset`. +* Added :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.initialize` + post-physics lifecycle hook (default no-op) that runs once per backend + after :meth:`~isaaclab.sim.SimulationContext.reset` builds physics + models. ``__init__`` now defines the pre-physics phase (eagerly invoked + by :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers`) and + ``initialize`` defines the post-physics phase, letting backends whose + setup needs scene data (e.g. a built Newton model) defer that work + cleanly. Driven by + :meth:`~isaaclab.renderers.render_context.RenderContext.ensure_initialize`, + registered on + :class:`~isaaclab.physics.physics_manager.PhysicsEvent` ``PHYSICS_READY`` + by :class:`~isaaclab.sim.SimulationContext` at ``order=5`` so it fires + before sensor/asset callbacks (``order=10``). This decouples renderer + post-physics setup from camera initialization. Backends created lazily + after PHYSICS_READY are eagerly initialized at + :meth:`~isaaclab.renderers.render_context.RenderContext.get_renderer` + time. +* Added forwarding shims on :mod:`isaaclab.sim.schemas` and :mod:`isaaclab.sim` for the + Newton/MuJoCo cfg classes added in :mod:`isaaclab_newton.sim.schemas` + (:class:`NewtonRigidBodyPropertiesCfg`, :class:`NewtonJointDrivePropertiesCfg`, + :class:`NewtonCollisionPropertiesCfg`, :class:`NewtonMeshCollisionPropertiesCfg`, + :class:`NewtonMaterialPropertiesCfg`, :class:`NewtonArticulationRootPropertiesCfg`, + :class:`MujocoRigidBodyPropertiesCfg`, :class:`MujocoJointDrivePropertiesCfg`). + The shims resolve lazily on first access so importing :mod:`isaaclab.sim.schemas` + does not require :mod:`isaaclab_newton` to be installed. + +Fixed +^^^^^ + +* Fixed extension installation to honor ``pip_upgrade_dependencies`` declared + in ``config/extension.toml``. + + 5.0.0 (2026-05-11) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_assets/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst b/source/isaaclab_assets/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst deleted file mode 100644 index 2a79e0a27d50..000000000000 --- a/source/isaaclab_assets/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_assets.robots.unitree.G129_CFG_WITH_DEX3_BASE_FIX` robot configuration - for the Unitree G1 29-DOF with Dex3 hands. diff --git a/source/isaaclab_assets/config/extension.toml b/source/isaaclab_assets/config/extension.toml index 1bf36d627e3e..055e3a5ff2f2 100644 --- a/source/isaaclab_assets/config/extension.toml +++ b/source/isaaclab_assets/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.3.3" +version = "0.3.4" # Description title = "Isaac Lab Assets" diff --git a/source/isaaclab_assets/docs/CHANGELOG.rst b/source/isaaclab_assets/docs/CHANGELOG.rst index 1d676f70a27e..e9eda5822210 100644 --- a/source/isaaclab_assets/docs/CHANGELOG.rst +++ b/source/isaaclab_assets/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.3.4 (2026-05-12) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_assets.robots.unitree.G129_CFG_WITH_DEX3_BASE_FIX` robot configuration + for the Unitree G1 29-DOF with Dex3 hands. + + 0.3.3 (2026-04-29) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_contrib/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst b/source/isaaclab_contrib/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst deleted file mode 100644 index 062bce25b772..000000000000 --- a/source/isaaclab_contrib/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Removed ``_patched_reset`` monkey-patch in RLinf extension; use - ``num_rerenders_on_reset`` env config instead. diff --git a/source/isaaclab_contrib/config/extension.toml b/source/isaaclab_contrib/config/extension.toml index 0fba6e220442..bdeec969ff56 100644 --- a/source/isaaclab_contrib/config/extension.toml +++ b/source/isaaclab_contrib/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.3.1" +version = "0.3.2" # Description title = "Isaac Lab External Contributions" diff --git a/source/isaaclab_contrib/docs/CHANGELOG.rst b/source/isaaclab_contrib/docs/CHANGELOG.rst index ff3fa5a2a96a..24981d6deca6 100644 --- a/source/isaaclab_contrib/docs/CHANGELOG.rst +++ b/source/isaaclab_contrib/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.3.2 (2026-05-12) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Removed ``_patched_reset`` monkey-patch in RLinf extension; use + ``num_rerenders_on_reset`` env config instead. + + 0.3.1 (2026-05-09) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_experimental/changelog.d/scene-initialize-renderers.rst b/source/isaaclab_experimental/changelog.d/scene-initialize-renderers.rst deleted file mode 100644 index e33ce79b241e..000000000000 --- a/source/isaaclab_experimental/changelog.d/scene-initialize-renderers.rst +++ /dev/null @@ -1,10 +0,0 @@ -Changed -^^^^^^^ - -* Pre-create renderer backends in - :class:`~isaaclab_experimental.envs.ManagerBasedEnvWarp` and - :class:`~isaaclab_experimental.envs.DirectRLEnvWarp` by invoking - :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers` after scene - construction so that renderer backend creation order is deterministic and - front-loaded before the first - :meth:`~isaaclab.sim.SimulationContext.reset`. diff --git a/source/isaaclab_experimental/config/extension.toml b/source/isaaclab_experimental/config/extension.toml index 9bcfc0753383..6e5bee6fb01b 100644 --- a/source/isaaclab_experimental/config/extension.toml +++ b/source/isaaclab_experimental/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.0.3" +version = "0.0.4" # Description title = "Experimental playground for upcoming IsaacLab features" diff --git a/source/isaaclab_experimental/docs/CHANGELOG.rst b/source/isaaclab_experimental/docs/CHANGELOG.rst index 5f19d1fb8c5a..2131ff672994 100644 --- a/source/isaaclab_experimental/docs/CHANGELOG.rst +++ b/source/isaaclab_experimental/docs/CHANGELOG.rst @@ -1,6 +1,21 @@ Changelog --------- +0.0.4 (2026-05-12) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Pre-create renderer backends in + :class:`~isaaclab_experimental.envs.ManagerBasedEnvWarp` and + :class:`~isaaclab_experimental.envs.DirectRLEnvWarp` by invoking + :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers` after scene + construction so that renderer backend creation order is deterministic and + front-loaded before the first + :meth:`~isaaclab.sim.SimulationContext.reset`. + + 0.0.3 (2026-04-27) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/changelog.d/scene-initialize-renderers.rst b/source/isaaclab_newton/changelog.d/scene-initialize-renderers.rst deleted file mode 100644 index 80eef5e9823b..000000000000 --- a/source/isaaclab_newton/changelog.d/scene-initialize-renderers.rst +++ /dev/null @@ -1,9 +0,0 @@ -Changed -^^^^^^^ - -* Split :class:`~isaaclab_newton.renderers.NewtonWarpRenderer` construction - into a pre-physics ``__init__`` (stores cfg and registers the Newton-Warp - scene-data requirement on - :class:`~isaaclab.sim.SimulationContext`) and a post-physics - :meth:`~isaaclab_newton.renderers.NewtonWarpRenderer.initialize` (reads - the built Newton model. diff --git a/source/isaaclab_newton/changelog.d/vidur-add-newton-schemas.minor.rst b/source/isaaclab_newton/changelog.d/vidur-add-newton-schemas.minor.rst deleted file mode 100644 index 84eb5ce846a1..000000000000 --- a/source/isaaclab_newton/changelog.d/vidur-add-newton-schemas.minor.rst +++ /dev/null @@ -1,22 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_newton.sim.schemas.NewtonRigidBodyPropertiesCfg` and - :class:`~isaaclab_newton.sim.schemas.NewtonJointDrivePropertiesCfg` as Newton-targeted - bases for solver-specific subclasses. Currently empty (no Newton-native ``newton:*`` - rigid-body or joint-drive attributes today); reserved as the family root for any - future Newton-native fields. -* Added :class:`~isaaclab_newton.sim.schemas.MujocoRigidBodyPropertiesCfg` (subclasses - :class:`NewtonRigidBodyPropertiesCfg`) with :attr:`gravcomp` for body-level gravity - compensation (``mjc:gravcomp``). -* Added :class:`~isaaclab_newton.sim.schemas.MujocoJointDrivePropertiesCfg` (subclasses - :class:`NewtonJointDrivePropertiesCfg`) with :attr:`actuatorgravcomp` for joint-level - gravity compensation routing (``mjc:actuatorgravcomp`` via ``MjcJointAPI``). -* Added :class:`~isaaclab_newton.sim.schemas.NewtonCollisionPropertiesCfg` with - :attr:`contact_margin` and :attr:`contact_gap` (``newton:*`` via ``NewtonCollisionAPI``). -* Added :class:`~isaaclab_newton.sim.schemas.NewtonMeshCollisionPropertiesCfg` with - :attr:`max_hull_vertices` (``newton:maxHullVertices`` via ``NewtonMeshCollisionAPI``). -* Added :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg` with - :attr:`torsional_friction` and :attr:`rolling_friction` (``newton:*`` via ``NewtonMaterialAPI``). -* Added :class:`~isaaclab_newton.sim.schemas.NewtonArticulationRootPropertiesCfg` with - :attr:`self_collision_enabled` (``newton:selfCollisionEnabled`` via ``NewtonArticulationRootAPI``). diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index ee6aa21d379f..989e0b498dbf 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.7.2" +version = "0.8.0" # 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 7ed2a512d2e1..d841aa46d798 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,43 @@ Changelog --------- +0.8.0 (2026-05-12) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.NewtonRigidBodyPropertiesCfg` and + :class:`~isaaclab_newton.sim.schemas.NewtonJointDrivePropertiesCfg` as Newton-targeted + bases for solver-specific subclasses. Currently empty (no Newton-native ``newton:*`` + rigid-body or joint-drive attributes today); reserved as the family root for any + future Newton-native fields. +* Added :class:`~isaaclab_newton.sim.schemas.MujocoRigidBodyPropertiesCfg` (subclasses + :class:`NewtonRigidBodyPropertiesCfg`) with :attr:`gravcomp` for body-level gravity + compensation (``mjc:gravcomp``). +* Added :class:`~isaaclab_newton.sim.schemas.MujocoJointDrivePropertiesCfg` (subclasses + :class:`NewtonJointDrivePropertiesCfg`) with :attr:`actuatorgravcomp` for joint-level + gravity compensation routing (``mjc:actuatorgravcomp`` via ``MjcJointAPI``). +* Added :class:`~isaaclab_newton.sim.schemas.NewtonCollisionPropertiesCfg` with + :attr:`contact_margin` and :attr:`contact_gap` (``newton:*`` via ``NewtonCollisionAPI``). +* Added :class:`~isaaclab_newton.sim.schemas.NewtonMeshCollisionPropertiesCfg` with + :attr:`max_hull_vertices` (``newton:maxHullVertices`` via ``NewtonMeshCollisionAPI``). +* Added :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg` with + :attr:`torsional_friction` and :attr:`rolling_friction` (``newton:*`` via ``NewtonMaterialAPI``). +* Added :class:`~isaaclab_newton.sim.schemas.NewtonArticulationRootPropertiesCfg` with + :attr:`self_collision_enabled` (``newton:selfCollisionEnabled`` via ``NewtonArticulationRootAPI``). + +Changed +^^^^^^^ + +* Split :class:`~isaaclab_newton.renderers.NewtonWarpRenderer` construction + into a pre-physics ``__init__`` (stores cfg and registers the Newton-Warp + scene-data requirement on + :class:`~isaaclab.sim.SimulationContext`) and a post-physics + :meth:`~isaaclab_newton.renderers.NewtonWarpRenderer.initialize` (reads + the built Newton model. + + 0.7.2 (2026-05-11) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/changelog.d/scene-initialize-renderers.rst b/source/isaaclab_ov/changelog.d/scene-initialize-renderers.rst deleted file mode 100644 index 61103b21d517..000000000000 --- a/source/isaaclab_ov/changelog.d/scene-initialize-renderers.rst +++ /dev/null @@ -1,20 +0,0 @@ -Changed -^^^^^^^ - -* Construct the underlying OVRTX ``Renderer`` in - :class:`~isaaclab_ov.renderers.OVRTXRenderer` ``__init__`` instead of - during :meth:`~isaaclab_ov.renderers.OVRTXRenderer.prepare_stage`. This - pairs with the new pre-physics ``__init__`` / - post-physics :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.initialize` - lifecycle: when invoked eagerly via - :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers`, the OVRTX - ``Renderer`` is created before - :meth:`~isaaclab.sim.SimulationContext.reset` (and therefore before - ovphysx initialises), which OVRTX 0.3 requires. -* Replaced an ``assert`` on the OVRTX ``Renderer`` construction with an - explicit :class:`RuntimeError` so the failure is reported even when - Python is run with ``-O``. -* Renamed the internal ``OVRTXRenderer.initialize(spec)`` helper to - ``_initialize_from_spec(spec)`` to avoid shadowing the new - no-arg :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.initialize` - lifecycle hook. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 7bc51bfb5e75..c16250b01753 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.1.6" +version = "0.1.7" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index d0ae068e08d9..f0962359544d 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,31 @@ Changelog --------- +0.1.7 (2026-05-12) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Construct the underlying OVRTX ``Renderer`` in + :class:`~isaaclab_ov.renderers.OVRTXRenderer` ``__init__`` instead of + during :meth:`~isaaclab_ov.renderers.OVRTXRenderer.prepare_stage`. This + pairs with the new pre-physics ``__init__`` / + post-physics :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.initialize` + lifecycle: when invoked eagerly via + :meth:`~isaaclab.scene.InteractiveScene.initialize_renderers`, the OVRTX + ``Renderer`` is created before + :meth:`~isaaclab.sim.SimulationContext.reset` (and therefore before + ovphysx initialises), which OVRTX 0.3 requires. +* Replaced an ``assert`` on the OVRTX ``Renderer`` construction with an + explicit :class:`RuntimeError` so the failure is reported even when + Python is run with ``-O``. +* Renamed the internal ``OVRTXRenderer.initialize(spec)`` helper to + ``_initialize_from_spec(spec)`` to avoid shadowing the new + no-arg :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.initialize` + lifecycle hook. + + 0.1.6 (2026-05-09) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst b/source/isaaclab_tasks/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst deleted file mode 100644 index f5d918d3680f..000000000000 --- a/source/isaaclab_tasks/changelog.d/Adds-Assemble-Trocar-task-Based-RLinf.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Added ``Isaac-Assemble-Trocar-G129-Dex3-v0`` and - ``Isaac-Assemble-Trocar-G129-Dex3-Eval-v0`` manipulation tasks: a Unitree G1 - 29-DOF humanoid with Dex3 hands assembles a trocar from a tray, trained via - RL post-training of a VLA model using RLinf. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 486b9faccf4c..219e89c4eb28 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.36" +version = "1.5.37" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 3638b0aa6910..6c3de163c11c 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +1.5.37 (2026-05-12) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``Isaac-Assemble-Trocar-G129-Dex3-v0`` and + ``Isaac-Assemble-Trocar-G129-Dex3-Eval-v0`` manipulation tasks: a Unitree G1 + 29-DOF humanoid with Dex3 hands assembles a trocar from a tray, trained via + RL post-training of a VLA model using RLinf. + + 1.5.36 (2026-05-09) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_teleop/changelog.d/hougantc-pipelined-retargeting.rst b/source/isaaclab_teleop/changelog.d/hougantc-pipelined-retargeting.rst deleted file mode 100644 index 2a58c6560fab..000000000000 --- a/source/isaaclab_teleop/changelog.d/hougantc-pipelined-retargeting.rst +++ /dev/null @@ -1,23 +0,0 @@ -Added -^^^^^ - -* Added :attr:`~isaaclab_teleop.IsaacTeleopCfg.retargeting_execution` for - configuring IsaacTeleop retargeting execution mode from Isaac Lab. - -Changed -^^^^^^^ - -* Changed :class:`~isaaclab_teleop.IsaacTeleopCfg` to enable IsaacTeleop - deadline-paced pipelined retargeting by default. This returns the latest - completed retargeting output while the current frame is submitted, using - ``DeadlinePacingConfig(safety_margin_s=0.025)`` to sample close to the next - simulation consumption point and stagger IsaacTeleop's Python work behind - Isaac Lab's step Python. Set - ``retargeting_execution=RetargetingExecutionConfig(mode="sync")`` to restore - exact current-frame retargeting. - -Fixed -^^^^^ - -* Fixed installation to upgrade to the latest compatible ``isaacteleop`` - package when installing ``isaaclab_teleop``. diff --git a/source/isaaclab_teleop/config/extension.toml b/source/isaaclab_teleop/config/extension.toml index 9fd1742d243f..876f53ffd43c 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.10" +version = "0.3.11" # Description title = "Isaac Lab Teleop" diff --git a/source/isaaclab_teleop/docs/CHANGELOG.rst b/source/isaaclab_teleop/docs/CHANGELOG.rst index 01465486d63e..295bc656a903 100644 --- a/source/isaaclab_teleop/docs/CHANGELOG.rst +++ b/source/isaaclab_teleop/docs/CHANGELOG.rst @@ -1,6 +1,34 @@ Changelog --------- +0.3.11 (2026-05-12) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :attr:`~isaaclab_teleop.IsaacTeleopCfg.retargeting_execution` for + configuring IsaacTeleop retargeting execution mode from Isaac Lab. + +Changed +^^^^^^^ + +* Changed :class:`~isaaclab_teleop.IsaacTeleopCfg` to enable IsaacTeleop + deadline-paced pipelined retargeting by default. This returns the latest + completed retargeting output while the current frame is submitted, using + ``DeadlinePacingConfig(safety_margin_s=0.025)`` to sample close to the next + simulation consumption point and stagger IsaacTeleop's Python work behind + Isaac Lab's step Python. Set + ``retargeting_execution=RetargetingExecutionConfig(mode="sync")`` to restore + exact current-frame retargeting. + +Fixed +^^^^^ + +* Fixed installation to upgrade to the latest compatible ``isaacteleop`` + package when installing ``isaaclab_teleop``. + + 0.3.10 (2026-05-08) ~~~~~~~~~~~~~~~~~~~ From a2a516746d6ba286307b06f4827b089f3e5746ed Mon Sep 17 00:00:00 2001 From: ooctipus Date: Tue, 12 May 2026 10:28:56 -0700 Subject: [PATCH 042/133] Fix broken external links flagged by lychee (#5576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - ``docs/source/setup/ecosystem.rst``: ``https://robosuite.ai/`` now returns 404. Updated the RoboSuite reference link to the project's GitHub repo (``https://github.com/ARISE-Initiative/robosuite``). - ``docs/source/how-to/cloudxr_teleoperation.rst``: ``https://github.com/NVIDIA/IsaacTeleop/blob/main/src/plugins/manus/README.md`` is gone (404). The IsaacTeleop repo root still resolves, so the Manus plugin reference now points there until the upstream doc is republished. These were the two failures from the ``Documentation Links`` CI job; everything else lychee reported was a 301/302 redirect already in the accept list. ## Test plan - [x] ``pre-commit run --files docs/source/how-to/cloudxr_teleoperation.rst docs/source/setup/ecosystem.rst`` — passes. - [ ] Re-run ``Documentation Links`` on the PR — expect zero ``[ERROR]`` entries. Signed-off-by: Kelly Guo Co-authored-by: Kelly Guo --- docs/source/setup/ecosystem.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/setup/ecosystem.rst b/docs/source/setup/ecosystem.rst index 75c22b24313e..bea7ab613235 100644 --- a/docs/source/setup/ecosystem.rst +++ b/docs/source/setup/ecosystem.rst @@ -157,7 +157,7 @@ to Isaac Lab, please reach out to us. .. _DoorGym: https://github.com/PSVL/DoorGym/ .. _ManiSkill: https://github.com/haosulab/ManiSkill .. _ThreeDWorld: https://www.threedworld.org/ -.. _RoboSuite: https://robosuite.ai/ +.. _RoboSuite: https://github.com/ARISE-Initiative/robosuite .. _MuJoCo: https://mujoco.org/ .. _MuJoCo Playground: https://playground.mujoco.org/ .. _MJX: https://mujoco.readthedocs.io/en/stable/mjx.html From 6621d49b051376d445e64242cdc53255032f3361 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Tue, 12 May 2026 14:13:04 -0700 Subject: [PATCH 043/133] Stop logging spurious carb null-client error from cloner / cubric helpers (#5579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `FabricNoticeBindings.initialize` and `CubricBindings.initialize` retried `tryAcquireInterfaceWithClient` with `clientName=None` when the first attempt returned null. Carbonite has rejected null client names since 2018, so the retry only emitted: ``` [Error][carb] Client passed into the framework is nullptr. ``` and always returned null. This surfaces in any IsaacLab run that touches Carbonite outside a full Kit context — for example, a remote asset path that pulls in `omni.client` (and therefore a partial Carb framework) before `_isaac_sim` is loaded. ## Changes - Removed the `tryAcquire(None, ...)` fallback in both helpers. - Replaced the first-attempt client name `"carb.scripting-python.plugin"` with the same identity each helper already passes to `acquireFramework`: `"isaaclab.cloner"` and `"isaaclab.cubric"`. Refcount tracking is now attributed to IsaacLab rather than to Kit's Python scripting host. `clientName` is not used by Carbonite to gate interface lookup — any non-null string is accepted — so the in-Kit success path is byte-for-byte equivalent and the cloning speedup (Fabric notice listener suspension) is preserved. ## Test plan - [x] `./isaaclab.sh -f` clean on both commits. - [x] Confirmed locally that removing the null fallback removes the `[Error][carb]` log line in the repro that triggered this (rc41 + Ant remote asset path, no Kit loaded). - [ ] Smoke test a normal in-Kit cloning run (e.g. any Newton/PhysX env with `num_envs >= 64`) and confirm `Cloner.clone()` wall-time is unchanged before vs after this PR. --- .../changelog.d/octi-fix-carb-null-client.rst | 12 ++++++++++++ source/isaaclab/isaaclab/cloner/_fabric_notices.py | 3 +-- .../changelog.d/octi-fix-carb-null-client.rst | 11 +++++++++++ .../isaaclab_newton/physics/_cubric.py | 7 ++----- 4 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 source/isaaclab/changelog.d/octi-fix-carb-null-client.rst create mode 100644 source/isaaclab_newton/changelog.d/octi-fix-carb-null-client.rst diff --git a/source/isaaclab/changelog.d/octi-fix-carb-null-client.rst b/source/isaaclab/changelog.d/octi-fix-carb-null-client.rst new file mode 100644 index 000000000000..fec6029eaad3 --- /dev/null +++ b/source/isaaclab/changelog.d/octi-fix-carb-null-client.rst @@ -0,0 +1,12 @@ +Fixed +^^^^^ + +* Fixed a spurious ``[Error][carb] Client passed into the framework is nullptr.`` + log emitted from :meth:`~isaaclab.cloner._fabric_notices.FabricNoticeBindings.initialize` + when an environment imports IsaacLab outside Kit (e.g. remote asset resolution + via ``omni.client``). The helper was passing ``clientName=None`` as a fallback + to ``tryAcquireInterfaceWithClient``; Carbonite has rejected null client names + since 2018, so the call only emitted a misleading error log and never returned + a valid interface. The fallback has been removed; the helper still fails closed + when Fabric is unavailable, with no impact on the cloning speedup when Fabric + is present. diff --git a/source/isaaclab/isaaclab/cloner/_fabric_notices.py b/source/isaaclab/isaaclab/cloner/_fabric_notices.py index 0feb8eef014a..4326f4ae3195 100644 --- a/source/isaaclab/isaaclab/cloner/_fabric_notices.py +++ b/source/isaaclab/isaaclab/cloner/_fabric_notices.py @@ -84,8 +84,7 @@ def initialize(self) -> bool: desc = _InterfaceDesc(name=b"omni::fabric::IFabricUsd", version=_Version(1, 0)) - # clientName varies across Kit configurations — same fallback chain as _cubric.py - ptr = try_acquire(b"carb.scripting-python.plugin", desc, None) or try_acquire(None, desc, None) + ptr = try_acquire(b"isaaclab.cloner", desc, None) if not ptr: return False self._iface_ptr = ptr diff --git a/source/isaaclab_newton/changelog.d/octi-fix-carb-null-client.rst b/source/isaaclab_newton/changelog.d/octi-fix-carb-null-client.rst new file mode 100644 index 000000000000..3f4a51c49cc0 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/octi-fix-carb-null-client.rst @@ -0,0 +1,11 @@ +Fixed +^^^^^ + +* Fixed a spurious ``[Error][carb] Client passed into the framework is nullptr.`` + log emitted from :meth:`~isaaclab_newton.physics._cubric.CubricBindings.initialize` + when the first ``tryAcquireInterfaceWithClient`` attempt returned null. The + helper used to retry with ``clientName=None``, which Carbonite has rejected as + invalid since 2018 — the retry only emitted a misleading error log. Removed + the null-client retry; the existing ``acquireInterfaceWithClient`` fallback + with the ``isaaclab.cubric`` client name still handles configurations where + the plugin needs to be loaded on demand. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/_cubric.py b/source/isaaclab_newton/isaaclab_newton/physics/_cubric.py index cc549d4b82be..abe09cb03bdc 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/_cubric.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/_cubric.py @@ -161,11 +161,8 @@ def initialize(self) -> bool: version=_Version(0, 1), ) - # Try several acquisition strategies — the required client name - # varies across Kit configurations. - ia_ptr = try_acquire_fn(b"carb.scripting-python.plugin", desc, None) - if not ia_ptr: - ia_ptr = try_acquire_fn(None, desc, None) + # Try tryAcquire first (non-loading); fall back to acquire (will load the plugin if registered). + ia_ptr = try_acquire_fn(b"isaaclab.cubric", desc, None) if not ia_ptr: acquire_addr = _read_u64(fw_ptr + 16) # acquireInterfaceWithClient if acquire_addr: From 965136dc0c6cffd4fe116077f02c9b61a31d0b52 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Tue, 12 May 2026 16:17:59 -0700 Subject: [PATCH 044/133] Adds heterogeneous dexsuite to Newton backend (#5024) # Description This PR enables heterogeneous dexsuite with newton backend 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 --- .../feature-heterogeneous_dexsuite.rst | 5 + source/isaaclab/setup.py | 4 +- .../feature-heterogeneous_dexsuite.rst | 5 + source/isaaclab_newton/setup.py | 4 +- .../feature-heterogeneous_dexsuite.rst | 5 + .../feature-heterogeneous_dexsuite.rst | 5 + source/isaaclab_physx/setup.py | 2 +- .../feature-heterogeneous_dexsuite.rst | 5 + .../dexsuite_kuka_allegro_env_cfg.py | 44 ------- .../manipulation/dexsuite/dexsuite_env_cfg.py | 108 +++++++++++------- source/isaaclab_visualizers/setup.py | 6 +- tools/wheel_builder/res/python_packages.toml | 4 +- 12 files changed, 104 insertions(+), 93 deletions(-) create mode 100644 source/isaaclab/changelog.d/feature-heterogeneous_dexsuite.rst create mode 100644 source/isaaclab_newton/changelog.d/feature-heterogeneous_dexsuite.rst create mode 100644 source/isaaclab_ov/changelog.d/feature-heterogeneous_dexsuite.rst create mode 100644 source/isaaclab_physx/changelog.d/feature-heterogeneous_dexsuite.rst create mode 100644 source/isaaclab_tasks/changelog.d/feature-heterogeneous_dexsuite.rst diff --git a/source/isaaclab/changelog.d/feature-heterogeneous_dexsuite.rst b/source/isaaclab/changelog.d/feature-heterogeneous_dexsuite.rst new file mode 100644 index 000000000000..dbe5adca2b14 --- /dev/null +++ b/source/isaaclab/changelog.d/feature-heterogeneous_dexsuite.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed Newton-related dependencies to use MuJoCo 3.8, MuJoCo Warp 3.8.0.2, + Warp 1.13 or newer, and the packaged Newton 1.2.0 release candidate. diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index cfced25a24aa..94479116a24f 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -31,11 +31,11 @@ "trimesh", "pyglet>=2.1.6,<3", "mujoco==3.8.0", - "mujoco-warp==3.8.0.1", + "mujoco-warp==3.8.0.2", # image processing "transformers==4.57.6", "einops", # needed for transformers, doesn't always auto-install - "warp-lang==1.13.0", + "warp-lang>=1.13.0", "matplotlib>=3.10.3", # minimum version for Python 3.12 support # make sure this is consistent with isaac sim version "pillow==12.1.1", diff --git a/source/isaaclab_newton/changelog.d/feature-heterogeneous_dexsuite.rst b/source/isaaclab_newton/changelog.d/feature-heterogeneous_dexsuite.rst new file mode 100644 index 000000000000..5d98f0322179 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/feature-heterogeneous_dexsuite.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed Newton integration to use the packaged Newton 1.2.0 release candidate + and updated transform conversion calls for Warp 1.13 compatibility. diff --git a/source/isaaclab_newton/setup.py b/source/isaaclab_newton/setup.py index 4621e77f879b..4a954d0e9371 100644 --- a/source/isaaclab_newton/setup.py +++ b/source/isaaclab_newton/setup.py @@ -39,9 +39,9 @@ def run(self): "all": [ "prettytable==3.3.0", "mujoco==3.8.0", - "mujoco-warp==3.8.0.1", + "mujoco-warp==3.8.0.2", "PyOpenGL-accelerate==3.1.10", - "newton @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", + "newton==1.2.0rc3", ], } diff --git a/source/isaaclab_ov/changelog.d/feature-heterogeneous_dexsuite.rst b/source/isaaclab_ov/changelog.d/feature-heterogeneous_dexsuite.rst new file mode 100644 index 000000000000..6f0d819202d4 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/feature-heterogeneous_dexsuite.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed Newton transform synchronization for Warp 1.13 compatibility in the + RTX renderer. diff --git a/source/isaaclab_physx/changelog.d/feature-heterogeneous_dexsuite.rst b/source/isaaclab_physx/changelog.d/feature-heterogeneous_dexsuite.rst new file mode 100644 index 000000000000..d32a1bbc6491 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/feature-heterogeneous_dexsuite.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed the Newton extra to depend on the packaged Newton 1.2.0 release + candidate instead of a Git commit. diff --git a/source/isaaclab_physx/setup.py b/source/isaaclab_physx/setup.py index 9cc172addf50..eddfca89e1e1 100644 --- a/source/isaaclab_physx/setup.py +++ b/source/isaaclab_physx/setup.py @@ -20,7 +20,7 @@ EXTRAS_REQUIRE = { "newton": [ - "newton @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", + "newton==1.2.0rc3", ], } diff --git a/source/isaaclab_tasks/changelog.d/feature-heterogeneous_dexsuite.rst b/source/isaaclab_tasks/changelog.d/feature-heterogeneous_dexsuite.rst new file mode 100644 index 000000000000..edd206ed8eb9 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/feature-heterogeneous_dexsuite.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added Newton MJWarp physics preset support and mesh-based heterogeneous + object spawning for Dexsuite manipulation environments. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py index 4492d197f763..6513e8d7daa3 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py @@ -3,9 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg -from isaaclab_physx.physics import PhysxCfg - from isaaclab.assets import ArticulationCfg from isaaclab.managers import RewardTermCfg as RewTerm from isaaclab.managers import SceneEntityCfg @@ -31,34 +28,6 @@ FINGER_SENSORS = [f"{name}_object_s" for name in FINGERTIP_LIST if name != "thumb_link_3"] -@configclass -class KukaAllegroPhysicsCfg(PresetCfg): - default = PhysxCfg( - bounce_threshold_velocity=0.01, - gpu_max_rigid_patch_count=4 * 5 * 2**15, - gpu_found_lost_pairs_capacity=2**26, - ) - newton_mjwarp = NewtonCfg( - solver_cfg=MJWarpSolverCfg( - solver="newton", - integrator="implicitfast", - njmax=300, - nconmax=70, - impratio=10.0, - cone="elliptic", - update_data_interval=2, - iterations=100, - ls_iterations=15, - ls_parallel=False, - use_mujoco_contacts=True, - ccd_iterations=5000, - ), - num_substeps=2, - debug_mode=False, - ) - physx = default - - @configclass class KukaAllegroSceneCfg(PresetCfg): @configclass @@ -132,28 +101,15 @@ class KukaAllegroObservationCfg(PresetCfg): default = state -@configclass -class KukaAllegroEventCfg(PresetCfg): - @configclass - class KukaAllegroPhysxEventCfg(dexsuite.StartupEventCfg, dexsuite.EventCfg): - pass - - default = KukaAllegroPhysxEventCfg() - newton_mjwarp = dexsuite.EventCfg() - physx = default - - @configclass class KukaAllegroMixinCfg: scene: KukaAllegroSceneCfg = KukaAllegroSceneCfg() rewards: KukaAllegroReorientRewardCfg = KukaAllegroReorientRewardCfg() observations: KukaAllegroObservationCfg = KukaAllegroObservationCfg() - events: KukaAllegroEventCfg = KukaAllegroEventCfg() actions: KukaAllegroRelJointPosActionCfg = KukaAllegroRelJointPosActionCfg() def __post_init__(self): super().__post_init__() - self.sim.physics = KukaAllegroPhysicsCfg() @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py index 449043e3977b..9f12f5b3b0b9 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py @@ -5,6 +5,7 @@ from dataclasses import MISSING +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg, NewtonCollisionPipelineCfg, NewtonShapeCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.sim as sim_utils @@ -18,7 +19,7 @@ from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.markers import VisualizationMarkersCfg from isaaclab.scene import InteractiveSceneCfg -from isaaclab.sim import CapsuleCfg, ConeCfg, CuboidCfg, RigidBodyMaterialCfg, SphereCfg +from isaaclab.sim import MeshCapsuleCfg, MeshConeCfg, MeshCuboidCfg, MeshSphereCfg, RigidBodyMaterialCfg from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR from isaaclab.utils.noise import UniformNoiseCfg as Unoise @@ -37,26 +38,32 @@ ) +OBJECT_PHYSICS = { + "physics_material": RigidBodyMaterialCfg(static_friction=0.5), + "collision_props": sim_utils.CollisionPropertiesCfg(contact_offset=0.002), +} + + @configclass class ObjectCfg(PresetCfg): shapes = sim_utils.MultiAssetSpawnerCfg( assets_cfg=[ - CuboidCfg(size=(0.05, 0.1, 0.1), physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - CuboidCfg(size=(0.05, 0.05, 0.1), physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - CuboidCfg(size=(0.025, 0.1, 0.1), physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - CuboidCfg(size=(0.025, 0.05, 0.1), physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - CuboidCfg(size=(0.025, 0.025, 0.1), physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - CuboidCfg(size=(0.01, 0.1, 0.1), physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - SphereCfg(radius=0.05, physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - SphereCfg(radius=0.025, physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - CapsuleCfg(radius=0.04, height=0.025, physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - CapsuleCfg(radius=0.04, height=0.01, physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - CapsuleCfg(radius=0.04, height=0.1, physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - CapsuleCfg(radius=0.025, height=0.1, physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - CapsuleCfg(radius=0.025, height=0.2, physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - CapsuleCfg(radius=0.01, height=0.2, physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - ConeCfg(radius=0.05, height=0.1, physics_material=RigidBodyMaterialCfg(static_friction=0.5)), - ConeCfg(radius=0.025, height=0.1, physics_material=RigidBodyMaterialCfg(static_friction=0.5)), + MeshCuboidCfg(size=(0.05, 0.1, 0.1), **OBJECT_PHYSICS), + MeshCuboidCfg(size=(0.05, 0.05, 0.1), **OBJECT_PHYSICS), + MeshCuboidCfg(size=(0.025, 0.1, 0.1), **OBJECT_PHYSICS), + MeshCuboidCfg(size=(0.025, 0.05, 0.1), **OBJECT_PHYSICS), + MeshCuboidCfg(size=(0.025, 0.025, 0.1), **OBJECT_PHYSICS), + MeshCuboidCfg(size=(0.01, 0.1, 0.1), **OBJECT_PHYSICS), + MeshSphereCfg(radius=0.05, **OBJECT_PHYSICS), + MeshSphereCfg(radius=0.025, **OBJECT_PHYSICS), + MeshCapsuleCfg(radius=0.04, height=0.025, **OBJECT_PHYSICS), + MeshCapsuleCfg(radius=0.04, height=0.01, **OBJECT_PHYSICS), + MeshCapsuleCfg(radius=0.04, height=0.1, **OBJECT_PHYSICS), + MeshCapsuleCfg(radius=0.025, height=0.1, **OBJECT_PHYSICS), + MeshCapsuleCfg(radius=0.025, height=0.2, **OBJECT_PHYSICS), + MeshCapsuleCfg(radius=0.01, height=0.2, **OBJECT_PHYSICS), + MeshConeCfg(radius=0.05, height=0.1, **OBJECT_PHYSICS), + MeshConeCfg(radius=0.025, height=0.1, **OBJECT_PHYSICS), ], rigid_props=sim_utils.RigidBodyPropertiesCfg( solver_position_iteration_count=16, @@ -77,7 +84,6 @@ class ObjectCfg(PresetCfg): collision_props=sim_utils.CollisionPropertiesCfg(), mass_props=sim_utils.MassPropertiesCfg(mass=0.2), ) - newton_mjwarp = cube # newton does not support multi-asset spawning yet default = shapes @@ -218,8 +224,8 @@ def __post_init__(self): @configclass -class StartupEventCfg: - """Startup-mode domain randomization (PhysX only — Newton does not support startup events).""" +class EventCfg: + """Reset-mode events (shared by all physics backends).""" robot_physics_material = EventTerm( func=mdp.randomize_rigid_body_material, @@ -245,6 +251,17 @@ class StartupEventCfg: }, ) + object_physics_inertia = EventTerm( + func=mdp.randomize_rigid_body_inertia, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("object"), + "inertia_distribution_params": [0.01, 0.01], + "operation": "add", + "diagonal_only": True, + }, + ) + joint_stiffness_and_damping = EventTerm( func=mdp.randomize_actuator_gains, mode="startup", @@ -276,11 +293,6 @@ class StartupEventCfg: }, ) - -@configclass -class EventCfg: - """Reset-mode events (shared by all physics backends).""" - # Gravity scheduling is a deliberate curriculum trick — starting with no # gravity (easy) and gradually introducing full gravity (hard) makes learning # smoother and removes the need for a separate "Lift" reward. @@ -409,6 +421,36 @@ class TerminationsCfg: abnormal_robot = DoneTerm(func=mdp.abnormal_robot_state) +@configclass +class PhysicsCfg(PresetCfg): + default = PhysxCfg( + bounce_threshold_velocity=0.01, + gpu_max_rigid_patch_count=4 * 5 * 2**15, + gpu_found_lost_pairs_capacity=2**26, + ) + newton_mjwarp = NewtonCfg( + solver_cfg=MJWarpSolverCfg( + solver="newton", + integrator="implicitfast", + njmax=300, + nconmax=200, + impratio=10.0, + cone="elliptic", + update_data_interval=2, + iterations=100, + ls_iterations=15, + ls_parallel=False, + use_mujoco_contacts=False, + ccd_iterations=35, + ), + collision_cfg=NewtonCollisionPipelineCfg(), + default_shape_cfg=NewtonShapeCfg(), + num_substeps=2, + debug_mode=False, + ) + physx = default + + @configclass class DexsuiteReorientEnvCfg(ManagerBasedEnvCfg): """Dexsuite reorientation task definition, also the base definition for derivative Lift task and evaluation task""" @@ -423,19 +465,11 @@ class DexsuiteReorientEnvCfg(ManagerBasedEnvCfg): # MDP settings rewards: RewardsCfg = RewardsCfg() terminations: TerminationsCfg = TerminationsCfg() - events: EventCfg = MISSING # type: ignore + events: EventCfg = EventCfg() curriculum: CurriculumCfg | None = CurriculumCfg() def validate_config(self): """Check for invalid preset combinations after resolution.""" - is_newton = not isinstance(self.sim.physics, PhysxCfg) - is_multi_asset = isinstance(self.scene.object.spawn, sim_utils.MultiAssetSpawnerCfg) - - if is_newton and is_multi_asset: - raise ValueError( - "Newton physics does not support multi-asset spawning." - " Use a single-geometry object preset (e.g. presets=cube) instead of 'shapes'." - ) warp_supported = {"rgb", "depth", "distance_to_image_plane"} for cam_attr in ("base_camera", "wrist_camera"): @@ -466,11 +500,7 @@ def __post_init__(self): # simulation settings self.sim.dt = 1 / 120 self.sim.render_interval = self.decimation - self.sim.physics = PhysxCfg( - bounce_threshold_velocity=0.01, - gpu_max_rigid_patch_count=4 * 5 * 2**15, - gpu_found_lost_pairs_capacity=2**26, - ) + self.sim.physics = PhysicsCfg() class DexsuiteLiftEnvCfg(DexsuiteReorientEnvCfg): diff --git a/source/isaaclab_visualizers/setup.py b/source/isaaclab_visualizers/setup.py index 9ad52a712360..78269a201fea 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@v1.2.0rc2", + "newton==1.2.0rc3", "PyOpenGL-accelerate", "imgui-bundle>=1.92.5", ], "rerun": [ - "newton @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", + "newton==1.2.0rc3", "rerun-sdk>=0.29.0", ], "viser": [ - "newton @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", + "newton==1.2.0rc3", "viser>=1.0.16", ], } diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index 9944580034e0..7fbe39505863 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -85,8 +85,8 @@ pyproject.optional-dependencies.all = [ { "newton" = [ "warp-lang==1.13.0", "mujoco==3.8.0", - "mujoco-warp==3.8.0.1", - "newton @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", + "mujoco-warp==3.8.0.2", + "newton==1.2.0rc3", "PyOpenGL-accelerate==3.1.10" ] }, # ================================================================================ From d58e3d7e3e8f74672d1f0990018fa3df4efba7aa Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Tue, 12 May 2026 21:26:27 -0700 Subject: [PATCH 045/133] Pins Isaac Sim image to previous image from 05/11 (#5600) # Description Reverts CI image to previous Isaac Sim image from 05/11 as the new image is causing timeouts in our CI. TODO: investigate failing cause of the new image. ## 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 - [ ] 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 --- .github/workflows/config.yaml | 2 +- tools/test_settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/config.yaml b/.github/workflows/config.yaml index aa3c1888b240..e1fe2f3bfb81 100644 --- a/.github/workflows/config.yaml +++ b/.github/workflows/config.yaml @@ -6,5 +6,5 @@ # Shared image config for CI workflows. Loaded by the `config` job in each # workflow via yq and exposed as job outputs (see e.g. .github/workflows/build.yaml). isaacsim_image_name: nvcr.io/nvidian/isaac-sim -isaacsim_image_tag: latest-develop +isaacsim_image_tag: latest-develop@sha256:0dd49a1121b297dc85eee7777a9c528318683dbe03b29fd01f2059ac1b099301 isaaclab_image_name: nvcr.io/nvidian/isaac-lab diff --git a/tools/test_settings.py b/tools/test_settings.py index aece6deba348..7fdde2fef9a1 100644 --- a/tools/test_settings.py +++ b/tools/test_settings.py @@ -17,7 +17,7 @@ PER_TEST_TIMEOUTS = { - "test_articulation.py": 1500, + "test_articulation.py": 3000, "test_stage_in_memory.py": 1000, "test_imu.py": 1000, "test_environments.py": 10000, # This test runs through all the environments for 100 steps each From b8fead49c89d1b8f0d471b010b85e1095089a29a Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 06:10:17 +0000 Subject: [PATCH 046/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 5.1.0 → 5.1.1 - isaaclab_newton: 0.8.0 → 0.8.1 - isaaclab_ov: 0.1.7 → 0.1.8 - isaaclab_physx: 0.6.3 → 0.6.4 - isaaclab_tasks: 1.5.37 → 1.5.38 --- .../feature-heterogeneous_dexsuite.rst | 5 ---- .../changelog.d/octi-fix-carb-null-client.rst | 12 ---------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 23 +++++++++++++++++++ .../feature-heterogeneous_dexsuite.rst | 5 ---- .../changelog.d/octi-fix-carb-null-client.rst | 11 --------- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 22 ++++++++++++++++++ .../feature-heterogeneous_dexsuite.rst | 5 ---- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 10 ++++++++ .../feature-heterogeneous_dexsuite.rst | 5 ---- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 10 ++++++++ .../feature-heterogeneous_dexsuite.rst | 5 ---- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 10 ++++++++ 17 files changed, 80 insertions(+), 53 deletions(-) delete mode 100644 source/isaaclab/changelog.d/feature-heterogeneous_dexsuite.rst delete mode 100644 source/isaaclab/changelog.d/octi-fix-carb-null-client.rst delete mode 100644 source/isaaclab_newton/changelog.d/feature-heterogeneous_dexsuite.rst delete mode 100644 source/isaaclab_newton/changelog.d/octi-fix-carb-null-client.rst delete mode 100644 source/isaaclab_ov/changelog.d/feature-heterogeneous_dexsuite.rst delete mode 100644 source/isaaclab_physx/changelog.d/feature-heterogeneous_dexsuite.rst delete mode 100644 source/isaaclab_tasks/changelog.d/feature-heterogeneous_dexsuite.rst diff --git a/source/isaaclab/changelog.d/feature-heterogeneous_dexsuite.rst b/source/isaaclab/changelog.d/feature-heterogeneous_dexsuite.rst deleted file mode 100644 index dbe5adca2b14..000000000000 --- a/source/isaaclab/changelog.d/feature-heterogeneous_dexsuite.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Changed Newton-related dependencies to use MuJoCo 3.8, MuJoCo Warp 3.8.0.2, - Warp 1.13 or newer, and the packaged Newton 1.2.0 release candidate. diff --git a/source/isaaclab/changelog.d/octi-fix-carb-null-client.rst b/source/isaaclab/changelog.d/octi-fix-carb-null-client.rst deleted file mode 100644 index fec6029eaad3..000000000000 --- a/source/isaaclab/changelog.d/octi-fix-carb-null-client.rst +++ /dev/null @@ -1,12 +0,0 @@ -Fixed -^^^^^ - -* Fixed a spurious ``[Error][carb] Client passed into the framework is nullptr.`` - log emitted from :meth:`~isaaclab.cloner._fabric_notices.FabricNoticeBindings.initialize` - when an environment imports IsaacLab outside Kit (e.g. remote asset resolution - via ``omni.client``). The helper was passing ``clientName=None`` as a fallback - to ``tryAcquireInterfaceWithClient``; Carbonite has rejected null client names - since 2018, so the call only emitted a misleading error log and never returned - a valid interface. The fallback has been removed; the helper still fails closed - when Fabric is unavailable, with no impact on the cloning speedup when Fabric - is present. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 2afa36a1dd92..3a1dc987c390 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 = "5.1.0" +version = "5.1.1" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index fdb92ec1c070..4413fa3b710e 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,29 @@ Changelog --------- +5.1.1 (2026-05-13) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed Newton-related dependencies to use MuJoCo 3.8, MuJoCo Warp 3.8.0.2, + Warp 1.13 or newer, and the packaged Newton 1.2.0 release candidate. + +Fixed +^^^^^ + +* Fixed a spurious ``[Error][carb] Client passed into the framework is nullptr.`` + log emitted from :meth:`~isaaclab.cloner._fabric_notices.FabricNoticeBindings.initialize` + when an environment imports IsaacLab outside Kit (e.g. remote asset resolution + via ``omni.client``). The helper was passing ``clientName=None`` as a fallback + to ``tryAcquireInterfaceWithClient``; Carbonite has rejected null client names + since 2018, so the call only emitted a misleading error log and never returned + a valid interface. The fallback has been removed; the helper still fails closed + when Fabric is unavailable, with no impact on the cloning speedup when Fabric + is present. + + 5.1.0 (2026-05-12) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/changelog.d/feature-heterogeneous_dexsuite.rst b/source/isaaclab_newton/changelog.d/feature-heterogeneous_dexsuite.rst deleted file mode 100644 index 5d98f0322179..000000000000 --- a/source/isaaclab_newton/changelog.d/feature-heterogeneous_dexsuite.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Changed Newton integration to use the packaged Newton 1.2.0 release candidate - and updated transform conversion calls for Warp 1.13 compatibility. diff --git a/source/isaaclab_newton/changelog.d/octi-fix-carb-null-client.rst b/source/isaaclab_newton/changelog.d/octi-fix-carb-null-client.rst deleted file mode 100644 index 3f4a51c49cc0..000000000000 --- a/source/isaaclab_newton/changelog.d/octi-fix-carb-null-client.rst +++ /dev/null @@ -1,11 +0,0 @@ -Fixed -^^^^^ - -* Fixed a spurious ``[Error][carb] Client passed into the framework is nullptr.`` - log emitted from :meth:`~isaaclab_newton.physics._cubric.CubricBindings.initialize` - when the first ``tryAcquireInterfaceWithClient`` attempt returned null. The - helper used to retry with ``clientName=None``, which Carbonite has rejected as - invalid since 2018 — the retry only emitted a misleading error log. Removed - the null-client retry; the existing ``acquireInterfaceWithClient`` fallback - with the ``isaaclab.cubric`` client name still handles configurations where - the plugin needs to be loaded on demand. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 989e0b498dbf..2aa95e8f185b 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.8.0" +version = "0.8.1" # 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 d841aa46d798..ee88d55a3241 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,28 @@ Changelog --------- +0.8.1 (2026-05-13) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed Newton integration to use the packaged Newton 1.2.0 release candidate + and updated transform conversion calls for Warp 1.13 compatibility. + +Fixed +^^^^^ + +* Fixed a spurious ``[Error][carb] Client passed into the framework is nullptr.`` + log emitted from :meth:`~isaaclab_newton.physics._cubric.CubricBindings.initialize` + when the first ``tryAcquireInterfaceWithClient`` attempt returned null. The + helper used to retry with ``clientName=None``, which Carbonite has rejected as + invalid since 2018 — the retry only emitted a misleading error log. Removed + the null-client retry; the existing ``acquireInterfaceWithClient`` fallback + with the ``isaaclab.cubric`` client name still handles configurations where + the plugin needs to be loaded on demand. + + 0.8.0 (2026-05-12) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/changelog.d/feature-heterogeneous_dexsuite.rst b/source/isaaclab_ov/changelog.d/feature-heterogeneous_dexsuite.rst deleted file mode 100644 index 6f0d819202d4..000000000000 --- a/source/isaaclab_ov/changelog.d/feature-heterogeneous_dexsuite.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed Newton transform synchronization for Warp 1.13 compatibility in the - RTX renderer. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index c16250b01753..3f2861d1bf6a 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.1.7" +version = "0.1.8" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index f0962359544d..177c9235cb98 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.1.8 (2026-05-13) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed Newton transform synchronization for Warp 1.13 compatibility in the + RTX renderer. + + 0.1.7 (2026-05-12) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/changelog.d/feature-heterogeneous_dexsuite.rst b/source/isaaclab_physx/changelog.d/feature-heterogeneous_dexsuite.rst deleted file mode 100644 index d32a1bbc6491..000000000000 --- a/source/isaaclab_physx/changelog.d/feature-heterogeneous_dexsuite.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Changed the Newton extra to depend on the packaged Newton 1.2.0 release - candidate instead of a Git commit. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 4e00f31716d6..3371307fa567 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.6.3" +version = "0.6.4" # 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 95e059b045b6..0eef200d5f15 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.6.4 (2026-05-13) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed the Newton extra to depend on the packaged Newton 1.2.0 release + candidate instead of a Git commit. + + 0.6.3 (2026-05-11) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/changelog.d/feature-heterogeneous_dexsuite.rst b/source/isaaclab_tasks/changelog.d/feature-heterogeneous_dexsuite.rst deleted file mode 100644 index edd206ed8eb9..000000000000 --- a/source/isaaclab_tasks/changelog.d/feature-heterogeneous_dexsuite.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added Newton MJWarp physics preset support and mesh-based heterogeneous - object spawning for Dexsuite manipulation environments. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 219e89c4eb28..93ec41a16c75 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.37" +version = "1.5.38" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 6c3de163c11c..6f97bf866892 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +1.5.38 (2026-05-13) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added Newton MJWarp physics preset support and mesh-based heterogeneous + object spawning for Dexsuite manipulation environments. + + 1.5.37 (2026-05-12) ~~~~~~~~~~~~~~~~~~~ From 5ea751244d3f05658d25e6b6fc8d98d11571546f Mon Sep 17 00:00:00 2001 From: camevor Date: Wed, 13 May 2026 08:17:52 +0200 Subject: [PATCH 047/133] [Newton] Fixes contact sensor metadata access (#5588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Fixes partial migration of metadata access in #5418 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 --------- Co-authored-by: Kelly Guo --- ...ca-fix-newton-contact-sensor-migration.rst | 7 ++ .../sensors/contact_sensor/contact_sensor.py | 64 +++++------ .../test/sensors/test_contact_sensor.py | 107 ++++++++++++++++++ 3 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/ca-fix-newton-contact-sensor-migration.rst diff --git a/source/isaaclab_newton/changelog.d/ca-fix-newton-contact-sensor-migration.rst b/source/isaaclab_newton/changelog.d/ca-fix-newton-contact-sensor-migration.rst new file mode 100644 index 000000000000..5acd1c0cf4f0 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/ca-fix-newton-contact-sensor-migration.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_newton.sensors.ContactSensor` metadata extraction + after the migration to Newton 1.1, where ``sensing_obj_type`` and + ``counterpart_type`` became scalar strings and ``counterpart_indices`` + became per-row. diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py index 43e878fecbfb..65d15de98750 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py @@ -346,48 +346,42 @@ def _create_buffers(self): body_labels = self._get_model_labels("body") shape_labels = self._get_model_labels("shape") - def get_name(idx, kind): - kind_name = getattr(kind, "name", None) - kind_value = getattr(kind, "value", kind) - if kind_name == "BODY" or kind_value == 2: - return body_labels[int(idx)].split("/")[-1] - if kind_name == "SHAPE" or kind_value == 1: - return shape_labels[int(idx)].split("/")[-1] - return "MATCH_ANY" - - def flatten_metadata(values): - if isinstance(values, wp.array): - values = values.numpy() - flat_values = np.asarray(values, dtype=object).reshape(-1).tolist() - if flat_values and isinstance(flat_values[0], list | tuple | np.ndarray): - return [ - value - for nested_values in flat_values - for value in np.asarray(nested_values, dtype=object).reshape(-1).tolist() - ] - return flat_values - - flat_sensing = list( - zip( - flatten_metadata(self.contact_view.sensing_obj_idx), - flatten_metadata(self.contact_view.sensing_obj_type), - ) - ) - self._sensor_names = [get_name(idx, kind) for idx, kind in flat_sensing] + s_kind = self.contact_view.sensing_obj_type + if s_kind == "body": + s_labels = body_labels + elif s_kind == "shape": + s_labels = shape_labels + else: + raise RuntimeError(f"Unexpected Newton sensing_obj_type {s_kind!r}; expected 'body' or 'shape'.") + self._sensor_names = [s_labels[i].split("/")[-1] for i in self.contact_view.sensing_obj_idx] # Assumes the environments are processed in order. self._sensor_names = self._sensor_names[: self._num_sensors] - flat_counterparts = list( - zip( - flatten_metadata(self.contact_view.counterpart_indices), - flatten_metadata(self.contact_view.counterpart_type), - ) - ) - self._filter_object_names = [get_name(idx, kind) for idx, kind in flat_counterparts] + + c_kind = self.contact_view.counterpart_type + c_idx_per_sensor = self.contact_view.counterpart_indices + if c_kind is None: + if self._generate_force_matrix: + raise RuntimeError("Filter expressions were configured but Newton reports no counterpart type.") + self._filter_object_names = [] + else: + if c_kind == "body": + c_labels = body_labels + elif c_kind == "shape": + c_labels = shape_labels + else: + raise RuntimeError(f"Unexpected Newton counterpart_type {c_kind!r}; expected 'body' or 'shape'.") + # Envs are homogeneous: every sensor row sees the same counterpart list. Take row 0. + row0 = c_idx_per_sensor[0] if c_idx_per_sensor else [] + self._filter_object_names = [c_labels[i].split("/")[-1] for i in row0] + if self._generate_force_matrix and not self._filter_object_names: + logger.warning("Filter expressions matched zero counterpart objects; force matrix will be empty.") force_matrix = self.contact_view.force_matrix force_matrix_shape = force_matrix.shape if force_matrix is not None else (total_sensor_count, 0) # Number of filter objects. self._num_filter_objects = force_matrix_shape[1] if len(force_matrix_shape) > 1 else 0 + if self._num_filter_objects > 0 and force_matrix is None: + raise RuntimeError("Filter counterparts present but Newton force_matrix is None.") # Store flat Newton force views for copying data. These may be non-contiguous # views, so the copy kernel indexes them without reshaping. diff --git a/source/isaaclab_newton/test/sensors/test_contact_sensor.py b/source/isaaclab_newton/test/sensors/test_contact_sensor.py index 066803ee884b..3aaa6e14b39c 100644 --- a/source/isaaclab_newton/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_newton/test/sensors/test_contact_sensor.py @@ -26,6 +26,7 @@ import pytest import torch +from isaaclab_newton.sensors.contact_sensor import ContactSensorCfg as NewtonContactSensorCfg from physics.physics_test_utils import ( COLLISION_PIPELINES, STABLE_SHAPES, @@ -780,6 +781,112 @@ def test_finger_contact_sensor_isolation(device: str, use_mujoco_contacts: bool, ) +# =================================================================== +# Sensor metadata +# =================================================================== + + +def _make_two_box_scene_cfg(num_envs: int) -> ContactSensorTestSceneCfg: + """Scene with two distinct Cuboid bodies (BoxA, BoxB) per env.""" + rigid_props = sim_utils.RigidBodyPropertiesCfg(disable_gravity=True, linear_damping=0.0, angular_damping=0.0) + scene_cfg = ContactSensorTestSceneCfg(num_envs=num_envs, env_spacing=5.0, lazy_sensor_update=False) + scene_cfg.object_a = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/BoxA", + spawn=sim_utils.CuboidCfg( + size=(0.3, 0.3, 0.3), + rigid_props=rigid_props, + collision_props=sim_utils.CollisionPropertiesCfg(collision_enabled=True), + mass_props=sim_utils.MassPropertiesCfg(mass=1.0), + activate_contact_sensors=True, + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(-0.5, 0.0, 1.0)), + ) + scene_cfg.object_b = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/BoxB", + spawn=sim_utils.CuboidCfg( + size=(0.3, 0.3, 0.3), + rigid_props=rigid_props, + collision_props=sim_utils.CollisionPropertiesCfg(collision_enabled=True), + mass_props=sim_utils.MassPropertiesCfg(mass=1.0), + activate_contact_sensors=True, + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.5, 0.0, 1.0)), + ) + return scene_cfg + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_sensor_metadata(device: str): + """Verify sensor_names and filter_object_names match the underlying sensing and + counterpart configuration across body-mode, body-mode-with-filter, and shape-mode. + """ + num_envs = 4 + sim_cfg = make_sim_cfg(use_mujoco_contacts=False, device=device, gravity=(0.0, 0.0, -9.81)) + + # (1) Body-mode, no filter: pattern matches two distinct body names per env. + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + sim._app_control_on_stop_handle = None + scene_cfg = _make_two_box_scene_cfg(num_envs) + scene_cfg.contact_sensor_a = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Box.*", + update_period=0.0, + history_length=1, + ) + scene = InteractiveScene(scene_cfg) + sim.reset() + scene.reset() + + sensor: ContactSensor = scene["contact_sensor_a"] + assert sensor.num_sensors == 2, f"expected 2 sensors per env, got {sensor.num_sensors}" + assert sensor.sensor_names == ["BoxA", "BoxB"], f"unexpected sensor_names: {sensor.sensor_names}" + assert sensor.filter_object_names == [], ( + f"expected empty filter_object_names with no filter, got {sensor.filter_object_names}" + ) + + # (2) Body-mode, with filter: one body matches the sensor pattern, one matches the filter pattern. + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + sim._app_control_on_stop_handle = None + scene_cfg = _make_two_box_scene_cfg(num_envs) + scene_cfg.contact_sensor_a = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/BoxA", + filter_prim_paths_expr=["{ENV_REGEX_NS}/BoxB"], + update_period=0.0, + history_length=1, + ) + scene = InteractiveScene(scene_cfg) + sim.reset() + scene.reset() + + sensor: ContactSensor = scene["contact_sensor_a"] + assert sensor.num_sensors == 1, f"expected 1 sensor per env, got {sensor.num_sensors}" + assert sensor.sensor_names == ["BoxA"], f"unexpected sensor_names: {sensor.sensor_names}" + assert sensor.num_filter_objects == 1, f"expected 1 filter object per sensor, got {sensor.num_filter_objects}" + assert sensor.filter_object_names == ["BoxB"], f"unexpected filter_object_names: {sensor.filter_object_names}" + + # (3) Shape-mode, no filter: pattern matches shapes (not bodies). + # `sensor_shape_prim_expr` is a Newton-only extension, so this block uses the + # backend-specific NewtonContactSensorCfg subclass. + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + sim._app_control_on_stop_handle = None + scene_cfg = _make_two_box_scene_cfg(num_envs) + scene_cfg.contact_sensor_a = NewtonContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Box.*", + sensor_shape_prim_expr=["{ENV_REGEX_NS}/Box.*"], + update_period=0.0, + history_length=1, + ) + scene = InteractiveScene(scene_cfg) + sim.reset() + scene.reset() + + sensor: ContactSensor = scene["contact_sensor_a"] + assert sensor.num_sensors == 2, f"expected 2 shape sensors per env, got {sensor.num_sensors}" + assert sensor.sensor_names == ["mesh", "mesh"], f"unexpected shape sensor_names: {sensor.sensor_names}" + assert sensor.filter_object_names == [], ( + f"expected empty filter_object_names with no filter, got {sensor.filter_object_names}" + ) + + # =================================================================== # Utility # =================================================================== From 68a651f733c9927f98c2a88a47134999f9b85447 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Wed, 13 May 2026 13:04:24 +0200 Subject: [PATCH 048/133] [OVPHYSX] RigidObject + RigidObjectData asset (#5426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements `RigidObject` and `RigidObjectData` for the OVPhysX backend (issue #5316), satisfying the `BaseRigidObject` and `BaseRigidObjectData` contracts. Mirrors the PhysX `RigidObject` and the existing OVPhysX `Articulation` patterns; runs kitless via the standard `SimulationContext` + `UsdFileCfg(usd_path=…)` pipeline. - New: `source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/{rigid_object.py, rigid_object_data.py, __init__.py, __init__.pyi}` (~1900 lines). - New: `source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py` — shared Warp kernels relocated from `articulation/kernels.py` so both asset types use them; new `_compose_root_link_pose_from_com` for COM→link write conversion; ported Newton's `derive_body_acceleration_from_body_com_velocities` to FD acceleration locally (no wheel `RIGID_BODY_ACCELERATION` dependency). - New `RIGID_BODY_*` `TensorType` aliases in `isaaclab_ovphysx/tensor_types.py` — six already-shipping types as direct imports, three forward-compat aliases (`ACCELERATION`, `INV_MASS`, `INV_INERTIA`) gated by `try/except AttributeError` so the module loads cleanly today. - Allegro env hookup (`source/isaaclab_tasks/.../allegro_hand/allegro_hand_env_cfg.py`): adds `ovphysx` variants to `ObjectCfg` and `PhysicsCfg`, mirroring the Cartpole/Ant pattern. Enables running `Isaac-Repose-Cube-Allegro-Direct-v0` against OVPhysX via `./scripts/run_ovphysx.sh`. - Cross-backend interface tests: `BACKENDS.append(\"ovphysx\")` + `create_ovphysx_rigid_object` factory in `source/isaaclab/test/assets/test_rigid_object_iface.py`. - Versioning: `isaaclab_ovphysx 0.1.2 → 0.2.0`, `isaaclab_tasks 1.5.29 → 1.5.30`. ## Test plan ### Real-backend rigid-object tests (kitless, via `run_ovphysx.sh`) ``` ./scripts/run_ovphysx.sh -m pytest source/isaaclab_ovphysx/test/assets/test_rigid_object.py -v ``` Current state: **61 passed, 14 xfailed**. The 61 passing tests are real-backend (live `ovphysx.PhysX` instance, real `TensorBinding` reads, real sim steps) — port of the PhysX `test_rigid_object.py` structure with the canonical `SimulationContext` + `UsdFileCfg(ISAAC_NUCLEUS_DIR/Props/Blocks/DexCube/dex_cube_instanceable.usd)` pattern Cartpole/Newton already use. Catches two production bugs the previous mock-based suite missed (\`hasattr\` swallow on \`body_names\`, \`self._device\` always falling back to \`cuda:0\`). ### Cross-backend interface tests ``` ./scripts/run_ovphysx.sh -m pytest source/isaaclab/test/assets/test_rigid_object_iface.py -v -k ovphysx ``` Current state: **252 passed, 120 fixed shape-mismatch failures fixed in this branch** (4 distinct bugs caught: full-write row-count guard, 1-D mask src normalization, COM-pose row-count guard, two unimplemented \`default_root_pose/vel\` stubs). ### Existing articulation regression check ``` ./scripts/run_ovphysx.sh -m pytest source/isaaclab_ovphysx/test/assets/test_articulation.py source/isaaclab_ovphysx/test/assets/test_articulation_data.py -v ``` Verifies the kernel relocation in Task 2 didn't break existing articulation tests. Recommended before merge. ### Manual end-to-end (Kit + Nucleus) \`Isaac-Repose-Cube-Allegro-Direct-v0\` with the new \`ovphysx\` preset — manual smoke test (Kit-required, requires Nucleus access): ``` ./scripts/run_ovphysx.sh source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/allegro_hand_env.py --num_envs 4 --headless ``` ## Wheel-side gaps (for @marcodiiga) The 14 remaining xfails split as follows; only **10 are wheel-side blockers** (all in the same category): | Category | xfailed | Owner | |---|---|---| | Material-properties API (`RIGID_BODY_MATERIAL` TensorType or view helper) | 10 | Wheel — see [docs/superpowers/specs/2026-04-28-ovphysx-wheel-gaps-for-marco.md](https://github.com/AntoineRichard/IsaacLab/blob/antoiner/feat/ovphysx_rigidobject/docs/superpowers/specs/2026-04-28-ovphysx-wheel-gaps-for-marco.md) for the proposed contract | | `test_initialization_with_no_rigid_body` (RuntimeError on missing prim) | 2 | IsaacLab follow-up — error-handling polish | | `test_initialization_with_articulation_root` (out-of-scope per spec) | 2 | IsaacLab follow-up — explicit \`NotImplementedError\` stub | Three additional wheel-side `RIGID_BODY_*` TensorTypes (`ACCELERATION`, `INV_MASS`, `INV_INERTIA`) are forward-compat — declared via `try/except AttributeError` aliases on the IsaacLab side, no IsaacLab consumers depend on them today, but they auto-activate when the wheel ships them. ## Notes - IsaacLab side is wheel-update-agnostic: `tensor_types.py` defensive aliases let \`isaaclab_ovphysx\` import cleanly against today's \`ovphysx 0.3.7\`. - Local docs at \`docs/superpowers/specs/\` (gitignored) include the original design spec, the corrected Marco-feedback gap spec, and the test-gaps follow-up. - Branch contains 30 commits including Marco's contract corrections (renames \`RIGID_BODY_ROOT_POSE\` → \`RIGID_BODY_POSE\`, \`MASS\` shape \`(N, 1)\` → \`(N,)\`). --------- Co-authored-by: Kelly Guo --- scripts/run_ovphysx.sh | 8 + .../antoiner-feat-ovphysx_rigidobject.skip | 0 .../test/assets/test_articulation_iface.py | 23 +- .../test/assets/test_rigid_object_iface.py | 107 +- ...ntoiner-feat-ovphysx_rigidobject.major.rst | 50 + .../isaaclab_ovphysx/assets/__init__.pyi | 3 + .../assets/articulation/articulation.py | 3 +- .../assets/articulation/articulation_data.py | 7 +- .../assets/articulation/kernels.py | 142 -- .../isaaclab_ovphysx/assets/kernels.py | 1108 +++++++++++++++ .../assets/rigid_object/__init__.py | 10 + .../assets/rigid_object/__init__.pyi | 12 + .../assets/rigid_object/rigid_object.py | 1173 ++++++++++++++++ .../assets/rigid_object/rigid_object_data.py | 1198 +++++++++++++++++ .../physics/ovphysx_manager.py | 221 ++- .../isaaclab_ovphysx/tensor_types.py | 116 +- .../views/mock_ovphysx_bindings.py | 77 +- .../test/assets/test_rigid_object.py | 1134 ++++++++++++++++ .../test/assets/test_rigid_object_helpers.py | 45 + 19 files changed, 5180 insertions(+), 257 deletions(-) create mode 100644 source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobject.skip create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject.major.rst create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/__init__.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/__init__.pyi create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object_data.py create mode 100644 source/isaaclab_ovphysx/test/assets/test_rigid_object.py create mode 100644 source/isaaclab_ovphysx/test/assets/test_rigid_object_helpers.py diff --git a/scripts/run_ovphysx.sh b/scripts/run_ovphysx.sh index 72d18a1bec82..c3bb76c2d581 100755 --- a/scripts/run_ovphysx.sh +++ b/scripts/run_ovphysx.sh @@ -3,6 +3,14 @@ # Use when ovphysx is installed into Kit's Python. # # Usage: ./scripts/run_ovphysx.sh [your_script.py or -m pytest ...] +# +# CI note: the OVPhysX wheel's device mode is a process-global C++/Carbonite +# static (gap G5 in docs/superpowers/specs/2026-04-28-ovphysx-wheel-gaps-for-marco.md). +# To exercise both CPU and GPU coverage, invoke this script TWICE in separate +# processes -- e.g. +# ./scripts/run_ovphysx.sh -m pytest -k 'cpu' +# ./scripts/run_ovphysx.sh -m pytest -k 'cuda:0' +# A single invocation locks to whichever device is requested first. set -e ISAACLAB_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" diff --git a/source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobject.skip b/source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobject.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/test/assets/test_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index 3e471026a9dc..498091f51058 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -20,9 +20,13 @@ from unittest.mock import MagicMock # When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher -# will try to boot Kit and hang. Skip it entirely when LD_PRELOAD is cleared -# (the signature of run_ovphysx.sh) or when EXP_PATH is not set. -_kitless = os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ +# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets +# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless +# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is +# set (bare Python, no Kit at all). +_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( + os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ +) if not _kitless: from isaaclab.app import AppLauncher @@ -30,6 +34,19 @@ simulation_app = AppLauncher(headless=True).app else: simulation_app = None + # Stub out the Kit/Omniverse modules that are not present under + # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). + # ``omni`` is a real namespace package, so missing submodules also need + # to be installed as attributes on it -- ``sys.modules`` alone is not + # enough because attribute access on the real ``omni`` won't fall + # through to ``sys.modules``. + import omni as _omni + + for _mod in ("physics", "physics.tensors", "physx", "timeline", "usd"): + _stub = MagicMock() + sys.modules[f"omni.{_mod}"] = _stub + # Bind the leaf attribute so that ``omni.`` resolves. + setattr(_omni, _mod.split(".", 1)[0], _stub) for _mod in ("isaacsim.core", "isaacsim.core.simulation_manager"): sys.modules.setdefault(_mod, MagicMock()) diff --git a/source/isaaclab/test/assets/test_rigid_object_iface.py b/source/isaaclab/test/assets/test_rigid_object_iface.py index 178feeddb603..772130149ee3 100644 --- a/source/isaaclab/test/assets/test_rigid_object_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_iface.py @@ -13,16 +13,42 @@ The setup is a bit convoluted so that we can run these tests without requiring Isaac Sim or GPU simulation. """ -"""Launch Isaac Sim Simulator first.""" +"""Launch Isaac Sim Simulator first (when available).""" -from isaaclab.app import AppLauncher - -HEADLESS = True +import os +import sys +from unittest.mock import MagicMock -# launch omniverse app -simulation_app = AppLauncher(headless=True).app +# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher +# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets +# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless +# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is +# set (bare Python, no Kit at all). +_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( + os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ +) -from unittest.mock import MagicMock +if not _kitless: + from isaaclab.app import AppLauncher + + simulation_app = AppLauncher(headless=True).app +else: + simulation_app = None + # Stub out the Kit/Omniverse modules that are not present under + # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). + # ``omni`` is a real namespace package, so missing submodules also need + # to be installed as attributes on it -- ``sys.modules`` alone is not + # enough because attribute access on the real ``omni`` won't fall + # through to ``sys.modules``. + import omni as _omni + + for _mod in ("physics", "physics.tensors", "physx", "timeline", "usd"): + _stub = MagicMock() + sys.modules[f"omni.{_mod}"] = _stub + # Bind the leaf attribute so that ``omni.`` resolves. + setattr(_omni, _mod.split(".", 1)[0], _stub) + for _mod in ("isaacsim.core", "isaacsim.core.simulation_manager"): + sys.modules.setdefault(_mod, MagicMock()) import numpy as np import pytest @@ -66,6 +92,15 @@ except ImportError: pass +try: + from isaaclab_ovphysx.assets.rigid_object.rigid_object import RigidObject as OvPhysxRigidObject + from isaaclab_ovphysx.assets.rigid_object.rigid_object_data import RigidObjectData as OvPhysxRigidObjectData + from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet + + BACKENDS.append("ovphysx") +except (ImportError, AttributeError): + pass + def create_physx_rigid_object( num_instances: int = 2, @@ -206,6 +241,62 @@ def create_newton_rigid_object( return rigid_object, mock_view +def create_ovphysx_rigid_object( + num_instances: int = 2, + device: str = "cuda:0", +): + """Create a test OvPhysX RigidObject instance with mocked tensor bindings.""" + body_names = ["base_link"] + + obj = object.__new__(OvPhysxRigidObject) + + obj.cfg = RigidObjectCfg(prim_path="/World/object") + + # Create mock binding set + mock_bindings = MockOvPhysxBindingSet( + num_instances=num_instances, + num_joints=0, + num_bodies=1, + body_names=body_names, + asset_kind="rigid_object", + ) + mock_bindings.set_random_data() + + object.__setattr__(obj, "_device", device) + object.__setattr__(obj, "_ovphysx", MagicMock()) + object.__setattr__(obj, "_bindings", mock_bindings.bindings) + object.__setattr__(obj, "_num_instances", num_instances) + object.__setattr__(obj, "_num_bodies", 1) + object.__setattr__(obj, "_body_names", body_names) + + # Create RigidObjectData + data = OvPhysxRigidObjectData(mock_bindings.bindings, device) + data.num_instances = num_instances + data.num_bodies = 1 + data._is_primed = True + object.__setattr__(obj, "_data", data) + + # Build the buffers RigidObject normally allocates in _initialize_impl + # (_ALL_INDICES, _ALL_*_MASK, pinned CPU staging buffers, wrench buf). + # _create_buffers also instantiates real WrenchComposers; those get + # replaced with mocks just below. + obj._create_buffers() + + # Replace the real wrench composers with mocks for iface coverage. + mock_inst_wrench = MockWrenchComposer(obj) + mock_perm_wrench = MockWrenchComposer(obj) + object.__setattr__(obj, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(obj, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising + object.__setattr__(obj, "_initialize_handle", None) + object.__setattr__(obj, "_invalidate_initialize_handle", None) + object.__setattr__(obj, "_prim_deletion_handle", None) + object.__setattr__(obj, "_debug_vis_handle", None) + + return obj, mock_bindings + + def create_mock_rigid_object( num_instances: int = 2, device: str = "cuda:0", @@ -226,6 +317,8 @@ def get_rigid_object( ): if backend == "physx": return create_physx_rigid_object(num_instances, device) + elif backend == "ovphysx": + return create_ovphysx_rigid_object(num_instances, device) elif backend == "newton": return create_newton_rigid_object(num_instances, device) elif backend.lower() == "mock": diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject.major.rst new file mode 100644 index 000000000000..2a43911295c2 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject.major.rst @@ -0,0 +1,50 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.assets.RigidObject` and + :class:`~isaaclab_ovphysx.assets.RigidObjectData` for single-actor rigid-body + simulation against the OVPhysX backend, satisfying the + :class:`~isaaclab.assets.BaseRigidObject` and + :class:`~isaaclab.assets.BaseRigidObjectData` contracts. Public surface + matches the PhysX/Newton conventions: ``write_root_*_to_sim_index`` / + ``write_root_*_to_sim_mask`` writers (link- and com-frame variants), + ``set_masses_*``, ``set_coms_*``, ``set_inertias_*`` setters, and the + external-wrench composers exposed via + :meth:`~isaaclab_ovphysx.assets.RigidObject.set_external_force_and_torque`. +* Added the ``RIGID_BODY_*`` :class:`TensorType` aliases in + :mod:`isaaclab_ovphysx.tensor_types` (``POSE``, ``VELOCITY``, ``WRENCH``, + ``MASS``, ``COM_POSE``, ``INERTIA``; plus ``ACCELERATION``, ``INV_MASS``, + ``INV_INERTIA`` declared for forward compatibility once the wheel ships + them). +* Added :class:`~isaaclab_ovphysx.assets.kernels` as a shared Warp-kernel + module (frame conversions, state concatenation, finite-difference + acceleration, index- and mask-style scatter writers) consumed by both the + rigid-object and articulation assets. +* Added USD prim-scan validation in + :meth:`~isaaclab_ovphysx.assets.RigidObject._initialize_impl`: a clear + ``RuntimeError`` is raised when ``cfg.prim_path`` resolves to no + ``UsdPhysics.RigidBodyAPI`` prim, multiple rigid-body prims, or a prim with + an enabled ``UsdPhysics.ArticulationRootAPI``. + +Changed +^^^^^^^ + +* Changed :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._release_physx` to + perform a soft reset (``physx.reset()``) and keep the cached + :class:`ovphysx.PhysX` reference alive across + :class:`~isaaclab.sim.SimulationContext` lifetimes, instead of dropping the + reference and triggering the wheel's dual-Carbonite static-destructor race. + :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._warmup_and_load` now reuses + the cached instance on subsequent calls. +* Changed :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._warmup_and_load` to + raise a clear ``RuntimeError`` when a later + :class:`~isaaclab.sim.SimulationContext` requests a different device than + the one the process is locked to, surfacing the wheel's process-global + device-mode lock as a Python error before + :exc:`ovphysx.types.PhysXDeviceError` would fire. +* Changed :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._configure_physx_scene_prim` + to apply the ``UsdPhysics.PhysxSceneAPI`` schema and + ``enableSceneQuerySupport`` on both CPU and GPU; GPU-only attributes + (``enableGPUDynamics``, ``broadphaseType``, the ``gpu*`` capacity attributes + from :class:`~isaaclab_ovphysx.physics.OvPhysxCfg`) remain gated on + ``device == "gpu"``. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/__init__.pyi b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/__init__.pyi index 516e15c5ef6a..52d1a435596a 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/__init__.pyi +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/__init__.pyi @@ -6,6 +6,9 @@ __all__ = [ "Articulation", "ArticulationData", + "RigidObject", + "RigidObjectData", ] from .articulation import Articulation, ArticulationData +from .rigid_object import RigidObject, RigidObjectData diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py index bea4345ca5ba..351467cb6164 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py @@ -22,10 +22,11 @@ from isaaclab.utils.wrench_composer import WrenchComposer from isaaclab_ovphysx import tensor_types as TT +from isaaclab_ovphysx.assets.kernels import _body_wrench_to_world, _scatter_rows_partial from isaaclab_ovphysx.physics import OvPhysxManager from .articulation_data import ArticulationData -from .kernels import _body_wrench_to_world, _scatter_rows_partial, update_soft_joint_pos_limits +from .kernels import update_soft_joint_pos_limits if TYPE_CHECKING: from isaaclab.actuators import ActuatorBase diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py index e5be6c05328b..7c59c946dfae 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py @@ -18,18 +18,17 @@ from isaaclab.utils.warp import ProxyArray from isaaclab_ovphysx import tensor_types as TT - -from .kernels import ( - _compose_body_com_poses, +from isaaclab_ovphysx.assets.kernels import ( _compose_root_com_pose, _compute_heading, _copy_first_body, - _fd_joint_acc, _projected_gravity, _world_vel_to_body_ang, _world_vel_to_body_lin, ) +from .kernels import _compose_body_com_poses, _fd_joint_acc + class ArticulationData(BaseArticulationData): """Data container for an articulation backed by ovphysx tensor bindings. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/kernels.py index b93c4e6d4b41..cc9faf15753a 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/kernels.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/kernels.py @@ -8,41 +8,6 @@ import warp as wp -@wp.kernel -def _body_wrench_to_world( - force_b: wp.array(dtype=wp.vec3f, ndim=2), - torque_b: wp.array(dtype=wp.vec3f, ndim=2), - poses: wp.array(dtype=wp.transformf, ndim=2), - wrench_out: wp.array(dtype=wp.float32, ndim=3), -): - """Rotate body-frame force/torque to world frame and pack into [N, L, 9].""" - i, j = wp.tid() - q = wp.transform_get_rotation(poses[i, j]) - f_w = wp.quat_rotate(q, force_b[i, j]) - t_w = wp.quat_rotate(q, torque_b[i, j]) - wrench_out[i, j, 0] = f_w[0] - wrench_out[i, j, 1] = f_w[1] - wrench_out[i, j, 2] = f_w[2] - wrench_out[i, j, 3] = t_w[0] - wrench_out[i, j, 4] = t_w[1] - wrench_out[i, j, 5] = t_w[2] - p_w = wp.transform_get_translation(poses[i, j]) - wrench_out[i, j, 6] = p_w[0] - wrench_out[i, j, 7] = p_w[1] - wrench_out[i, j, 8] = p_w[2] - - -@wp.kernel -def _scatter_rows_partial( - dst: wp.array2d(dtype=wp.float32), - src: wp.array2d(dtype=wp.float32), - ids: wp.array(dtype=wp.int32), -): - """dst[ids[i], j] = src[i, j] -- scatter partial [K,C] into full [N,C] on GPU.""" - i, j = wp.tid() - dst[ids[i], j] = src[i, j] - - @wp.func def compute_soft_joint_pos_limits_func( joint_pos_limits: wp.vec2f, @@ -93,38 +58,6 @@ def _fd_joint_acc( prev_vel[i, j] = cur_vel[i, j] -@wp.kernel -def _copy_first_body( - body_vel: wp.array(dtype=wp.spatial_vectorf, ndim=2), - root_vel: wp.array(dtype=wp.spatial_vectorf), -): - """Copy the first body's velocity to the root velocity buffer. - - Args: - body_vel: Body velocities. Shape is (num_envs, num_bodies). - root_vel: Output root velocities. Shape is (num_envs,). - """ - i = wp.tid() - root_vel[i] = body_vel[i, 0] - - -@wp.kernel -def _compose_root_com_pose( - link_pose: wp.array(dtype=wp.transformf), - com_pose_b: wp.array(dtype=wp.transformf, ndim=2), - com_pose_w: wp.array(dtype=wp.transformf), -): - """Compose root link pose with body-frame CoM offset to get world-frame root CoM pose. - - Args: - link_pose: Root link poses in world frame. Shape is (num_envs,). - com_pose_b: Body-frame CoM offsets. Shape is (num_envs, num_bodies). - com_pose_w: Output world-frame root CoM poses. Shape is (num_envs,). - """ - i = wp.tid() - com_pose_w[i] = wp.transform_multiply(link_pose[i], com_pose_b[i, 0]) - - @wp.kernel def _compose_body_com_poses( link_pose: wp.array(dtype=wp.transformf, ndim=2), @@ -140,78 +73,3 @@ def _compose_body_com_poses( """ i, j = wp.tid() com_pose_w[i, j] = wp.transform_multiply(link_pose[i, j], com_pose_b[i, j]) - - -@wp.kernel -def _projected_gravity( - gravity_vec_w: wp.array(dtype=wp.vec3f), - root_pose: wp.array(dtype=wp.transformf), - out: wp.array(dtype=wp.vec3f), -): - """Project world-frame gravity direction into the root body frame. - - Args: - gravity_vec_w: Gravity unit vector per instance in world frame. Shape is (num_envs,). - root_pose: Root link poses in world frame. Shape is (num_envs,). - out: Output projected gravity in body frame. Shape is (num_envs,). - """ - i = wp.tid() - q = wp.transform_get_rotation(root_pose[i]) - out[i] = wp.quat_rotate_inv(q, gravity_vec_w[i]) - - -@wp.kernel -def _compute_heading( - forward_vec_b: wp.array(dtype=wp.vec3f), - root_pose: wp.array(dtype=wp.transformf), - out: wp.array(dtype=wp.float32), -): - """Compute yaw heading angle from the forward direction rotated into the world frame. - - Args: - forward_vec_b: Forward direction in body frame per instance. Shape is (num_envs,). - root_pose: Root link poses in world frame. Shape is (num_envs,). - out: Output heading angles [rad]. Shape is (num_envs,). - """ - i = wp.tid() - q = wp.transform_get_rotation(root_pose[i]) - forward = wp.quat_rotate(q, forward_vec_b[i]) - out[i] = wp.atan2(forward[1], forward[0]) - - -@wp.kernel -def _world_vel_to_body_lin( - root_pose: wp.array(dtype=wp.transformf), - vel_w: wp.array(dtype=wp.spatial_vectorf), - out: wp.array(dtype=wp.vec3f), -): - """Rotate world-frame linear velocity into the root body frame. - - Args: - root_pose: Root link poses in world frame. Shape is (num_envs,). - vel_w: Spatial velocities in world frame. Shape is (num_envs,). - out: Output linear velocity in body frame. Shape is (num_envs,). - """ - i = wp.tid() - q = wp.transform_get_rotation(root_pose[i]) - lin = wp.spatial_top(vel_w[i]) - out[i] = wp.quat_rotate_inv(q, lin) - - -@wp.kernel -def _world_vel_to_body_ang( - root_pose: wp.array(dtype=wp.transformf), - vel_w: wp.array(dtype=wp.spatial_vectorf), - out: wp.array(dtype=wp.vec3f), -): - """Rotate world-frame angular velocity into the root body frame. - - Args: - root_pose: Root link poses in world frame. Shape is (num_envs,). - vel_w: Spatial velocities in world frame. Shape is (num_envs,). - out: Output angular velocity in body frame. Shape is (num_envs,). - """ - i = wp.tid() - q = wp.transform_get_rotation(root_pose[i]) - ang = wp.spatial_bottom(vel_w[i]) - out[i] = wp.quat_rotate_inv(q, ang) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py new file mode 100644 index 000000000000..cf49c8362636 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py @@ -0,0 +1,1108 @@ +# 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 + +import warp as wp + +vec13f = wp.types.vector(length=13, dtype=wp.float32) + +""" +Shared @wp.func helpers. +""" + + +@wp.func +def get_link_vel_from_root_com_vel_func( + com_vel: wp.spatial_vectorf, + link_pose: wp.transformf, + body_com_pose: wp.transformf, +): + """Compute link velocity from center-of-mass velocity. + + Transforms a COM spatial velocity into a link-frame velocity by projecting + the angular velocity contribution from the COM offset relative to the link frame. + + Args: + com_vel: COM spatial velocity (angular, linear). + link_pose: Link pose in world frame. + body_com_pose: COM pose in body (link) frame. + + Returns: + Link spatial velocity (angular, linear). + """ + projected_vel = wp.cross( + wp.spatial_bottom(com_vel), + wp.quat_rotate(wp.transform_get_rotation(link_pose), -wp.transform_get_translation(body_com_pose)), + ) + return wp.spatial_vector(wp.spatial_top(com_vel) + projected_vel, wp.spatial_bottom(com_vel)) + + +@wp.func +def get_com_pose_from_link_pose_func( + link_pose: wp.transformf, + body_com_pose: wp.transformf, +): + """Compute COM pose in world frame from link pose and body-frame COM offset. + + Args: + link_pose: Link pose in world frame. + body_com_pose: COM pose in body (link) frame. + + Returns: + COM pose in world frame. + """ + return link_pose * body_com_pose + + +@wp.func +def concat_pose_and_vel_to_state_func( + pose: wp.transformf, + vel: wp.spatial_vectorf, +) -> vec13f: + """Concatenate a pose and velocity into a 13-element state vector. + + The state vector layout is [pos(3), quat(4), ang_vel(3), lin_vel(3)]. + + Args: + pose: Pose as a transform (position + quaternion). + vel: Spatial velocity (angular, linear). + + Returns: + 13-element state vector. + """ + return vec13f( + pose[0], pose[1], pose[2], pose[3], pose[4], pose[5], pose[6], vel[0], vel[1], vel[2], vel[3], vel[4], vel[5] + ) + + +@wp.func +def compute_heading_w_func( + forward_vec: wp.vec3f, + quat: wp.quatf, +): + """Compute heading angle (yaw) in world frame from a forward vector and orientation. + + Rotates the forward vector by the quaternion and computes atan2(y, x). + + Args: + forward_vec: Forward direction vector in body frame. + quat: Orientation quaternion. + + Returns: + Heading angle in radians. + """ + forward_w = wp.quat_rotate(quat, forward_vec) + return wp.atan2(forward_w[1], forward_w[0]) + + +@wp.func +def set_state_transforms_func( + state: vec13f, + transform: wp.transformf, +) -> vec13f: + """Set the pose portion (first 7 elements) of a 13-element state vector. + + Overwrites elements [0..6] (position + quaternion) with the given transform, + leaving the velocity portion [7..12] unchanged. + + Args: + state: 13-element state vector to modify. + transform: New pose (position + quaternion). + + Returns: + Updated 13-element state vector. + """ + state[0] = transform[0] + state[1] = transform[1] + state[2] = transform[2] + state[3] = transform[3] + state[4] = transform[4] + state[5] = transform[5] + state[6] = transform[6] + return state + + +@wp.func +def set_state_velocities_func( + state: vec13f, + velocity: wp.spatial_vectorf, +) -> vec13f: + """Set the velocity portion (last 6 elements) of a 13-element state vector. + + Overwrites elements [7..12] (angular + linear velocity) with the given spatial velocity, + leaving the pose portion [0..6] unchanged. + + Args: + state: 13-element state vector to modify. + velocity: New spatial velocity (angular, linear). + + Returns: + Updated 13-element state vector. + """ + state[7] = velocity[0] + state[8] = velocity[1] + state[9] = velocity[2] + state[10] = velocity[3] + state[11] = velocity[4] + state[12] = velocity[5] + return state + + +@wp.func +def get_link_velocity_in_com_frame_func( + link_velocity_w: wp.spatial_vectorf, + link_pose_w: wp.transformf, + body_com_pose_b: wp.transformf, +): + """Compute COM velocity from link velocity by accounting for the COM offset. + + Transforms a link-frame spatial velocity into a COM-frame velocity by adding + the cross-product contribution of the COM offset rotated into the world frame. + + Args: + link_velocity_w: Link spatial velocity in world frame (angular, linear). + link_pose_w: Link pose in world frame. + body_com_pose_b: COM pose in body (link) frame. + + Returns: + COM spatial velocity in world frame (angular, linear). + """ + return wp.spatial_vector( + wp.spatial_top(link_velocity_w) + + wp.cross( + wp.spatial_bottom(link_velocity_w), + wp.quat_rotate(wp.transform_get_rotation(link_pose_w), wp.transform_get_translation(body_com_pose_b)), + ), + wp.spatial_bottom(link_velocity_w), + ) + + +@wp.func +def get_com_pose_in_link_frame_func( + com_pose_w: wp.transformf, + com_pose_b: wp.transformf, +): + """Compute link pose in world frame from COM pose by inverting the body-frame COM offset. + + This is the inverse of ``get_com_pose_from_link_pose_func``. Given the COM pose in + world frame and the COM offset in body frame, it recovers the link pose in world frame. + + Args: + com_pose_w: COM pose in world frame. + com_pose_b: COM pose in body (link) frame. + + Returns: + Link pose in world frame. + """ + T2 = wp.transform( + wp.quat_rotate( + wp.quat_inverse(wp.transform_get_rotation(com_pose_b)), -wp.transform_get_translation(com_pose_b) + ), + wp.quat_inverse(wp.transform_get_rotation(com_pose_b)), + ) + link_pose_w = com_pose_w * T2 + return link_pose_w + + +""" +Root-level @wp.kernel (1D — used by RigidObject + Articulation). +""" + + +@wp.kernel +def get_root_link_vel_from_root_com_vel( + com_vel: wp.array(dtype=wp.spatial_vectorf), + link_pose: wp.array(dtype=wp.transformf), + body_com_pose_b: wp.array2d(dtype=wp.transformf), + link_vel: wp.array(dtype=wp.spatial_vectorf), +): + """Compute root link velocity from root center-of-mass velocity. + + This kernel transforms the root COM velocity into link-frame velocity by projecting + the angular velocity contribution from the COM offset. + + Args: + com_vel: Input array of root COM spatial velocities. Shape is (num_envs,). + link_pose: Input array of root link poses in world frame. Shape is (num_envs,). + body_com_pose_b: Input array of body COM poses in body frame. Shape is (num_envs, num_bodies). + Only the first body (index 0) is used for the root. + link_vel: Output array where root link velocities are written. Shape is (num_envs,). + """ + i = wp.tid() + link_vel[i] = get_link_vel_from_root_com_vel_func(com_vel[i], link_pose[i], body_com_pose_b[i, 0]) + + +@wp.kernel +def get_root_com_pose_from_root_link_pose( + link_pose: wp.array(dtype=wp.transformf), + body_com_pose_b: wp.array2d(dtype=wp.transformf), + com_pose_w: wp.array(dtype=wp.transformf), +): + """Compute root COM pose from root link pose. + + This kernel transforms the root link pose to the root COM pose using the body COM offset. + + Args: + link_pose: Input array of root link poses in world frame. Shape is (num_envs,). + body_com_pose_b: Input array of body COM poses in body frame. Shape is (num_envs, num_bodies). + Only the first body (index 0) is used for the root. + com_pose_w: Output array where root COM poses are written. Shape is (num_envs,). + """ + i = wp.tid() + com_pose_w[i] = get_com_pose_from_link_pose_func(link_pose[i], body_com_pose_b[i, 0]) + + +@wp.kernel +def concat_root_pose_and_vel_to_state( + pose: wp.array(dtype=wp.transformf), + vel: wp.array(dtype=wp.spatial_vectorf), + state: wp.array(dtype=vec13f), +): + """Concatenate root pose and velocity into a 13-element state vector. + + This kernel combines a 7-element pose (pos + quat) and a 6-element velocity + (angular + linear) into a single 13-element state vector. + + Args: + pose: Input array of root poses in world frame. Shape is (num_envs,). + vel: Input array of root spatial velocities. Shape is (num_envs,). + state: Output array where concatenated state vectors are written. Shape is (num_envs,). + """ + i = wp.tid() + state[i] = concat_pose_and_vel_to_state_func(pose[i], vel[i]) + + +@wp.kernel +def split_state_to_root_pose_and_vel( + state: wp.array2d(dtype=wp.float32), + pose: wp.array(dtype=wp.transformf), + vel: wp.array(dtype=wp.spatial_vectorf), +): + """Split a 13-element state vector into root pose and velocity. + + This kernel extracts a 7-element pose (pos + quat) and a 6-element velocity + (angular + linear) from a 13-element state vector. + + Args: + state: Input array of root states. Shape is (num_envs, 13). + pose: Output array where root poses are written. Shape is (num_envs,). + vel: Output array where root spatial velocities are written. Shape is (num_envs,). + """ + i = wp.tid() + # Extract pose: [pos(3), quat(4)] = state[0:7] + pose[i] = wp.transform( + wp.vec3f(state[i, 0], state[i, 1], state[i, 2]), wp.quatf(state[i, 3], state[i, 4], state[i, 5], state[i, 6]) + ) + # Extract velocity: [ang_vel(3), lin_vel(3)] = state[7:13] + vel[i] = wp.spatial_vector( + wp.vec3f(state[i, 7], state[i, 8], state[i, 9]), # angular velocity + wp.vec3f(state[i, 10], state[i, 11], state[i, 12]), # linear velocity + ) + + +""" +Body-level @wp.kernel (2D — used by Articulation + RigidObjectCollection). +""" + + +@wp.kernel +def get_body_link_vel_from_body_com_vel( + body_com_vel: wp.array2d(dtype=wp.spatial_vectorf), + body_link_pose: wp.array2d(dtype=wp.transformf), + body_com_pose: wp.array2d(dtype=wp.transformf), + body_link_vel: wp.array2d(dtype=wp.spatial_vectorf), +): + """Compute body link velocities from body COM velocities for all bodies. + + This kernel transforms COM velocities into link-frame velocities by projecting + the angular velocity contribution from the COM offset, for each body in each environment. + + Args: + body_com_vel: Input array of body COM spatial velocities. Shape is (num_envs, num_bodies). + body_link_pose: Input array of body link poses in world frame. Shape is (num_envs, num_bodies). + body_com_pose: Input array of body COM poses in body frame. Shape is (num_envs, num_bodies). + body_link_vel: Output array where body link velocities are written. Shape is (num_envs, num_bodies). + """ + i, j = wp.tid() + body_link_vel[i, j] = get_link_vel_from_root_com_vel_func( + body_com_vel[i, j], body_link_pose[i, j], body_com_pose[i, j] + ) + + +@wp.kernel +def get_body_com_pose_from_body_link_pose( + body_link_pose: wp.array2d(dtype=wp.transformf), + body_com_pose_b: wp.array2d(dtype=wp.transformf), + body_com_pose_w: wp.array2d(dtype=wp.transformf), +): + """Compute body COM poses from body link poses for all bodies. + + This kernel transforms link poses to COM poses using the body COM offset in the body frame. + + Args: + body_link_pose: Input array of body link poses in world frame. Shape is (num_envs, num_bodies). + body_com_pose_b: Input array of body COM poses in body frame. Shape is (num_envs, num_bodies). + body_com_pose_w: Output array where body COM poses in world frame are written. + Shape is (num_envs, num_bodies). + """ + i, j = wp.tid() + body_com_pose_w[i, j] = get_com_pose_from_link_pose_func(body_link_pose[i, j], body_com_pose_b[i, j]) + + +@wp.kernel +def concat_body_pose_and_vel_to_state( + pose: wp.array2d(dtype=wp.transformf), + vel: wp.array2d(dtype=wp.spatial_vectorf), + state: wp.array2d(dtype=vec13f), +): + """Concatenate body pose and velocity into 13-element state vectors for all bodies. + + This kernel combines a 7-element pose (pos + quat) and a 6-element velocity + (angular + linear) into a single 13-element state vector, for each body in each environment. + + Args: + pose: Input array of body poses in world frame. Shape is (num_envs, num_bodies). + vel: Input array of body spatial velocities. Shape is (num_envs, num_bodies). + state: Output array where concatenated state vectors are written. + Shape is (num_envs, num_bodies). + """ + i, j = wp.tid() + state[i, j] = concat_pose_and_vel_to_state_func(pose[i, j], vel[i, j]) + + +""" +Derived property kernels. +""" + + +@wp.kernel +def quat_apply_inverse_1D_kernel( + gravity: wp.array(dtype=wp.vec3f), + quat: wp.array(dtype=wp.quatf), + projected_gravity: wp.array(dtype=wp.vec3f), +): + """Apply inverse quaternion rotation to gravity vectors (1D). + + This kernel rotates gravity vectors into the local frame of each environment + using the inverse of the provided quaternion. + + Args: + gravity: Input array of gravity vectors in world frame. Shape is (num_envs,). + quat: Input array of quaternions representing orientations. Shape is (num_envs,). + projected_gravity: Output array where projected gravity vectors are written. + Shape is (num_envs,). + """ + i = wp.tid() + projected_gravity[i] = wp.quat_rotate_inv(quat[i], gravity[i]) + + +@wp.kernel +def root_heading_w( + forward_vec: wp.array(dtype=wp.vec3f), + quat: wp.array(dtype=wp.quatf), + heading_w: wp.array(dtype=wp.float32), +): + """Compute root heading angle in the world frame. + + This kernel computes the heading angle (yaw) by rotating the forward vector + by the root quaternion and computing atan2 of the resulting x and y components. + + Args: + forward_vec: Input array of forward direction vectors. Shape is (num_envs,). + quat: Input array of root quaternions. Shape is (num_envs,). + heading_w: Output array where heading angles (radians) are written. Shape is (num_envs,). + """ + i = wp.tid() + heading_w[i] = compute_heading_w_func(forward_vec[i], quat[i]) + + +@wp.kernel +def quat_apply_inverse_2D_kernel( + vec: wp.array2d(dtype=wp.vec3f), + quat: wp.array2d(dtype=wp.quatf), + result: wp.array2d(dtype=wp.vec3f), +): + """Apply inverse quaternion rotation to vectors (2D). + + This kernel rotates vectors into the local frame of each body in each environment + using the inverse of the provided quaternion. + + Args: + vec: Input array of vectors in world frame. Shape is (num_envs, num_bodies). + quat: Input array of quaternions representing orientations. Shape is (num_envs, num_bodies). + result: Output array where rotated vectors are written. Shape is (num_envs, num_bodies). + """ + i, j = wp.tid() + result[i, j] = wp.quat_rotate_inv(quat[i, j], vec[i, j]) + + +@wp.kernel +def body_heading_w( + forward_vec: wp.array2d(dtype=wp.vec3f), + quat: wp.array2d(dtype=wp.quatf), + heading_w: wp.array2d(dtype=wp.float32), +): + """Compute body heading angles in the world frame for all bodies. + + This kernel computes heading angles (yaw) by rotating forward vectors + by body quaternions and computing atan2 of the resulting x and y components. + + Args: + forward_vec: Input array of forward direction vectors. Shape is (num_envs, num_bodies). + quat: Input array of body quaternions. Shape is (num_envs, num_bodies). + heading_w: Output array where heading angles (radians) are written. + Shape is (num_envs, num_bodies). + """ + i, j = wp.tid() + heading_w[i, j] = compute_heading_w_func(forward_vec[i, j], quat[i, j]) + + +""" +Root-level write kernels (1D — used by RigidObject + Articulation). +""" + + +@wp.kernel +def set_root_link_pose_to_sim_index( + data: wp.array(dtype=wp.transformf), + env_ids: wp.array(dtype=wp.int32), + root_link_pose_w: wp.array(dtype=wp.transformf), +): + """Write root link pose data to simulation buffers. + + This kernel scatters root link poses from the partial input array into the cached + world-frame buffer at the specified environment indices. + + Args: + data: Input array of root link poses. Shape is (num_selected_envs,). + env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). + root_link_pose_w: Output array where root link poses are written. Shape is (num_envs,). + """ + i = wp.tid() + root_link_pose_w[env_ids[i]] = data[i] + + +@wp.kernel +def set_root_com_pose_to_sim_index( + data: wp.array(dtype=wp.transformf), + body_com_pose_b: wp.array2d(dtype=wp.transformf), + env_ids: wp.array(dtype=wp.int32), + root_com_pose_w: wp.array(dtype=wp.transformf), + root_link_pose_w: wp.array(dtype=wp.transformf), +): + """Write root COM pose data to simulation buffers. + + This kernel scatters root COM poses from the partial input array into the cached + world-frame buffer at the specified environment indices and derives the + corresponding link pose via the body-frame COM offset. + + Args: + data: Input array of root COM poses. Shape is (num_selected_envs,). + body_com_pose_b: Input array of body COM poses in body frame. Shape is + (num_envs, num_bodies). Only the first body (index 0) is used for the root. + env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). + root_com_pose_w: Output array where root COM poses are written. Shape is (num_envs,). + root_link_pose_w: Output array where root link poses (derived from COM) are written. + Shape is (num_envs,). + """ + i = wp.tid() + root_com_pose_w[env_ids[i]] = data[i] + # Get the com pose in the link frame + root_link_pose_w[env_ids[i]] = get_com_pose_in_link_frame_func( + root_com_pose_w[env_ids[i]], body_com_pose_b[env_ids[i], 0] + ) + + +@wp.kernel +def set_root_com_velocity_to_sim_index( + data: wp.array(dtype=wp.spatial_vectorf), + env_ids: wp.array(dtype=wp.int32), + num_bodies: wp.int32, + root_com_velocity_w: wp.array(dtype=wp.spatial_vectorf), + body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), +): + """Write root COM velocity data to simulation buffers. + + This kernel scatters root COM velocities from the partial input array into the cached + world-frame buffer at the specified environment indices and zeros the body acceleration + buffer to prevent reporting stale values. + + Args: + data: Input array of root COM spatial velocities. Shape is (num_selected_envs,). + env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). + num_bodies: Input scalar number of bodies per environment. + root_com_velocity_w: Output array where root COM velocities are written. Shape is (num_envs,). + body_acc_w: Output array where body accelerations are zeroed. Shape is + (num_envs, num_bodies). + """ + i = wp.tid() + root_com_velocity_w[env_ids[i]] = data[i] + # Make the acceleration zero to prevent reporting old values + for j in range(num_bodies): + body_acc_w[env_ids[i], j] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + +@wp.kernel +def set_root_link_velocity_to_sim_index( + data: wp.array(dtype=wp.spatial_vectorf), + body_com_pose_b: wp.array2d(dtype=wp.transformf), + link_pose_w: wp.array(dtype=wp.transformf), + env_ids: wp.array(dtype=wp.int32), + num_bodies: wp.int32, + root_link_velocity_w: wp.array(dtype=wp.spatial_vectorf), + root_com_velocity_w: wp.array(dtype=wp.spatial_vectorf), + body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), +): + """Write root link velocity data to simulation buffers. + + This kernel scatters root link velocities from the partial input array into the cached + world-frame buffer at the specified environment indices, derives the corresponding + COM velocity via the lever-arm transform, and zeros the body acceleration buffer. + + Args: + data: Input array of root link spatial velocities. Shape is (num_selected_envs,). + body_com_pose_b: Input array of body COM poses in body frame. Shape is + (num_envs, num_bodies). Only the first body (index 0) is used for the root. + link_pose_w: Input array of root link poses in world frame. Shape is (num_envs,). + env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). + num_bodies: Input scalar number of bodies per environment. + root_link_velocity_w: Output array where root link velocities are written. + Shape is (num_envs,). + root_com_velocity_w: Output array where root COM velocities (derived from link) + are written. Shape is (num_envs,). + body_acc_w: Output array where body accelerations are zeroed. + Shape is (num_envs, num_bodies). + """ + i = wp.tid() + root_link_velocity_w[env_ids[i]] = data[i] + # Get the link velocity in the com frame + root_com_velocity_w[env_ids[i]] = get_link_velocity_in_com_frame_func( + root_link_velocity_w[env_ids[i]], link_pose_w[env_ids[i]], body_com_pose_b[env_ids[i], 0] + ) + # Make the acceleration zero to prevent reporting old values + for j in range(num_bodies): + body_acc_w[env_ids[i], j] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + +""" +Body-level write kernels (2D — used by RigidObjectCollection). +""" + + +@wp.kernel +def set_body_link_pose_to_sim( + data: wp.array2d(dtype=wp.transformf), + env_ids: wp.array(dtype=wp.int32), + body_ids: wp.array(dtype=wp.int32), + from_mask: bool, + body_link_pose_w: wp.array2d(dtype=wp.transformf), + body_link_state_w: wp.array2d(dtype=vec13f), + body_state_w: wp.array2d(dtype=vec13f), +): + """Write body link pose data to simulation buffers. + + This kernel writes body link poses from the input array to the output buffers + and optionally updates the corresponding state vectors, for each body in each environment. + + Args: + data: Input array of body link poses. Shape is (num_envs, num_bodies) or + (num_selected_envs, num_selected_bodies) depending on from_mask. + env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). + body_ids: Input array of body indices to write to. Shape is (num_selected_bodies,). + from_mask: Input flag indicating whether to use masked indexing. + body_link_pose_w: Output array where body link poses are written. + Shape is (num_envs, num_bodies). + body_link_state_w: Output array where body link states are updated (pose portion). + Shape is (num_envs, num_bodies). Can be None if not needed. + body_state_w: Output array where body states are updated (pose portion). + Shape is (num_envs, num_bodies). Can be None if not needed. + """ + i, j = wp.tid() + if from_mask: + body_link_pose_w[env_ids[i], body_ids[j]] = data[env_ids[i], body_ids[j]] + if body_link_state_w: + body_link_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( + body_link_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] + ) + if body_state_w: + body_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( + body_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] + ) + else: + body_link_pose_w[env_ids[i], body_ids[j]] = data[i, j] + if body_link_state_w: + body_link_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( + body_link_state_w[env_ids[i], body_ids[j]], data[i, j] + ) + if body_state_w: + body_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( + body_state_w[env_ids[i], body_ids[j]], data[i, j] + ) + + +@wp.kernel +def set_body_com_pose_to_sim( + data: wp.array2d(dtype=wp.transformf), + body_com_pose_b: wp.array2d(dtype=wp.transformf), + env_ids: wp.array(dtype=wp.int32), + body_ids: wp.array(dtype=wp.int32), + from_mask: bool, + body_com_pose_w: wp.array2d(dtype=wp.transformf), + body_link_pose_w: wp.array2d(dtype=wp.transformf), + body_com_state_w: wp.array2d(dtype=vec13f), + body_link_state_w: wp.array2d(dtype=vec13f), + body_state_w: wp.array2d(dtype=vec13f), +): + """Write body COM pose data to simulation buffers. + + This kernel writes body COM poses from the input array to the output buffers, + computes the corresponding link poses from the COM poses, and optionally updates + the corresponding state vectors, for each body in each environment. + + Args: + data: Input array of body COM poses. Shape is (num_envs, num_bodies) or + (num_selected_envs, num_selected_bodies) depending on from_mask. + body_com_pose_b: Input array of body COM poses in body frame. Shape is + (num_envs, num_bodies). + env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). + body_ids: Input array of body indices to write to. Shape is (num_selected_bodies,). + from_mask: Input flag indicating whether to use masked indexing. + body_com_pose_w: Output array where body COM poses are written. + Shape is (num_envs, num_bodies). + body_link_pose_w: Output array where body link poses (derived from COM) are written. + Shape is (num_envs, num_bodies). + body_com_state_w: Output array where body COM states are updated (pose portion). + Shape is (num_envs, num_bodies). Can be None if not needed. + body_link_state_w: Output array where body link states are updated (pose portion). + Shape is (num_envs, num_bodies). Can be None if not needed. + body_state_w: Output array where body states are updated (pose portion). + Shape is (num_envs, num_bodies). Can be None if not needed. + """ + i, j = wp.tid() + if from_mask: + body_com_pose_w[env_ids[i], body_ids[j]] = data[env_ids[i], body_ids[j]] + if body_com_state_w: + body_com_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( + body_com_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] + ) + else: + body_com_pose_w[env_ids[i], body_ids[j]] = data[i, j] + if body_com_state_w: + body_com_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( + body_com_state_w[env_ids[i], body_ids[j]], data[i, j] + ) + # Get the link pose from com pose + body_link_pose_w[env_ids[i], body_ids[j]] = get_com_pose_in_link_frame_func( + body_com_pose_w[env_ids[i], body_ids[j]], body_com_pose_b[env_ids[i], body_ids[j]] + ) + if body_link_state_w: + body_link_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( + body_link_state_w[env_ids[i], body_ids[j]], body_link_pose_w[env_ids[i], body_ids[j]] + ) + if body_state_w: + body_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( + body_state_w[env_ids[i], body_ids[j]], body_link_pose_w[env_ids[i], body_ids[j]] + ) + + +@wp.kernel +def set_body_com_velocity_to_sim( + data: wp.array2d(dtype=wp.spatial_vectorf), + env_ids: wp.array(dtype=wp.int32), + body_ids: wp.array(dtype=wp.int32), + from_mask: bool, + body_com_velocity_w: wp.array2d(dtype=wp.spatial_vectorf), + body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), + body_state_w: wp.array2d(dtype=vec13f), + body_com_state_w: wp.array2d(dtype=vec13f), +): + """Write body COM velocity data to simulation buffers. + + This kernel writes body COM velocities from the input array to the output buffers, + optionally updates the corresponding state vectors, and zeros out the body + acceleration buffer, for each body in each environment. + + Args: + data: Input array of body COM spatial velocities. Shape is (num_envs, num_bodies) or + (num_selected_envs, num_selected_bodies) depending on from_mask. + env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). + body_ids: Input array of body indices to write to. Shape is (num_selected_bodies,). + from_mask: Input flag indicating whether to use masked indexing. + body_com_velocity_w: Output array where body COM velocities are written. + Shape is (num_envs, num_bodies). + body_acc_w: Output array where body accelerations are zeroed. + Shape is (num_envs, num_bodies). + body_state_w: Output array where body states are updated (velocity portion). + Shape is (num_envs, num_bodies). Can be None if not needed. + body_com_state_w: Output array where body COM states are updated (velocity portion). + Shape is (num_envs, num_bodies). Can be None if not needed. + """ + i, j = wp.tid() + if from_mask: + body_com_velocity_w[env_ids[i], body_ids[j]] = data[env_ids[i], body_ids[j]] + if body_state_w: + body_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( + body_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] + ) + if body_com_state_w: + body_com_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( + body_com_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] + ) + else: + body_com_velocity_w[env_ids[i], body_ids[j]] = data[i, j] + if body_state_w: + body_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( + body_state_w[env_ids[i], body_ids[j]], data[i, j] + ) + if body_com_state_w: + body_com_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( + body_com_state_w[env_ids[i], body_ids[j]], data[i, j] + ) + # Make the acceleration zero to prevent reporting old values + body_acc_w[env_ids[i], body_ids[j]] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + +@wp.kernel +def set_body_link_velocity_to_sim( + data: wp.array2d(dtype=wp.spatial_vectorf), + body_com_pose_b: wp.array2d(dtype=wp.transformf), + body_link_pose_w: wp.array2d(dtype=wp.transformf), + env_ids: wp.array(dtype=wp.int32), + body_ids: wp.array(dtype=wp.int32), + from_mask: bool, + body_link_velocity_w: wp.array2d(dtype=wp.spatial_vectorf), + body_com_velocity_w: wp.array2d(dtype=wp.spatial_vectorf), + body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), + body_link_state_w: wp.array2d(dtype=vec13f), + body_state_w: wp.array2d(dtype=vec13f), + body_com_state_w: wp.array2d(dtype=vec13f), +): + """Write body link velocity data to simulation buffers. + + This kernel writes body link velocities from the input array to the output buffers, + computes the corresponding COM velocities from the link velocities, optionally updates + the corresponding state vectors, and zeros out the body acceleration buffer. + + Args: + data: Input array of body link spatial velocities. Shape is (num_envs, num_bodies) + or (num_selected_envs, num_selected_bodies) depending on from_mask. + body_com_pose_b: Input array of body COM poses in body frame. Shape is + (num_envs, num_bodies). + body_link_pose_w: Input array of body link poses in world frame. Shape is + (num_envs, num_bodies). + env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). + body_ids: Input array of body indices to write to. Shape is (num_selected_bodies,). + from_mask: Input flag indicating whether to use masked indexing. + body_link_velocity_w: Output array where body link velocities are written. + Shape is (num_envs, num_bodies). + body_com_velocity_w: Output array where body COM velocities (derived from link) + are written. Shape is (num_envs, num_bodies). + body_acc_w: Output array where body accelerations are zeroed. + Shape is (num_envs, num_bodies). + body_link_state_w: Output array where body link states are updated (velocity portion). + Shape is (num_envs, num_bodies). Can be None if not needed. + body_state_w: Output array where body states are updated (velocity portion). + Shape is (num_envs, num_bodies). Can be None if not needed. + body_com_state_w: Output array where body COM states are updated (velocity portion). + Shape is (num_envs, num_bodies). Can be None if not needed. + """ + i, j = wp.tid() + if from_mask: + body_link_velocity_w[env_ids[i], body_ids[j]] = data[env_ids[i], body_ids[j]] + if body_link_state_w: + body_link_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( + body_link_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] + ) + else: + body_link_velocity_w[env_ids[i], body_ids[j]] = data[i, j] + if body_link_state_w: + body_link_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( + body_link_state_w[env_ids[i], body_ids[j]], data[i, j] + ) + # Get the link velocity in the com frame + body_com_velocity_w[env_ids[i], body_ids[j]] = get_link_velocity_in_com_frame_func( + body_link_velocity_w[env_ids[i], body_ids[j]], + body_link_pose_w[env_ids[i], body_ids[j]], + body_com_pose_b[env_ids[i], body_ids[j]], + ) + if body_com_state_w: + body_com_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( + body_com_state_w[env_ids[i], body_ids[j]], body_com_velocity_w[env_ids[i], body_ids[j]] + ) + if body_state_w: + body_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( + body_state_w[env_ids[i], body_ids[j]], body_com_velocity_w[env_ids[i], body_ids[j]] + ) + # Make the acceleration zero to prevent reporting old values + body_acc_w[env_ids[i], body_ids[j]] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + +""" +Generic buffer-writing kernels (used by Articulation + RigidObject + RigidObjectCollection). +""" + + +@wp.kernel +def write_2d_data_to_buffer_with_indices( + in_data: wp.array2d(dtype=wp.float32), + env_ids: wp.array(dtype=wp.int32), + joint_ids: wp.array(dtype=wp.int32), + out_data: wp.array2d(dtype=wp.float32), +): + """Write 2D float data to a buffer at specified indices. + + This kernel copies float data from a partial input array to an output buffer at the + specified environment and joint/body indices. + + Args: + in_data: Input array containing float data. Shape is (num_selected_envs, num_selected_joints). + env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). + joint_ids: Input array of joint/body indices to write to. Shape is (num_selected_joints,). + out_data: Output array where data is written. Shape is (num_envs, num_joints). + """ + i, j = wp.tid() + out_data[env_ids[i], joint_ids[j]] = in_data[i, j] + + +@wp.kernel +def write_body_inertia_to_buffer_index( + in_data: wp.array3d(dtype=wp.float32), + env_ids: wp.array(dtype=wp.int32), + body_ids: wp.array(dtype=wp.int32), + out_data: wp.array3d(dtype=wp.float32), +): + """Write body inertia data to a buffer at specified indices. + + This kernel copies 3x3 inertia tensor data (stored as 9 floats) from a partial input + array to an output buffer at the specified environment and body indices. + + Args: + in_data: Input array containing inertia data. Shape is (num_selected_envs, num_selected_bodies, 9). + env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). + body_ids: Input array of body indices to write to. Shape is (num_selected_bodies,). + out_data: Output array where inertia data is written. Shape is (num_envs, num_bodies, 9). + """ + i, j = wp.tid() + for k in range(9): + out_data[env_ids[i], body_ids[j], k] = in_data[i, j, k] + + +@wp.kernel +def write_body_com_pose_to_buffer_index( + in_data: wp.array2d(dtype=wp.transformf), + env_ids: wp.array(dtype=wp.int32), + body_ids: wp.array(dtype=wp.int32), + out_data: wp.array2d(dtype=wp.transformf), +): + """Write body COM pose data to a buffer at specified indices. + + This kernel copies body COM pose data from a partial input array to an output buffer + at the specified environment and body indices. + + Args: + in_data: Input array containing body COM poses. Shape is (num_selected_envs, num_selected_bodies). + env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). + body_ids: Input array of body indices to write to. Shape is (num_selected_bodies,). + out_data: Output array where body COM poses are written. Shape is (num_envs, num_bodies). + """ + i, j = wp.tid() + out_data[env_ids[i], body_ids[j]] = in_data[i, j] + + +@wp.kernel +def derive_body_acceleration_from_body_com_velocities( + body_com_vel: wp.array2d(dtype=wp.spatial_vectorf), + dt: wp.float32, + prev_body_com_vel: wp.array2d(dtype=wp.spatial_vectorf), + body_acc: wp.array2d(dtype=wp.spatial_vectorf), +): + """Derive body acceleration from body COM velocities. + + This kernel derives body acceleration from body COM velocities using finite differencing. + + Args: + body_com_vel: Input array of body COM velocities. Shape is (num_envs, num_bodies). + dt: Input time step (scalar) used for finite differencing. + prev_body_com_vel: Input/output array of previous body COM velocities. Shape is (num_envs, num_bodies). + body_acc: Output array where body accelerations are written. Shape is (num_envs, num_bodies). + """ + i, j = wp.tid() + # Compute the acceleration + body_acc[i, j] = (body_com_vel[i, j] - prev_body_com_vel[i, j]) / dt + # Update the previous body COM velocity + prev_body_com_vel[i, j] = body_com_vel[i, j] + + +@wp.kernel +def _body_wrench_to_world( + force_b: wp.array(dtype=wp.vec3f, ndim=2), + torque_b: wp.array(dtype=wp.vec3f, ndim=2), + poses: wp.array(dtype=wp.transformf, ndim=2), + wrench_out: wp.array(dtype=wp.float32, ndim=3), +): + """Rotate body-frame force/torque to world frame and pack into a flat output array. + + Output layout per ``(i, j)`` slice (9 floats total): + + * ``[0:3]`` -- world-frame force ``[N]`` + * ``[3:6]`` -- world-frame torque ``[N*m]`` + * ``[6:9]`` -- world-frame link position ``[m]`` + + Args: + force_b: Body-frame applied forces ``[N]``. Shape is ``(N, L)``. + torque_b: Body-frame applied torques ``[N*m]``. Shape is ``(N, L)``. + poses: Link poses in world frame. Shape is ``(N, L)``. + wrench_out: Output packed wrench array. Shape is ``(N, L, 9)``. + """ + i, j = wp.tid() + q = wp.transform_get_rotation(poses[i, j]) + f_w = wp.quat_rotate(q, force_b[i, j]) + t_w = wp.quat_rotate(q, torque_b[i, j]) + wrench_out[i, j, 0] = f_w[0] + wrench_out[i, j, 1] = f_w[1] + wrench_out[i, j, 2] = f_w[2] + wrench_out[i, j, 3] = t_w[0] + wrench_out[i, j, 4] = t_w[1] + wrench_out[i, j, 5] = t_w[2] + p_w = wp.transform_get_translation(poses[i, j]) + wrench_out[i, j, 6] = p_w[0] + wrench_out[i, j, 7] = p_w[1] + wrench_out[i, j, 8] = p_w[2] + + +@wp.kernel +def _scatter_rows_partial( + dst: wp.array2d(dtype=wp.float32), + src: wp.array2d(dtype=wp.float32), + ids: wp.array(dtype=wp.int32), +): + """Scatter a partial row-indexed source array into a larger destination array. + + For each thread ``(i, j)`` writes ``dst[ids[i], j] = src[i, j]``. + + Args: + dst: Destination array of shape ``(N, C)`` to scatter values into. + src: Source array of shape ``(K, C)`` containing the values to scatter. + ids: Row indices into ``dst`` for each row of ``src``. Shape is ``(K,)``. + """ + i, j = wp.tid() + dst[ids[i], j] = src[i, j] + + +""" +Native-mask scatter kernels (mirrors Newton; the OVPhysX wheel's ``binding.write`` natively +supports a boolean mask via the ``mask=`` argument, so the ``*_mask`` setters update the cache +in-place and pass the mask straight through to the wheel without a ``torch.nonzero`` round-trip). +""" + + +@wp.kernel +def set_root_link_pose_to_sim_mask( + data: wp.array(dtype=wp.transformf), + env_mask: wp.array(dtype=wp.bool), + root_link_pose_w: wp.array(dtype=wp.transformf), +): + """Mask-scatter root link poses into the cache; rows where ``env_mask[i]`` is False are untouched.""" + i = wp.tid() + if env_mask[i]: + root_link_pose_w[i] = data[i] + + +@wp.kernel +def set_root_com_pose_to_sim_mask( + data: wp.array(dtype=wp.transformf), + body_com_pose_b: wp.array2d(dtype=wp.transformf), + env_mask: wp.array(dtype=wp.bool), + root_com_pose_w: wp.array(dtype=wp.transformf), + root_link_pose_w: wp.array(dtype=wp.transformf), +): + """Mask-scatter root COM poses into the cache and derive the corresponding link poses.""" + i = wp.tid() + if env_mask[i]: + root_com_pose_w[i] = data[i] + # link_pose = com_pose * inverse(com_pose_b) + root_link_pose_w[i] = wp.transform_multiply(root_com_pose_w[i], wp.transform_inverse(body_com_pose_b[i, 0])) + + +@wp.kernel +def set_root_com_velocity_to_sim_mask( + data: wp.array(dtype=wp.spatial_vectorf), + env_mask: wp.array(dtype=wp.bool), + num_bodies: wp.int32, + root_com_velocity_w: wp.array(dtype=wp.spatial_vectorf), + body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), +): + """Mask-scatter root COM velocities into the cache and zero the dependent body acceleration.""" + i = wp.tid() + if env_mask[i]: + root_com_velocity_w[i] = data[i] + for j in range(num_bodies): + body_acc_w[i, j] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + +@wp.kernel +def set_root_link_velocity_to_sim_mask( + data: wp.array(dtype=wp.spatial_vectorf), + body_com_pose_b: wp.array2d(dtype=wp.transformf), + link_pose_w: wp.array(dtype=wp.transformf), + env_mask: wp.array(dtype=wp.bool), + num_bodies: wp.int32, + root_link_velocity_w: wp.array(dtype=wp.spatial_vectorf), + root_com_velocity_w: wp.array(dtype=wp.spatial_vectorf), + body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), +): + """Mask-scatter root link velocities into the cache and derive the corresponding COM velocities + via the lever-arm transform: ``com_lin = link_lin + omega x rot(link_rot, com_offset)``. + """ + i = wp.tid() + if env_mask[i]: + root_link_velocity_w[i] = data[i] + ang = wp.spatial_bottom(data[i]) + lever = wp.quat_rotate( + wp.transform_get_rotation(link_pose_w[i]), wp.transform_get_translation(body_com_pose_b[i, 0]) + ) + com_lin = wp.spatial_top(data[i]) + wp.cross(ang, lever) + root_com_velocity_w[i] = wp.spatial_vector(com_lin, ang) + for j in range(num_bodies): + body_acc_w[i, j] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + +@wp.kernel +def write_2d_data_to_buffer_with_mask( + in_data: wp.array2d(dtype=wp.float32), + env_mask: wp.array(dtype=wp.bool), + body_mask: wp.array(dtype=wp.bool), + out_data: wp.array2d(dtype=wp.float32), +): + """Mask-scatter 2D float data into the cache where both ``env_mask[i]`` and ``body_mask[j]`` are True.""" + i, j = wp.tid() + if env_mask[i] and body_mask[j]: + out_data[i, j] = in_data[i, j] + + +@wp.kernel +def write_body_inertia_to_buffer_mask( + in_data: wp.array3d(dtype=wp.float32), + env_mask: wp.array(dtype=wp.bool), + body_mask: wp.array(dtype=wp.bool), + out_data: wp.array3d(dtype=wp.float32), +): + """Mask-scatter body inertia (3x3 = 9 floats per body) into the cache.""" + i, j = wp.tid() + if env_mask[i] and body_mask[j]: + for k in range(9): + out_data[i, j, k] = in_data[i, j, k] + + +@wp.kernel +def write_body_com_pose_to_buffer_mask( + in_data: wp.array2d(dtype=wp.transformf), + env_mask: wp.array(dtype=wp.bool), + body_mask: wp.array(dtype=wp.bool), + out_data: wp.array2d(dtype=wp.transformf), +): + """Mask-scatter body COM poses (transformf) into the cache.""" + i, j = wp.tid() + if env_mask[i] and body_mask[j]: + out_data[i, j] = in_data[i, j] diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/__init__.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/__init__.py new file mode 100644 index 000000000000..441515cd83a9 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/__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 + +"""Sub-module for ovphysx-backed rigid object assets.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/__init__.pyi b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/__init__.pyi new file mode 100644 index 000000000000..1c96a5aa4550 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/__init__.pyi @@ -0,0 +1,12 @@ +# 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__ = [ + "RigidObject", + "RigidObjectData", +] + +from .rigid_object import RigidObject +from .rigid_object_data import RigidObjectData diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object.py new file mode 100644 index 000000000000..015c8f102f44 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object.py @@ -0,0 +1,1173 @@ +# 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 + +"""OVPhysX-backed RigidObject implementation.""" + +from __future__ import annotations + +import re +import warnings +from collections.abc import Sequence +from typing import Any + +import numpy as np +import torch +import warp as wp + +from pxr import UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.assets.rigid_object.base_rigid_object import BaseRigidObject +from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg +from isaaclab.utils.string import resolve_matching_names +from isaaclab.utils.wrench_composer import WrenchComposer + +from isaaclab_ovphysx import tensor_types as TT +from isaaclab_ovphysx.assets import kernels as shared_kernels +from isaaclab_ovphysx.assets.kernels import _body_wrench_to_world +from isaaclab_ovphysx.physics import OvPhysxManager + +from .rigid_object_data import RigidObjectData + + +class RigidObject(BaseRigidObject): + """A rigid object asset class. + + Rigid objects are assets comprising of rigid bodies. They can be used to represent dynamic objects + such as boxes, spheres, etc. A rigid body is described by its pose, velocity and mass distribution. + + For an asset to be considered a rigid object, the root prim of the asset must have the `USD RigidBodyAPI`_ + applied to it. This API is used to define the simulation properties of the rigid body. On playing the + simulation, the physics engine will automatically register the rigid body and create a corresponding + rigid body handle. State is read and written through ovphysx ``TensorBinding`` objects acquired from + the :class:`~isaaclab_ovphysx.physics.OvPhysxManager`. Only free (non-articulated) rigid bodies are + supported; prims under an ``ArticulationRootAPI`` should use + :class:`~isaaclab_ovphysx.assets.articulation.Articulation` instead. + + .. _`USD RigidBodyAPI`: https://openusd.org/dev/api/class_usd_physics_rigid_body_a_p_i.html + """ + + cfg: RigidObjectCfg + """Configuration instance for the rigid object.""" + + __backend_name__: str = "ovphysx" + """The name of the backend for the rigid object.""" + + def __init__(self, cfg: RigidObjectCfg): + """Initialize the rigid object. + + Args: + cfg: A configuration instance. + """ + super().__init__(cfg) + # Bindings are created lazily (on first access) to avoid allocating + # handles for tensor types the user never queries. + self._bindings: dict[int, Any] = {} + + """ + Properties + """ + + @property + def data(self) -> RigidObjectData: + return self._data + + @property + def num_instances(self) -> int: + return self._num_instances + + @property + def num_bodies(self) -> int: + """Number of bodies in the asset. + + This is always 1 since each object is a single rigid body. + """ + return self._num_bodies + + @property + def body_names(self) -> list[str]: + """Ordered names of bodies in the rigid object.""" + return self._body_names + + @property + def root_view(self) -> dict[int, Any]: + """Root view for the asset. + + OVPhysX exposes per-tensor-type bindings rather than a single opaque view object + as used by the PhysX and Newton backends. Callers that need low-level binding + access should call :meth:`_get_binding` rather than iterating this dict directly. + For high-level state access (instance counts, prim paths, transforms), use the + :attr:`num_instances`, :attr:`body_names`, and + :attr:`~RigidObjectData.root_link_pose_w` accessors instead. + + .. note:: + Use this view with caution. It requires handling of tensors in a specific way. + """ + return self._bindings + + @property + def instantaneous_wrench_composer(self) -> WrenchComposer | None: + """Instantaneous wrench composer. + + Returns a :class:`~isaaclab.utils.wrench_composer.WrenchComposer` instance. Wrenches added or set to this wrench + composer are only valid for the current simulation step. At the end of the simulation step, the wrenches set + to this object are discarded. This is useful to apply forces that change all the time, things like drag forces + for instance. + """ + return self._instantaneous_wrench_composer + + @property + def permanent_wrench_composer(self) -> WrenchComposer | None: + """Permanent wrench composer. + + Returns a :class:`~isaaclab.utils.wrench_composer.WrenchComposer` instance. Wrenches added or set to this wrench + composer are persistent and are applied to the simulation at every step. This is useful to apply forces that + are constant over a period of time, things like the thrust of a motor for instance. + """ + return self._permanent_wrench_composer + + """ + Operations. + """ + + def reset( + self, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_mask: wp.array | None = None + ) -> None: + """Reset the rigid object. + + Args: + env_ids: Environment indices. If None, then all indices are used. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + # resolve all indices + if (env_ids is None) or (env_ids == slice(None)): + env_ids = slice(None) + # reset external wrench + self._instantaneous_wrench_composer.reset(env_ids, env_mask) + self._permanent_wrench_composer.reset(env_ids, env_mask) + + def write_data_to_sim(self) -> None: + """Write external wrench to the simulation. + + .. note:: + We write external wrench to the simulation here since this function is called before the simulation step. + This ensures that the external wrench is applied at every simulation step. + """ + inst = self._instantaneous_wrench_composer + perm = self._permanent_wrench_composer + if not inst.active and not perm.active: + return + if inst.active: + if perm.active: + inst.add_raw_buffers_from(perm) + force_b = inst.out_force_b.warp + torque_b = inst.out_torque_b.warp + else: + force_b = perm.out_force_b.warp + torque_b = perm.out_torque_b.warp + + poses = self._data.body_link_pose_w.warp # (N, 1) wp.transformf + wp.launch( + _body_wrench_to_world, + dim=(self._num_instances, 1), + inputs=[force_b, torque_b, poses], + outputs=[self._wrench_buf], + device=self._device, + ) + binding = self._get_binding(TT.RIGID_BODY_WRENCH) + binding.write(self._wrench_buf_flat) + inst.reset() + + def update(self, dt: float) -> None: + """Updates the simulation data. + + Args: + dt: The time step size in seconds. + """ + self._data.update(dt) + + """ + Operations - Finders. + """ + + def find_bodies(self, name_keys: str | Sequence[str], preserve_order: bool = False) -> tuple[list[int], list[str]]: + """Find bodies in the rigid body based on the name keys. + + Please check the :meth:`isaaclab.utils.string.resolve_matching_names` function for more + information on the name matching. + + Args: + name_keys: A regular expression or a list of regular expressions to match the body names. + preserve_order: Whether to preserve the order of the name keys in the output. Defaults to False. + + Returns: + A tuple of lists containing the body indices and names. + """ + return resolve_matching_names(name_keys, self._body_names, preserve_order) + + """ + Operations - Write to simulation. + """ + + def write_root_pose_to_sim_index( + self, + *, + root_pose: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Set the root pose over selected environment indices into the simulation. + + The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_pose: Root poses in simulation frame. Shape is (len(env_ids), 7) + or (len(env_ids),) with dtype wp.transformf. + env_ids: Environment indices. If None, then all indices are used. + """ + self.write_root_link_pose_to_sim_index(root_pose=root_pose, env_ids=env_ids) + + def write_root_pose_to_sim_mask( + self, + *, + root_pose: torch.Tensor | wp.array, + env_mask: wp.array | None = None, + ) -> None: + """Set the root pose over selected environment mask into the simulation. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_pose: Root poses in simulation frame. Shape is (num_instances, 7) + or (num_instances,) with dtype wp.transformf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + self.write_root_link_pose_to_sim_mask(root_pose=root_pose, env_mask=env_mask) + + def write_root_velocity_to_sim_index( + self, + *, + root_velocity: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Set the root center of mass velocity over selected environment indices into the simulation. + + The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + + .. note:: + This sets the velocity of the root's center of mass rather than the root's frame. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_velocity: Root center of mass velocities in simulation world frame. Shape is (len(env_ids), 6) + or (len(env_ids),) with dtype wp.spatial_vectorf. + env_ids: Environment indices. If None, then all indices are used. + """ + self.write_root_com_velocity_to_sim_index(root_velocity=root_velocity, env_ids=env_ids) + + def write_root_velocity_to_sim_mask( + self, + *, + root_velocity: torch.Tensor | wp.array, + env_mask: wp.array | None = None, + ) -> None: + """Set the root center of mass velocity over selected environment mask into the simulation. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_velocity: Root center of mass velocities in simulation world frame. Shape is (num_instances, 6) + or (num_instances,) with dtype wp.spatial_vectorf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + self.write_root_com_velocity_to_sim_mask(root_velocity=root_velocity, env_mask=env_mask) + + def write_root_link_pose_to_sim_index( + self, + *, + root_pose: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Set the root link pose over selected environment indices into the simulation. + + The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_pose: Root link poses in simulation frame. Shape is (len(env_ids), 7) + or (len(env_ids),) with dtype wp.transformf. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + self.assert_shape_and_dtype(root_pose, (env_ids.shape[0],), wp.transformf, "root_pose") + wp.launch( + shared_kernels.set_root_link_pose_to_sim_index, + dim=env_ids.shape[0], + inputs=[root_pose, env_ids], + outputs=[self.data.root_link_pose_w], + device=self._device, + ) + # Invalidate dependent root_com_pose timestamp so the next read recomposes it. + self.data._root_com_pose_w.timestamp = -1.0 + # Push cache to the wheel via an indexed write. + binding = self._get_binding(TT.RIGID_BODY_POSE) + binding.write(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) + + def write_root_link_pose_to_sim_mask( + self, + *, + root_pose: torch.Tensor | wp.array, + env_mask: wp.array | None = None, + ) -> None: + """Set the root link pose over selected environment mask into the simulation. + + The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_pose: Root poses in simulation frame. Shape is (num_instances, 7) + or (num_instances,) with dtype wp.transformf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + env_mask_wp = self._resolve_env_mask(env_mask) + self.assert_shape_and_dtype(root_pose, (self._num_instances,), wp.transformf, "root_pose") + wp.launch( + shared_kernels.set_root_link_pose_to_sim_mask, + dim=self._num_instances, + inputs=[root_pose, env_mask_wp], + outputs=[self.data.root_link_pose_w], + device=self._device, + ) + self.data._root_com_pose_w.timestamp = -1.0 + binding = self._get_binding(TT.RIGID_BODY_POSE) + binding.write(self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) + + def write_root_com_pose_to_sim_index( + self, + *, + root_pose: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Set the root center of mass pose over selected environment indices into the simulation. + + The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + The orientation is the orientation of the principal axes of inertia. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_pose: Root center of mass poses in simulation frame. Shape is (len(env_ids), 7) + or (len(env_ids),) with dtype wp.transformf. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + self.assert_shape_and_dtype(root_pose, (env_ids.shape[0],), wp.transformf, "root_pose") + wp.launch( + shared_kernels.set_root_com_pose_to_sim_index, + dim=env_ids.shape[0], + inputs=[root_pose, self.data.body_com_pose_b, env_ids], + outputs=[self.data.root_com_pose_w, self.data.root_link_pose_w], + device=self._device, + ) + binding = self._get_binding(TT.RIGID_BODY_POSE) + binding.write(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) + + def write_root_com_pose_to_sim_mask( + self, + *, + root_pose: torch.Tensor | wp.array, + env_mask: wp.array | None = None, + ) -> None: + """Set the root center of mass pose over selected environment mask into the simulation. + + The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + The orientation is the orientation of the principal axes of inertia. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_pose: Root center of mass poses in simulation frame. Shape is (num_instances, 7) + or (num_instances,) with dtype wp.transformf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + env_mask_wp = self._resolve_env_mask(env_mask) + self.assert_shape_and_dtype(root_pose, (self._num_instances,), wp.transformf, "root_pose") + wp.launch( + shared_kernels.set_root_com_pose_to_sim_mask, + dim=self._num_instances, + inputs=[root_pose, self.data.body_com_pose_b, env_mask_wp], + outputs=[self.data.root_com_pose_w, self.data.root_link_pose_w], + device=self._device, + ) + binding = self._get_binding(TT.RIGID_BODY_POSE) + binding.write(self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) + + def write_root_com_velocity_to_sim_index( + self, + *, + root_velocity: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Set the root center of mass velocity over selected environment indices into the simulation. + + The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + + .. note:: + This sets the velocity of the root's center of mass rather than the root's frame. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_velocity: Root center of mass velocities in simulation world frame. Shape is (len(env_ids), 6) + or (len(env_ids),) with dtype wp.spatial_vectorf. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + self.assert_shape_and_dtype(root_velocity, (env_ids.shape[0],), wp.spatial_vectorf, "root_velocity") + wp.launch( + shared_kernels.set_root_com_velocity_to_sim_index, + dim=env_ids.shape[0], + inputs=[root_velocity, env_ids, 1], + outputs=[self.data.root_com_vel_w, self.data.body_com_acc_w], + device=self._device, + ) + # Invalidate dependent root_link_vel timestamp. + self.data._root_link_vel_w.timestamp = -1.0 + binding = self._get_binding(TT.RIGID_BODY_VELOCITY) + binding.write(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) + + def write_root_com_velocity_to_sim_mask( + self, + *, + root_velocity: torch.Tensor | wp.array, + env_mask: wp.array | None = None, + ) -> None: + """Set the root center of mass velocity over selected environment mask into the simulation. + + The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + + .. note:: + This sets the velocity of the root's center of mass rather than the root's frame. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_velocity: Root center of mass velocities in simulation world frame. Shape is (num_instances, 6) + or (num_instances,) with dtype wp.spatial_vectorf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + env_mask_wp = self._resolve_env_mask(env_mask) + self.assert_shape_and_dtype(root_velocity, (self._num_instances,), wp.spatial_vectorf, "root_velocity") + wp.launch( + shared_kernels.set_root_com_velocity_to_sim_mask, + dim=self._num_instances, + inputs=[root_velocity, env_mask_wp, 1], + outputs=[self.data.root_com_vel_w, self.data.body_com_acc_w], + device=self._device, + ) + self.data._root_link_vel_w.timestamp = -1.0 + binding = self._get_binding(TT.RIGID_BODY_VELOCITY) + binding.write(self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp) + + def write_root_link_velocity_to_sim_index( + self, + *, + root_velocity: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Set the root link velocity over selected environment indices into the simulation. + + The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + + .. note:: + This sets the velocity of the root's frame rather than the root's center of mass. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_velocity: Root frame velocities in simulation world frame. Shape is (len(env_ids), 6) + or (len(env_ids),) with dtype wp.spatial_vectorf. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + self.assert_shape_and_dtype(root_velocity, (env_ids.shape[0],), wp.spatial_vectorf, "root_velocity") + wp.launch( + shared_kernels.set_root_link_velocity_to_sim_index, + dim=env_ids.shape[0], + inputs=[ + root_velocity, + self.data.body_com_pose_b, + self.data.root_link_pose_w, + env_ids, + 1, # num_bodies is always 1 for RigidObject + ], + outputs=[self.data.root_link_vel_w, self.data.root_com_vel_w, self.data.body_com_acc_w], + device=self._device, + ) + binding = self._get_binding(TT.RIGID_BODY_VELOCITY) + binding.write(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) + + def write_root_link_velocity_to_sim_mask( + self, + *, + root_velocity: torch.Tensor | wp.array, + env_mask: wp.array | None = None, + ) -> None: + """Set the root link velocity over selected environment mask into the simulation. + + The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + + .. note:: + This sets the velocity of the root's frame rather than the root's center of mass. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + root_velocity: Root frame velocities in simulation world frame. Shape is (num_instances, 6) + or (num_instances,) with dtype wp.spatial_vectorf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + env_mask_wp = self._resolve_env_mask(env_mask) + self.assert_shape_and_dtype(root_velocity, (self._num_instances,), wp.spatial_vectorf, "root_velocity") + wp.launch( + shared_kernels.set_root_link_velocity_to_sim_mask, + dim=self._num_instances, + inputs=[root_velocity, self.data.body_com_pose_b, self.data.root_link_pose_w, env_mask_wp, 1], + outputs=[self.data.root_link_vel_w, self.data.root_com_vel_w, self.data.body_com_acc_w], + device=self._device, + ) + binding = self._get_binding(TT.RIGID_BODY_VELOCITY) + binding.write(self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp) + + """ + Operations - Setters. + """ + + def set_masses_index( + self, + *, + masses: torch.Tensor | wp.array, + body_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Set masses of all bodies using indices. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + masses: Masses of all bodies. Shape is (len(env_ids), len(body_ids)). + body_ids: The body indices to set the masses for. Defaults to None (all bodies). + env_ids: The environment indices to set the masses for. Defaults to None (all environments). + """ + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype(masses, (env_ids.shape[0], body_ids.shape[0]), wp.float32, "masses") + # Scatter user data into the cached _body_mass at (env_ids, body_ids). + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[masses, env_ids, body_ids], + outputs=[self.data._body_mass], + device=self._device, + ) + # Push cache to the wheel via pinned-CPU staging (RIGID_BODY_MASS is CPU-only). + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self._cpu_body_mass, self.data._body_mass) + binding = self._get_binding(TT.RIGID_BODY_MASS) + binding.write(self._cpu_body_mass.flatten(), indices=cpu_env_ids) + + def set_masses_mask( + self, + *, + masses: torch.Tensor | wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set masses of all bodies using masks. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + masses: Masses of all bodies. Shape is (num_instances, num_bodies). + body_mask: Body mask. If None, then all bodies are used. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + env_mask_wp = self._resolve_env_mask(env_mask) + body_mask_wp = self._resolve_body_mask(body_mask) + self.assert_shape_and_dtype(masses, (self._num_instances, self._num_bodies), wp.float32, "masses") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=(self._num_instances, self._num_bodies), + inputs=[masses, env_mask_wp, body_mask_wp], + outputs=[self.data._body_mass], + device=self._device, + ) + wp.copy(self._cpu_body_mass, self.data._body_mass) + binding = self._get_binding(TT.RIGID_BODY_MASS) + binding.write(self._cpu_body_mass.flatten(), mask=self._get_cpu_env_mask(env_mask_wp)) + + def set_coms_index( + self, + *, + coms: torch.Tensor | wp.array, + body_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Set center of mass pose of all bodies using indices. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + coms: Center of mass pose of all bodies. Shape is (len(env_ids), len(body_ids), 7). + body_ids: The body indices to set the center of mass pose for. Defaults to None (all bodies). + env_ids: The environment indices to set the center of mass pose for. Defaults to None + (all environments). + """ + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype(coms, (env_ids.shape[0], body_ids.shape[0]), wp.transformf, "coms") + wp.launch( + shared_kernels.write_body_com_pose_to_buffer_index, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[coms, env_ids, body_ids], + outputs=[self.data._body_com_pose_b.data], + device=self._device, + ) + # Invalidate dependent root_com_pose timestamp -- it's derived from body_com_pose_b. + self.data._root_com_pose_w.timestamp = -1.0 + # Push cache to the wheel via pinned-CPU staging (RIGID_BODY_COM_POSE is CPU-only). + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self._cpu_body_coms, self.data._body_com_pose_b.data) + binding = self._get_binding(TT.RIGID_BODY_COM_POSE) + # Wheel binding shape is (N, 7); squeeze singleton body dim with a flat float32 view. + binding.write(self._cpu_body_coms.reshape((self._num_instances, 7)), indices=cpu_env_ids) + + def set_coms_mask( + self, + *, + coms: torch.Tensor | wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set center of mass pose of all bodies using masks. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + coms: Center of mass pose of all bodies. Shape is (num_instances, num_bodies, 7). + body_mask: Body mask. If None, then all bodies are used. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + env_mask_wp = self._resolve_env_mask(env_mask) + body_mask_wp = self._resolve_body_mask(body_mask) + self.assert_shape_and_dtype(coms, (self._num_instances, self._num_bodies), wp.transformf, "coms") + wp.launch( + shared_kernels.write_body_com_pose_to_buffer_mask, + dim=(self._num_instances, self._num_bodies), + inputs=[coms, env_mask_wp, body_mask_wp], + outputs=[self.data._body_com_pose_b.data], + device=self._device, + ) + self.data._root_com_pose_w.timestamp = -1.0 + wp.copy(self._cpu_body_coms, self.data._body_com_pose_b.data) + binding = self._get_binding(TT.RIGID_BODY_COM_POSE) + binding.write(self._cpu_body_coms.reshape((self._num_instances, 7)), mask=self._get_cpu_env_mask(env_mask_wp)) + + def set_inertias_index( + self, + *, + inertias: torch.Tensor | wp.array, + body_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Set inertias of all bodies using indices. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + inertias: Inertias of all bodies. Shape is (len(env_ids), len(body_ids), 9). + body_ids: The body indices to set the inertias for. Defaults to None (all bodies). + env_ids: The environment indices to set the inertias for. Defaults to None (all environments). + """ + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype(inertias, (env_ids.shape[0], body_ids.shape[0], 9), wp.float32, "inertias") + wp.launch( + shared_kernels.write_body_inertia_to_buffer_index, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[inertias, env_ids, body_ids], + outputs=[self.data._body_inertia], + device=self._device, + ) + # Push cache to the wheel via pinned-CPU staging (RIGID_BODY_INERTIA is CPU-only). + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self._cpu_body_inertia, self.data._body_inertia) + binding = self._get_binding(TT.RIGID_BODY_INERTIA) + # Wheel binding shape is (N, 9); flatten the singleton body dim. + binding.write(self._cpu_body_inertia.reshape((self._num_instances, 9)), indices=cpu_env_ids) + + def set_inertias_mask( + self, + *, + inertias: torch.Tensor | wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set inertias of all bodies using masks. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + + Args: + inertias: Inertias of all bodies. Shape is (num_instances, num_bodies, 9). + body_mask: Body mask. If None, then all bodies are used. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + env_mask_wp = self._resolve_env_mask(env_mask) + body_mask_wp = self._resolve_body_mask(body_mask) + self.assert_shape_and_dtype(inertias, (self._num_instances, self._num_bodies, 9), wp.float32, "inertias") + wp.launch( + shared_kernels.write_body_inertia_to_buffer_mask, + dim=(self._num_instances, self._num_bodies), + inputs=[inertias, env_mask_wp, body_mask_wp], + outputs=[self.data._body_inertia], + device=self._device, + ) + wp.copy(self._cpu_body_inertia, self.data._body_inertia) + binding = self._get_binding(TT.RIGID_BODY_INERTIA) + binding.write( + self._cpu_body_inertia.reshape((self._num_instances, 9)), mask=self._get_cpu_env_mask(env_mask_wp) + ) + + """ + Internal helper. + """ + + def _initialize_impl(self) -> None: + # acquire ovphysx instance + physx_instance = OvPhysxManager.get_physx_instance() + if physx_instance is None: + raise RuntimeError("OvPhysxManager has not been initialized yet.") + self._ovphysx = physx_instance + # Derive the device from PhysicsManager (which mirrors SimulationContext.cfg.device). + # The ovphysx PhysX object does not expose a .device property; reading it would + # raise AttributeError (masked by hasattr) and fall back to "cuda:0" even when the + # simulation is running on CPU, causing a device mismatch in binding.read(). + self._device = OvPhysxManager.get_device() + # Convert IsaacLab prim-path notation to the glob patterns ovphysx expects. + # IsaacLab uses two conventions: + # /World/envs/env_.*/object -- regex dot-star for "any env index" + # /World/envs/{ENV_REGEX_NS}/object -- explicit placeholder + # ovphysx ``create_tensor_binding`` uses fnmatch-style globs, so both map to ``*``. + pattern = re.sub(r"\{ENV_REGEX_NS\}", "*", self.cfg.prim_path) + pattern = re.sub(r"\.\*", "*", pattern) + self._binding_pattern = pattern + + # Validate the prim tree before creating tensor bindings -- the wheel silently + # produces a 0-prim binding when the pattern matches nothing, which surfaces as an + # obscure ``TypeError`` deep in property accessors. + # obtain the first prim in the regex expression (all others are assumed to be a copy of this) + template_prim = sim_utils.find_first_matching_prim(self.cfg.prim_path) + if template_prim is None: + raise RuntimeError(f"Failed to find prim for expression: '{self.cfg.prim_path}'.") + template_prim_path = template_prim.GetPath().pathString + + # find rigid root prims + root_prims = sim_utils.get_all_matching_child_prims( + template_prim_path, + predicate=lambda prim: prim.HasAPI(UsdPhysics.RigidBodyAPI), + traverse_instance_prims=False, + ) + if len(root_prims) == 0: + raise RuntimeError( + f"Failed to find a rigid body when resolving '{self.cfg.prim_path}'." + " Please ensure that the prim has 'USD RigidBodyAPI' applied." + ) + if len(root_prims) > 1: + raise RuntimeError( + f"Failed to find a single rigid body when resolving '{self.cfg.prim_path}'." + f" Found multiple '{root_prims}' under '{template_prim_path}'." + " Please ensure that there is only one rigid body in the prim path tree." + ) + articulation_prims = sim_utils.get_all_matching_child_prims( + template_prim_path, + predicate=lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), + traverse_instance_prims=False, + ) + if len(articulation_prims) != 0: + if articulation_prims[0].GetAttribute("physxArticulation:articulationEnabled").Get(): + raise RuntimeError( + f"Found an articulation root when resolving '{self.cfg.prim_path}' for rigid" + f" objects. These are located at: '{articulation_prims}' under" + f" '{template_prim_path}'. Please disable the articulation root in the USD" + " or from code by setting the parameter" + " 'ArticulationRootPropertiesCfg.articulation_enabled' to False in the spawn" + " configuration." + ) + + # Eagerly create every binding the data container reads at init, so failures + # surface here with a helpful message rather than as a raw wheel exception + # (or a KeyError) at first writer call. + for tt in ( + TT.RIGID_BODY_POSE, + TT.RIGID_BODY_VELOCITY, + TT.RIGID_BODY_WRENCH, + TT.RIGID_BODY_MASS, + TT.RIGID_BODY_COM_POSE, + TT.RIGID_BODY_INERTIA, + ): + try: + self._get_binding(tt) + except Exception as e: + raise RuntimeError( + f"OVPhysX could not create rigid-body binding {tt!r}. " + f"Check that prim_path={self._binding_pattern!r} matches " + f"at least one UsdPhysics.RigidBodyAPI prim and that the " + f"ovphysx wheel exposes the RIGID_BODY_* TensorType. " + f"Note: pattern resolution may currently include articulation " + f"links; an explicit selection policy is on the wheel-side roadmap." + ) from e + + # read counts and body names from the root-pose binding + root_pose = self._bindings[TT.RIGID_BODY_POSE] + self._num_instances = root_pose.count + self._num_bodies = 1 + try: + body_names_value = root_pose.body_names + # body_names may be an empty list for non-articulation bindings; fall back to + # the documented single-body default in that case. + self._body_names = list(body_names_value) if body_names_value else ["base_link"] + except (AttributeError, TypeError): + # ovphysx TensorBinding raises TypeError (not AttributeError) when body_names + # is queried on a non-articulation tensor type such as RIGID_BODY_POSE: + # "Articulation metadata … is not available for tensor type 'RIGID_BODY_POSE'." + # For a single-body rigid object the default ["base_link"] is always correct. + self._body_names = ["base_link"] + + # container for data access + self._data = RigidObjectData(self._bindings, self._device, check_shapes=self._check_shapes) + + # create buffers + self._create_buffers() + # process configuration + self._process_cfg() + # update the rigid body data + self.update(0.0) + # Let the rigid object data know that it is fully instantiated and ready to use. + self._data.is_primed = True + + def _create_buffers(self) -> None: + """Create buffers for storing data.""" + N = self._num_instances + B = 1 # rigid object always has a single body + device = self._device + + # constants + self._ALL_INDICES = wp.array(np.arange(N, dtype=np.int32), device=device) + self._ALL_BODY_INDICES = wp.array(np.arange(B, dtype=np.int32), device=device) + # All-true masks for default mask paths. These let ``binding.write(..., mask=...)`` + # cover all instances when no env_mask is supplied, without converting back to indices. + self._ALL_TRUE_ENV_MASK = wp.array(np.ones(N, dtype=bool), dtype=wp.bool, device=device) + self._ALL_TRUE_BODY_MASK = wp.array(np.ones(B, dtype=bool), dtype=wp.bool, device=device) + + # external wrench composer + # The kernel writes into the (N, 1, 9) view; the binding consumes the (N, 9) view -- + # both alias the same allocation, so we cache the flat reshape once. + self._wrench_buf = wp.zeros((N, 1, 9), dtype=wp.float32, device=device) + self._wrench_buf_flat = wp.array( + ptr=self._wrench_buf.ptr, + shape=(N, 9), + dtype=wp.float32, + device=device, + copy=False, + ) + self._instantaneous_wrench_composer = WrenchComposer(self) + self._permanent_wrench_composer = WrenchComposer(self) + + # set information about rigid body into data + self._data.body_names = self._body_names + + # Pre-allocated pinned CPU buffers for OVPhysX TensorBinding writes. The wheel + # requires CPU arrays for "model" property updates (mass / coms / inertia); pinned + # host memory enables DMA fast path and avoids per-call ``wp.clone`` allocation. + self._cpu_env_ids_all = wp.zeros(N, dtype=wp.int32, device="cpu", pinned=True) + wp.copy(self._cpu_env_ids_all, self._ALL_INDICES) + self._cpu_body_mass = wp.zeros((N, B), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_body_coms = wp.zeros((N, B, 7), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_body_inertia = wp.zeros((N, B, 9), dtype=wp.float32, device="cpu", pinned=True) + # Pinned-host mask staging for CPU-only binding writes (mass/coms/inertia). + self._cpu_env_mask = wp.zeros(N, dtype=wp.bool, device="cpu", pinned=True) + + def _process_cfg(self) -> None: + """Post processing of configuration parameters.""" + # default state + # -- root state + # note: we cast to tuple to avoid torch/numpy type mismatch. + default_root_pose = tuple(self.cfg.init_state.pos) + tuple(self.cfg.init_state.rot) + default_root_vel = tuple(self.cfg.init_state.lin_vel) + tuple(self.cfg.init_state.ang_vel) + default_root_pose = np.tile(np.array(default_root_pose, dtype=np.float32), (self._num_instances, 1)) + default_root_vel = np.tile(np.array(default_root_vel, dtype=np.float32), (self._num_instances, 1)) + self._data.default_root_pose = wp.array(default_root_pose, dtype=wp.transformf, device=self._device) + self._data.default_root_vel = wp.array(default_root_vel, dtype=wp.spatial_vectorf, device=self._device) + + def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array: + """Resolve environment indices to a warp array. + + Args: + env_ids: Environment indices. If None, then all indices are used. + + Returns: + A warp array of environment indices on ``self._device``. + """ + if env_ids is None or env_ids == slice(None): + return self._ALL_INDICES + if isinstance(env_ids, list): + return wp.array(env_ids, dtype=wp.int32, device=self._device) + if isinstance(env_ids, torch.Tensor): + return wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) + if isinstance(env_ids, wp.array) and str(env_ids.device) != self._device: + env_ids = wp.clone(env_ids, device=self._device) + return env_ids + + def _resolve_body_ids(self, body_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array: + """Resolve body indices to a warp array. + + Args: + body_ids: Body indices. If None, then all indices are used. + + Returns: + A warp array of body indices on ``self._device``. + """ + if body_ids is None or 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) + return body_ids + + def _resolve_env_mask(self, env_mask: wp.array | None) -> wp.array: + """Resolve an environment mask to a ``wp.bool`` array. + + Args: + env_mask: Environment mask. If None, then the pre-allocated all-true mask is used. + + Returns: + A warp ``wp.bool`` array on ``self._device``. + """ + if env_mask is None: + return self._ALL_TRUE_ENV_MASK + if isinstance(env_mask, torch.Tensor): + return wp.from_torch(env_mask.to(torch.bool), dtype=wp.bool) + if isinstance(env_mask, wp.array) and str(env_mask.device) != self._device: + env_mask = wp.clone(env_mask, device=self._device) + return env_mask + + def _resolve_body_mask(self, body_mask: wp.array | None) -> wp.array: + """Resolve a body mask to a ``wp.bool`` array. + + Args: + body_mask: Body mask. If None, then the pre-allocated all-true mask is used. + + Returns: + A warp ``wp.bool`` array on ``self._device``. + """ + if body_mask is None: + return self._ALL_TRUE_BODY_MASK + if isinstance(body_mask, torch.Tensor): + return wp.from_torch(body_mask.to(torch.bool), dtype=wp.bool) + if isinstance(body_mask, wp.array) and str(body_mask.device) != self._device: + body_mask = wp.clone(body_mask, device=self._device) + return body_mask + + def _get_cpu_env_mask(self, env_mask: wp.array) -> wp.array: + """Return a pinned-host CPU copy of *env_mask* for a CPU-only binding write. + + The wheel's ``binding.write(mask=...)`` requires the mask on the binding's + device, which is CPU for mass / coms / inertia. Reuses the pre-allocated + ``_cpu_env_mask`` pinned buffer. + """ + wp.copy(self._cpu_env_mask, env_mask) + return self._cpu_env_mask + + def _get_cpu_env_ids(self, env_ids: wp.array | torch.Tensor) -> wp.array: + """Return CPU int32 indices, using the pre-allocated pinned ``_cpu_env_ids_all`` + fast path when *env_ids* matches ``_ALL_INDICES``. + """ + if isinstance(env_ids, torch.Tensor): + env_ids = wp.from_torch(env_ids, dtype=wp.int32) + if env_ids.ptr == self._ALL_INDICES.ptr: + return self._cpu_env_ids_all + return wp.clone(env_ids, device="cpu") + + def _get_binding(self, tensor_type: int): + """Return a cached TensorBinding, creating it on first access. + + Bindings are lightweight handles (a pointer + shape metadata into PhysX's + shared GPU buffer). Creating one does NOT allocate new GPU memory -- the + underlying simulation buffers are allocated once by PhysX regardless of how + many bindings point into them. Still, we defer creation so that tensor types + the user never queries are never looked up. + + Args: + tensor_type: The TensorType constant identifying which simulation buffer + to bind (e.g. :attr:`~isaaclab_ovphysx.tensor_types.RIGID_BODY_POSE`). + + Returns: + The cached TensorBinding for ``tensor_type``. + """ + binding = self._bindings.get(tensor_type) + if binding is not None: + return binding + binding = self._ovphysx.create_tensor_binding(pattern=self._binding_pattern, tensor_type=tensor_type) + self._bindings[tensor_type] = binding + return binding + + """ + Internal simulation callbacks. + """ + + def _invalidate_initialize_callback(self, event) -> None: + """Invalidates the scene elements.""" + super()._invalidate_initialize_callback(event) + + def write_root_state_to_sim( + self, + root_state: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated, same as :meth:`write_root_link_pose_to_sim_index` and + :meth:`write_root_com_velocity_to_sim_index`.""" + warnings.warn( + "The function 'write_root_state_to_sim' will be deprecated in a future release. Please" + " use 'write_root_link_pose_to_sim_index' and 'write_root_com_velocity_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_root_link_pose_to_sim_index(root_pose=root_state[:, :7], env_ids=env_ids) + self.write_root_com_velocity_to_sim_index(root_velocity=root_state[:, 7:], env_ids=env_ids) + + def write_root_com_state_to_sim( + self, + root_state: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated, same as :meth:`write_root_com_pose_to_sim_index` and + :meth:`write_root_com_velocity_to_sim_index`.""" + warnings.warn( + "The function 'write_root_com_state_to_sim' will be deprecated in a future release. Please" + " use 'write_root_com_pose_to_sim_index' and 'write_root_com_velocity_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_root_com_pose_to_sim_index(root_pose=root_state[:, :7], env_ids=env_ids) + self.write_root_com_velocity_to_sim_index(root_velocity=root_state[:, 7:], env_ids=env_ids) + + def write_root_link_state_to_sim( + self, + root_state: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated, same as :meth:`write_root_link_pose_to_sim_index` and + :meth:`write_root_link_velocity_to_sim_index`.""" + warnings.warn( + "The function 'write_root_link_state_to_sim' will be deprecated in a future release. Please" + " use 'write_root_link_pose_to_sim_index' and 'write_root_link_velocity_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_root_link_pose_to_sim_index(root_pose=root_state[:, :7], env_ids=env_ids) + self.write_root_link_velocity_to_sim_index(root_velocity=root_state[:, 7:], env_ids=env_ids) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object_data.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object_data.py new file mode 100644 index 000000000000..05e5c45a0ebb --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object_data.py @@ -0,0 +1,1198 @@ +# 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 + +"""OVPhysX-backed RigidObjectData implementation.""" + +from __future__ import annotations + +import math +import warnings + +import torch +import warp as wp + +from isaaclab.assets.rigid_object.base_rigid_object_data import BaseRigidObjectData +from isaaclab.utils.buffers import TimestampedBufferWarp as TimestampedBuffer +from isaaclab.utils.math import normalize +from isaaclab.utils.warp import ProxyArray + +from isaaclab_ovphysx import tensor_types as TT +from isaaclab_ovphysx.assets import kernels as shared_kernels +from isaaclab_ovphysx.physics import OvPhysxManager as SimulationManager + + +class RigidObjectData(BaseRigidObjectData): + """Data container for a rigid object. + + This class contains the data for a rigid object in the simulation. The data includes the state of + the root rigid body and the state of all the bodies in the object. The data is stored in the simulation + world frame unless otherwise specified. + + For a rigid body, there are two frames of reference that are used: + + - Actor frame: The frame of reference of the rigid body prim. This typically corresponds to the Xform prim + with the rigid body schema. + - Center of mass frame: The frame of reference of the center of mass of the rigid body. + + Depending on the settings of the simulation, the actor frame and the center of mass frame may be the same. + This needs to be taken into account when interpreting the data. + + The data is lazily updated, meaning that the data is only updated when it is accessed. This is useful + when the data is expensive to compute or retrieve. The data is updated when the timestamp of the buffer + is older than the current simulation timestamp. The timestamp is updated whenever the data is updated. + + .. note:: + **Pull-to-refresh model.** Properties pull fresh data from the PhysX tensor API on first + access per timestamp and cache the result. This differs from Newton, where buffers are + refreshed automatically by the simulation. + + .. note:: + **ProxyArray pointer stability.** Each :class:`ProxyArray` wrapper is created once and + reused because the PhysX tensor API returns views into stable, pre-allocated GPU buffers + whose device pointer does not change across simulation steps. + """ + + __backend_name__: str = "ovphysx" + """The name of the backend for the rigid object data.""" + + def __init__( + self, + bindings: dict, + device: str, + check_shapes: bool = True, + ): + """Initializes the rigid object data. + + Args: + bindings: The OVPhysX tensor bindings dict keyed by tensor-type constant. + ``num_instances`` is read from ``bindings[RIGID_BODY_POSE].count`` and + ``num_bodies`` is fixed at 1; ``body_names`` is set by + :meth:`~isaaclab_ovphysx.assets.RigidObject._initialize_impl`. + device: The device used for processing. + check_shapes: Whether to enforce internal shape/dtype invariants on + lazy reads. Defaults to ``True``; production callers thread this + from :attr:`~isaaclab.assets.AssetBaseCfg.disable_shape_checks`. + """ + super().__init__(bindings, device) + # Set the tensor bindings (OVPhysX exposes per-tensor-type bindings rather than a single view). + self._bindings = bindings + self._check_shapes = check_shapes + # Set initial time stamp + self._sim_timestamp = 0.0 + self._is_primed = False + root_pose = self._bindings[TT.RIGID_BODY_POSE] + self._num_instances = root_pose.count + self._num_bodies = 1 + + if SimulationManager._sim is not None and hasattr(SimulationManager._sim, "cfg"): + gravity = SimulationManager._sim.cfg.gravity + else: + gravity = (0.0, 0.0, -9.81) + + gravity_dir = torch.tensor((gravity[0], gravity[1], gravity[2]), device=self.device) + # When gravity is disabled (cfg.gravity == (0, 0, 0)), normalize() would NaN. + if torch.linalg.norm(gravity_dir) > 0.0: + gravity_dir = normalize(gravity_dir.unsqueeze(0)).squeeze(0) + gravity_dir = gravity_dir.repeat(self._num_instances, 1) + forward_vec = torch.tensor((1.0, 0.0, 0.0), device=self.device).repeat(self._num_instances, 1) + + # Initialize constants + self.GRAVITY_VEC_W = ProxyArray(wp.from_torch(gravity_dir, dtype=wp.vec3f)) + self.FORWARD_VEC_B = ProxyArray(wp.from_torch(forward_vec, dtype=wp.vec3f)) + + self._create_buffers() + + @property + def is_primed(self) -> bool: + """Whether the rigid object data is fully instantiated and ready to use.""" + return self._is_primed + + @is_primed.setter + def is_primed(self, value: bool) -> None: + """Set whether the rigid object data is fully instantiated and ready to use. + + .. note:: + Once this quantity is set to True, it cannot be changed. + + Args: + value: The primed state. + + Raises: + ValueError: If the rigid object data is already primed. + """ + if self._is_primed: + raise ValueError("The rigid object data is already primed.") + self._is_primed = value + + def update(self, dt: float) -> None: + """Updates the data for the rigid object. + + Args: + dt: The time step for the update [s]. This must be a positive value. + """ + # update the simulation timestamp + self._sim_timestamp += dt + # Trigger an update of the body com acceleration buffer at a higher frequency + # since we do finite differencing. + self.body_com_acc_w + + """ + Names. + """ + + body_names: list[str] = None + """Body names in the order parsed by the simulation view.""" + + """ + Defaults. + """ + + @property + def default_root_pose(self) -> ProxyArray: + """Default root pose ``[pos, quat]`` in simulation world frame [m, -]. + Shape is (num_instances,), dtype = wp.transformf. + In torch this resolves to (num_instances, 7). + + Populated from :attr:`RigidObjectCfg.init_state` during initialisation. + """ + if self._default_root_pose_ta is None: + self._default_root_pose_ta = ProxyArray(self._default_root_pose) + return self._default_root_pose_ta + + @default_root_pose.setter + def default_root_pose(self, value: wp.array) -> None: + """Set the default root pose. + + Args: + value: The default root pose. Shape is (num_instances, 7). + + Raises: + ValueError: If the rigid object data is already primed. + """ + if self._is_primed: + raise ValueError("The rigid object data is already primed.") + self._default_root_pose.assign(value) + + @property + def default_root_vel(self) -> ProxyArray: + """Default root velocity ``[lin_vel, ang_vel]`` in simulation world frame [m/s, rad/s]. + Shape is (num_instances,), dtype = wp.spatial_vectorf. + In torch this resolves to (num_instances, 6). + + Populated from :attr:`RigidObjectCfg.init_state` during initialisation. + """ + if self._default_root_vel_ta is None: + self._default_root_vel_ta = ProxyArray(self._default_root_vel) + return self._default_root_vel_ta + + @default_root_vel.setter + def default_root_vel(self, value: wp.array) -> None: + """Set the default root velocity. + + Args: + value: The default root velocity. Shape is (num_instances, 6). + + Raises: + ValueError: If the rigid object data is already primed. + """ + if self._is_primed: + raise ValueError("The rigid object data is already primed.") + self._default_root_vel.assign(value) + + """ + Root state properties. + """ + + @property + def root_link_pose_w(self) -> ProxyArray: + """Root link pose ``[pos, quat]`` in simulation world frame [m, -]. + + Shape is (num_instances,), dtype = wp.transformf. In torch this resolves to (num_instances, 7). + This quantity is the pose of the actor frame of the root rigid body relative to the world. + The orientation is provided in (x, y, z, w) format. + """ + if self._root_link_pose_w.timestamp < self._sim_timestamp: + # read data from simulation + self._read_binding_into(TT.RIGID_BODY_POSE, self._root_link_pose_w.data) + self._root_link_pose_w.timestamp = self._sim_timestamp + if self._root_link_pose_w_ta is None: + self._root_link_pose_w_ta = ProxyArray(self._root_link_pose_w.data) + return self._root_link_pose_w_ta + + @property + def root_link_vel_w(self) -> ProxyArray: + """Root link velocity ``[lin_vel, ang_vel]`` in simulation world frame [m/s, rad/s]. + + Shape is (num_instances,), dtype = wp.spatial_vectorf. In torch this resolves to (num_instances, 6). + This quantity contains the linear and angular velocities of the actor frame of the root + rigid body relative to the world. + """ + if self._root_link_vel_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.get_root_link_vel_from_root_com_vel, + dim=self._num_instances, + inputs=[ + self.root_com_vel_w, + self.root_link_pose_w, + self.body_com_pose_b, + ], + outputs=[self._root_link_vel_w.data], + device=self.device, + ) + self._root_link_vel_w.timestamp = self._sim_timestamp + if self._root_link_vel_w_ta is None: + self._root_link_vel_w_ta = ProxyArray(self._root_link_vel_w.data) + return self._root_link_vel_w_ta + + @property + def root_com_pose_w(self) -> ProxyArray: + """Root center of mass pose ``[pos, quat]`` in simulation world frame [m, -]. + + Shape is (num_instances,), dtype = wp.transformf. In torch this resolves to (num_instances, 7). + This quantity is the pose of the center of mass frame of the root rigid body relative to the world. + The orientation is provided in (x, y, z, w) format. + """ + if self._root_com_pose_w.timestamp < self._sim_timestamp: + # apply local transform to center of mass frame + wp.launch( + shared_kernels.get_root_com_pose_from_root_link_pose, + dim=self._num_instances, + inputs=[ + self.root_link_pose_w, + self.body_com_pose_b, + ], + outputs=[ + self._root_com_pose_w.data, + ], + device=self.device, + ) + self._root_com_pose_w.timestamp = self._sim_timestamp + + if self._root_com_pose_w_ta is None: + self._root_com_pose_w_ta = ProxyArray(self._root_com_pose_w.data) + return self._root_com_pose_w_ta + + @property + def root_com_vel_w(self) -> ProxyArray: + """Root center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame [m/s, rad/s]. + + Shape is (num_instances,), dtype = wp.spatial_vectorf. In torch this resolves to (num_instances, 6). + This quantity contains the linear and angular velocities of the root rigid body's center of mass frame + relative to the world. + """ + if self._root_com_vel_w.timestamp < self._sim_timestamp: + self._read_binding_into(TT.RIGID_BODY_VELOCITY, self._root_com_vel_w.data) + self._root_com_vel_w.timestamp = self._sim_timestamp + if self._root_com_vel_w_ta is None: + self._root_com_vel_w_ta = ProxyArray(self._root_com_vel_w.data) + return self._root_com_vel_w_ta + + """ + Body state properties. + """ + + @property + def body_mass(self) -> ProxyArray: + """Mass of all bodies [kg]. + + Shape is (num_instances, 1), dtype = wp.float32. + In torch this resolves to (num_instances, 1). + """ + if self._body_mass_ta is None: + self._body_mass_ta = ProxyArray(self._body_mass) + return self._body_mass_ta + + @property + def body_inertia(self) -> ProxyArray: + """Inertia tensor of all bodies, expressed at the center of mass [kg·m²]. + + Shape is (num_instances, 1, 9), dtype = wp.float32. The 9 components are the row-major + flatten of the 3×3 inertia matrix ``(Ixx, Ixy, Ixz, Iyx, Iyy, Iyz, Izx, Izy, Izz)``. + In torch this resolves to (num_instances, 1, 9). + """ + if self._body_inertia_ta is None: + self._body_inertia_ta = ProxyArray(self._body_inertia) + return self._body_inertia_ta + + @property + def body_link_pose_w(self) -> ProxyArray: + """Body link pose ``[pos, quat]`` in simulation world frame [m, -]. + + Shape is (num_instances, 1), dtype = wp.transformf. In torch this resolves to (num_instances, 1, 7). + This quantity is the pose of the actor frame of the rigid body relative to the world. + The orientation is provided in (x, y, z, w) format. + """ + parent = self.root_link_pose_w + if self._body_link_pose_w_ta is None: + self._body_link_pose_w_ta = ProxyArray(parent.warp.reshape((self._num_instances, 1))) + return self._body_link_pose_w_ta + + @property + def body_link_vel_w(self) -> ProxyArray: + """Body link velocity ``[lin_vel, ang_vel]`` in simulation world frame [m/s, rad/s]. + + Shape is (num_instances, 1), dtype = wp.spatial_vectorf. In torch this resolves to (num_instances, 1, 6). + This quantity contains the linear and angular velocities of the body's link (actor) frame + relative to the world. + """ + parent = self.root_link_vel_w + if self._body_link_vel_w_ta is None: + self._body_link_vel_w_ta = ProxyArray(parent.warp.reshape((self._num_instances, 1))) + return self._body_link_vel_w_ta + + @property + def body_com_pose_w(self) -> ProxyArray: + """Body center of mass pose ``[pos, quat]`` in simulation world frame. + + Shape is (num_instances, 1), dtype = wp.transformf. In torch this resolves to (num_instances, 1, 7). + This quantity is the pose of the center of mass frame of the rigid body relative to the world. + The orientation is provided in (x, y, z, w) format. + """ + parent = self.root_com_pose_w + if self._body_com_pose_w_ta is None: + self._body_com_pose_w_ta = ProxyArray(parent.warp.reshape((self._num_instances, 1))) + return self._body_com_pose_w_ta + + @property + def body_com_vel_w(self) -> ProxyArray: + """Body center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame [m/s, rad/s]. + + Shape is (num_instances, 1), dtype = wp.spatial_vectorf. In torch this resolves to (num_instances, 1, 6). + This quantity contains the linear and angular velocities of the body's center of mass frame + relative to the world. + """ + parent = self.root_com_vel_w + if self._body_com_vel_w_ta is None: + self._body_com_vel_w_ta = ProxyArray(parent.warp.reshape((self._num_instances, 1))) + return self._body_com_vel_w_ta + + @property + def body_com_acc_w(self) -> ProxyArray: + """Acceleration of all bodies ``[lin_acc, ang_acc]`` in the simulation world frame [m/s², rad/s²]. + + Shape is (num_instances, 1), dtype = wp.spatial_vectorf. In torch this resolves to (num_instances, 1, 6). + This quantity is the acceleration of the rigid bodies' center of mass frame relative to the world. + """ + if self._body_com_acc_w.timestamp < self._sim_timestamp: + if self._previous_body_com_vel is None: + self._previous_body_com_vel = wp.clone(self.body_com_vel_w.warp) + wp.launch( + shared_kernels.derive_body_acceleration_from_body_com_velocities, + dim=(self._num_instances, 1), + device=self.device, + inputs=[ + self.body_com_vel_w.warp, + SimulationManager.get_physics_dt(), + self._previous_body_com_vel, + ], + outputs=[ + self._body_com_acc_w.data, + ], + ) + self._body_com_acc_w.timestamp = self._sim_timestamp + if self._body_com_acc_w_ta is None: + self._body_com_acc_w_ta = ProxyArray(self._body_com_acc_w.data) + return self._body_com_acc_w_ta + + @property + def body_com_pose_b(self) -> ProxyArray: + """Center of mass pose ``[pos, quat]`` of all bodies in their respective body's link frames. + + Shape is (num_instances, 1), dtype = wp.transformf. In torch this resolves to (num_instances, 1, 7). + This quantity is the pose of the center of mass frame of the rigid body relative to the body's link frame. + The orientation is provided in (x, y, z, w) format. + """ + if self._body_com_pose_b.timestamp < self._sim_timestamp: + # read data from simulation + self._read_binding_into(TT.RIGID_BODY_COM_POSE, self._body_com_pose_b.data) + self._body_com_pose_b.timestamp = self._sim_timestamp + + if self._body_com_pose_b_ta is None: + self._body_com_pose_b_ta = ProxyArray(self._body_com_pose_b.data) + return self._body_com_pose_b_ta + + """ + Derived Properties. + """ + + @property + def projected_gravity_b(self) -> ProxyArray: + """Projection of the gravity direction on base frame. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + """ + if self._projected_gravity_b.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.quat_apply_inverse_1D_kernel, + dim=self._num_instances, + inputs=[self.GRAVITY_VEC_W, self.root_link_quat_w], + outputs=[self._projected_gravity_b.data], + device=self.device, + ) + self._projected_gravity_b.timestamp = self._sim_timestamp + if self._projected_gravity_b_ta is None: + self._projected_gravity_b_ta = ProxyArray(self._projected_gravity_b.data) + return self._projected_gravity_b_ta + + @property + def heading_w(self) -> ProxyArray: + """Yaw heading of the base frame (in radians). + + Shape is (num_instances,), dtype = wp.float32. In torch this resolves to (num_instances,). + + .. note:: + This quantity is computed by assuming that the forward-direction of the base + frame is along x-direction, i.e. :math:`(1, 0, 0)`. + """ + if self._heading_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.root_heading_w, + dim=self._num_instances, + inputs=[self.FORWARD_VEC_B, self.root_link_quat_w], + outputs=[self._heading_w.data], + device=self.device, + ) + self._heading_w.timestamp = self._sim_timestamp + if self._heading_w_ta is None: + self._heading_w_ta = ProxyArray(self._heading_w.data) + return self._heading_w_ta + + @property + def root_link_lin_vel_b(self) -> ProxyArray: + """Root link linear velocity in base frame [m/s]. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + This quantity is the linear velocity of the root link frame relative to the world, + expressed in the root link's actor frame. + """ + if self._root_link_lin_vel_b.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.quat_apply_inverse_1D_kernel, + dim=self._num_instances, + inputs=[self.root_link_lin_vel_w, self.root_link_quat_w], + outputs=[self._root_link_lin_vel_b.data], + device=self.device, + ) + self._root_link_lin_vel_b.timestamp = self._sim_timestamp + if self._root_link_lin_vel_b_ta is None: + self._root_link_lin_vel_b_ta = ProxyArray(self._root_link_lin_vel_b.data) + return self._root_link_lin_vel_b_ta + + @property + def root_link_ang_vel_b(self) -> ProxyArray: + """Root link angular velocity in base frame [rad/s]. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + This quantity is the angular velocity of the root link frame relative to the world, + expressed in the root link's actor frame. + """ + if self._root_link_ang_vel_b.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.quat_apply_inverse_1D_kernel, + dim=self._num_instances, + inputs=[self.root_link_ang_vel_w, self.root_link_quat_w], + outputs=[self._root_link_ang_vel_b.data], + device=self.device, + ) + self._root_link_ang_vel_b.timestamp = self._sim_timestamp + if self._root_link_ang_vel_b_ta is None: + self._root_link_ang_vel_b_ta = ProxyArray(self._root_link_ang_vel_b.data) + return self._root_link_ang_vel_b_ta + + @property + def root_com_lin_vel_b(self) -> ProxyArray: + """Root center of mass linear velocity in base frame [m/s]. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + This quantity is the linear velocity of the root center of mass frame relative to the world, + expressed in the root link's actor frame. + """ + if self._root_com_lin_vel_b.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.quat_apply_inverse_1D_kernel, + dim=self._num_instances, + inputs=[self.root_com_lin_vel_w, self.root_link_quat_w], + outputs=[self._root_com_lin_vel_b.data], + device=self.device, + ) + self._root_com_lin_vel_b.timestamp = self._sim_timestamp + if self._root_com_lin_vel_b_ta is None: + self._root_com_lin_vel_b_ta = ProxyArray(self._root_com_lin_vel_b.data) + return self._root_com_lin_vel_b_ta + + @property + def root_com_ang_vel_b(self) -> ProxyArray: + """Root center of mass angular velocity in base frame [rad/s]. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + This quantity is the angular velocity of the root center of mass frame relative to the world, + expressed in the root link's actor frame. + """ + if self._root_com_ang_vel_b.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.quat_apply_inverse_1D_kernel, + dim=self._num_instances, + inputs=[self.root_com_ang_vel_w, self.root_link_quat_w], + outputs=[self._root_com_ang_vel_b.data], + device=self.device, + ) + self._root_com_ang_vel_b.timestamp = self._sim_timestamp + if self._root_com_ang_vel_b_ta is None: + self._root_com_ang_vel_b_ta = ProxyArray(self._root_com_ang_vel_b.data) + return self._root_com_ang_vel_b_ta + + """ + Sliced properties. + """ + + @property + def root_link_pos_w(self) -> ProxyArray: + """Root link position in simulation world frame. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + This quantity is the position of the actor frame of the root rigid body relative to the world. + """ + parent = self.root_link_pose_w + if self._root_link_pos_w_ta is None: + self._root_link_pos_w_ta = ProxyArray(self._get_pos_from_transform(parent.warp)) + return self._root_link_pos_w_ta + + @property + def root_link_quat_w(self) -> ProxyArray: + """Root link orientation (x, y, z, w) in simulation world frame. + + Shape is (num_instances,), dtype = wp.quatf. In torch this resolves to (num_instances, 4). + This quantity is the orientation of the actor frame of the root rigid body. + """ + parent = self.root_link_pose_w + if self._root_link_quat_w_ta is None: + self._root_link_quat_w_ta = ProxyArray(self._get_quat_from_transform(parent.warp)) + return self._root_link_quat_w_ta + + @property + def root_link_lin_vel_w(self) -> ProxyArray: + """Root linear velocity in simulation world frame. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + This quantity is the linear velocity of the root rigid body's actor frame relative to the world. + """ + parent = self.root_link_vel_w + if self._root_link_lin_vel_w_ta is None: + self._root_link_lin_vel_w_ta = ProxyArray(self._get_lin_vel_from_spatial_vector(parent.warp)) + return self._root_link_lin_vel_w_ta + + @property + def root_link_ang_vel_w(self) -> ProxyArray: + """Root link angular velocity in simulation world frame. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + This quantity is the angular velocity of the actor frame of the root rigid body relative to the world. + """ + parent = self.root_link_vel_w + if self._root_link_ang_vel_w_ta is None: + self._root_link_ang_vel_w_ta = ProxyArray(self._get_ang_vel_from_spatial_vector(parent.warp)) + return self._root_link_ang_vel_w_ta + + @property + def root_com_pos_w(self) -> ProxyArray: + """Root center of mass position in simulation world frame. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + This quantity is the position of the center of mass frame of the root rigid body relative to the world. + """ + parent = self.root_com_pose_w + if self._root_com_pos_w_ta is None: + self._root_com_pos_w_ta = ProxyArray(self._get_pos_from_transform(parent.warp)) + return self._root_com_pos_w_ta + + @property + def root_com_quat_w(self) -> ProxyArray: + """Root center of mass orientation (x, y, z, w) in simulation world frame. + + Shape is (num_instances,), dtype = wp.quatf. In torch this resolves to (num_instances, 4). + This quantity is the orientation of the principal axes of inertia of the root rigid body relative to the world. + """ + parent = self.root_com_pose_w + if self._root_com_quat_w_ta is None: + self._root_com_quat_w_ta = ProxyArray(self._get_quat_from_transform(parent.warp)) + return self._root_com_quat_w_ta + + @property + def root_com_lin_vel_w(self) -> ProxyArray: + """Root center of mass linear velocity in simulation world frame. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + This quantity is the linear velocity of the root rigid body's center of mass frame relative to the world. + """ + parent = self.root_com_vel_w + if self._root_com_lin_vel_w_ta is None: + self._root_com_lin_vel_w_ta = ProxyArray(self._get_lin_vel_from_spatial_vector(parent.warp)) + return self._root_com_lin_vel_w_ta + + @property + def root_com_ang_vel_w(self) -> ProxyArray: + """Root center of mass angular velocity in simulation world frame. + + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + This quantity is the angular velocity of the root rigid body's center of mass frame relative to the world. + """ + parent = self.root_com_vel_w + if self._root_com_ang_vel_w_ta is None: + self._root_com_ang_vel_w_ta = ProxyArray(self._get_ang_vel_from_spatial_vector(parent.warp)) + return self._root_com_ang_vel_w_ta + + @property + def body_link_pos_w(self) -> ProxyArray: + """Positions of all bodies in simulation world frame. + + Shape is (num_instances, 1), dtype = wp.vec3f. In torch this resolves to (num_instances, 1, 3). + This quantity is the position of the rigid bodies' actor frame relative to the world. + """ + parent = self.body_link_pose_w + if self._body_link_pos_w_ta is None: + self._body_link_pos_w_ta = ProxyArray(self._get_pos_from_transform(parent.warp)) + return self._body_link_pos_w_ta + + @property + def body_link_quat_w(self) -> ProxyArray: + """Orientation (x, y, z, w) of all bodies in simulation world frame. + + Shape is (num_instances, 1), dtype = wp.quatf. In torch this resolves to (num_instances, 1, 4). + This quantity is the orientation of the rigid bodies' actor frame relative to the world. + """ + parent = self.body_link_pose_w + if self._body_link_quat_w_ta is None: + self._body_link_quat_w_ta = ProxyArray(self._get_quat_from_transform(parent.warp)) + return self._body_link_quat_w_ta + + @property + def body_link_lin_vel_w(self) -> ProxyArray: + """Linear velocity of all bodies in simulation world frame. + + Shape is (num_instances, 1), dtype = wp.vec3f. In torch this resolves to (num_instances, 1, 3). + This quantity is the linear velocity of the rigid bodies' actor frame relative to the world. + """ + parent = self.body_link_vel_w + if self._body_link_lin_vel_w_ta is None: + self._body_link_lin_vel_w_ta = ProxyArray(self._get_lin_vel_from_spatial_vector(parent.warp)) + return self._body_link_lin_vel_w_ta + + @property + def body_link_ang_vel_w(self) -> ProxyArray: + """Angular velocity of all bodies in simulation world frame. + + Shape is (num_instances, 1), dtype = wp.vec3f. In torch this resolves to (num_instances, 1, 3). + This quantity is the angular velocity of the rigid bodies' actor frame relative to the world. + """ + parent = self.body_link_vel_w + if self._body_link_ang_vel_w_ta is None: + self._body_link_ang_vel_w_ta = ProxyArray(self._get_ang_vel_from_spatial_vector(parent.warp)) + return self._body_link_ang_vel_w_ta + + @property + def body_com_pos_w(self) -> ProxyArray: + """Positions of all bodies' center of mass in simulation world frame. + + Shape is (num_instances, 1), dtype = wp.vec3f. In torch this resolves to (num_instances, 1, 3). + This quantity is the position of the rigid bodies' center of mass frame. + """ + parent = self.body_com_pose_w + if self._body_com_pos_w_ta is None: + self._body_com_pos_w_ta = ProxyArray(self._get_pos_from_transform(parent.warp)) + return self._body_com_pos_w_ta + + @property + def body_com_quat_w(self) -> ProxyArray: + """Orientation (x, y, z, w) of the principal axes of inertia of all bodies in simulation world frame. + + Shape is (num_instances, 1), dtype = wp.quatf. In torch this resolves to (num_instances, 1, 4). + This quantity is the orientation of the principal axes of inertia of the rigid bodies. + """ + parent = self.body_com_pose_w + if self._body_com_quat_w_ta is None: + self._body_com_quat_w_ta = ProxyArray(self._get_quat_from_transform(parent.warp)) + return self._body_com_quat_w_ta + + @property + def body_com_lin_vel_w(self) -> ProxyArray: + """Linear velocity of all bodies in simulation world frame. + + Shape is (num_instances, 1), dtype = wp.vec3f. In torch this resolves to (num_instances, 1, 3). + This quantity is the linear velocity of the rigid bodies' center of mass frame. + """ + parent = self.body_com_vel_w + if self._body_com_lin_vel_w_ta is None: + self._body_com_lin_vel_w_ta = ProxyArray(self._get_lin_vel_from_spatial_vector(parent.warp)) + return self._body_com_lin_vel_w_ta + + @property + def body_com_ang_vel_w(self) -> ProxyArray: + """Angular velocity of all bodies in simulation world frame. + + Shape is (num_instances, 1), dtype = wp.vec3f. In torch this resolves to (num_instances, 1, 3). + This quantity is the angular velocity of the rigid bodies' center of mass frame. + """ + parent = self.body_com_vel_w + if self._body_com_ang_vel_w_ta is None: + self._body_com_ang_vel_w_ta = ProxyArray(self._get_ang_vel_from_spatial_vector(parent.warp)) + return self._body_com_ang_vel_w_ta + + @property + def body_com_lin_acc_w(self) -> ProxyArray: + """Linear acceleration of all bodies in simulation world frame. + + Shape is (num_instances, 1), dtype = wp.vec3f. In torch this resolves to (num_instances, 1, 3). + This quantity is the linear acceleration of the rigid bodies' center of mass frame. + """ + parent = self.body_com_acc_w + if self._body_com_lin_acc_w_ta is None: + self._body_com_lin_acc_w_ta = ProxyArray(self._get_lin_vel_from_spatial_vector(parent.warp)) + return self._body_com_lin_acc_w_ta + + @property + def body_com_ang_acc_w(self) -> ProxyArray: + """Angular acceleration of all bodies in simulation world frame. + + Shape is (num_instances, 1), dtype = wp.vec3f. In torch this resolves to (num_instances, 1, 3). + This quantity is the angular acceleration of the rigid bodies' center of mass frame. + """ + parent = self.body_com_acc_w + if self._body_com_ang_acc_w_ta is None: + self._body_com_ang_acc_w_ta = ProxyArray(self._get_ang_vel_from_spatial_vector(parent.warp)) + return self._body_com_ang_acc_w_ta + + @property + def body_com_pos_b(self) -> ProxyArray: + """Center of mass position of all of the bodies in their respective link frames. + + Shape is (num_instances, 1), dtype = wp.vec3f. In torch this resolves to (num_instances, 1, 3). + This quantity is the center of mass location relative to its body's link frame. + """ + parent = self.body_com_pose_b + if self._body_com_pos_b_ta is None: + self._body_com_pos_b_ta = ProxyArray(self._get_pos_from_transform(parent.warp)) + return self._body_com_pos_b_ta + + @property + def body_com_quat_b(self) -> ProxyArray: + """Orientation (x, y, z, w) of the principal axes of inertia of all of the bodies in their + respective link frames. + + Shape is (num_instances, 1), dtype = wp.quatf. In torch this resolves to (num_instances, 1, 4). + This quantity is the orientation of the principal axes of inertia relative to its body's link frame. + """ + parent = self.body_com_pose_b + if self._body_com_quat_b_ta is None: + self._body_com_quat_b_ta = ProxyArray(self._get_quat_from_transform(parent.warp)) + return self._body_com_quat_b_ta + + def _create_buffers(self) -> None: + super()._create_buffers() + # Initialize the lazy buffers. + # -- link frame w.r.t. world frame + self._root_link_pose_w = TimestampedBuffer((self._num_instances), self.device, wp.transformf) + self._root_link_vel_w = TimestampedBuffer((self._num_instances), self.device, wp.spatial_vectorf) + # -- com frame w.r.t. link frame + self._body_com_pose_b = TimestampedBuffer((self._num_instances, 1), self.device, wp.transformf) + # -- com frame w.r.t. world frame + self._root_com_pose_w = TimestampedBuffer((self._num_instances), self.device, wp.transformf) + self._root_com_vel_w = TimestampedBuffer((self._num_instances), self.device, wp.spatial_vectorf) + self._body_com_acc_w = TimestampedBuffer((self._num_instances, 1), self.device, wp.spatial_vectorf) + # -- combined state (these are cached as they concatenate) + self._root_state_w = TimestampedBuffer((self._num_instances), self.device, shared_kernels.vec13f) + self._root_link_state_w = TimestampedBuffer((self._num_instances), self.device, shared_kernels.vec13f) + self._root_com_state_w = TimestampedBuffer((self._num_instances), self.device, shared_kernels.vec13f) + # -- derived properties (these are cached to avoid repeated memory allocations) + self._projected_gravity_b = TimestampedBuffer((self._num_instances), self.device, wp.vec3f) + self._heading_w = TimestampedBuffer((self._num_instances), self.device, wp.float32) + self._root_link_lin_vel_b = TimestampedBuffer((self._num_instances), self.device, wp.vec3f) + self._root_link_ang_vel_b = TimestampedBuffer((self._num_instances), self.device, wp.vec3f) + self._root_com_lin_vel_b = TimestampedBuffer((self._num_instances), self.device, wp.vec3f) + self._root_com_ang_vel_b = TimestampedBuffer((self._num_instances), self.device, wp.vec3f) + + # -- Default state + self._default_root_pose = wp.zeros((self._num_instances), dtype=wp.transformf, device=self.device) + self._default_root_vel = wp.zeros((self._num_instances), dtype=wp.spatial_vectorf, device=self.device) + self._default_root_state = None + + # -- Previous body com velocity + self._previous_body_com_vel = None + + # -- Pinned-host staging buffers for CPU-only bindings on a non-CPU sim + # (lazily allocated, keyed by tensor type). + self._cpu_staging_buffers: dict[int, wp.array] = {} + + # -- Body properties (semi-static; read once from CPU-only bindings). + # The wheel exposes ``RIGID_BODY_MASS`` as ``(N,)`` and ``RIGID_BODY_INERTIA`` as ``(N, 9)``; + # the ``BaseRigidObjectData`` contract is ``(N, 1)`` and ``(N, 1, 9)`` respectively, so we + # read into a flat buffer and reshape (zero-copy) after the read. + mass_binding = self._bindings[TT.RIGID_BODY_MASS] + inertia_binding = self._bindings[TT.RIGID_BODY_INERTIA] + self._body_mass = wp.zeros(mass_binding.shape, dtype=wp.float32, device=self.device) + self._body_inertia = wp.zeros(inertia_binding.shape, dtype=wp.float32, device=self.device) + self._read_binding_into(TT.RIGID_BODY_MASS, self._body_mass) + self._read_binding_into(TT.RIGID_BODY_INERTIA, self._body_inertia) + self._body_mass = self._body_mass.reshape((self._num_instances, 1)) + self._body_inertia = self._body_inertia.reshape((self._num_instances, 1, 9)) + + # Initialize ProxyArray wrappers + self._pin_proxy_arrays() + + def _pin_proxy_arrays(self) -> None: + """Create pinned ProxyArray wrappers for all data buffers. + + This is called once from :meth:`_create_buffers` during initialization. + PhysX tensor API buffers have stable GPU pointers across simulation steps, + so no rebinding is needed (unlike Newton). + """ + # -- Pinned ProxyArray cache (one per read property, lazily created on first access) + # Defaults + self._default_root_pose_ta: ProxyArray | None = None + self._default_root_vel_ta: ProxyArray | None = None + # Root state (timestamped) + self._root_link_pose_w_ta: ProxyArray | None = None + self._root_link_vel_w_ta: ProxyArray | None = None + self._root_com_pose_w_ta: ProxyArray | None = None + self._root_com_vel_w_ta: ProxyArray | None = None + # Body properties + self._body_mass_ta: ProxyArray | None = None + self._body_inertia_ta: ProxyArray | None = None + # Body state (reshaped from root) + self._body_link_pose_w_ta: ProxyArray | None = None + self._body_link_vel_w_ta: ProxyArray | None = None + self._body_com_pose_w_ta: ProxyArray | None = None + self._body_com_vel_w_ta: ProxyArray | None = None + self._body_com_acc_w_ta: ProxyArray | None = None + self._body_com_pose_b_ta: ProxyArray | None = None + # Derived properties (timestamped) + self._projected_gravity_b_ta: ProxyArray | None = None + self._heading_w_ta: ProxyArray | None = None + self._root_link_lin_vel_b_ta: ProxyArray | None = None + self._root_link_ang_vel_b_ta: ProxyArray | None = None + self._root_com_lin_vel_b_ta: ProxyArray | None = None + self._root_com_ang_vel_b_ta: ProxyArray | None = None + # Sliced properties (root link) + self._root_link_pos_w_ta: ProxyArray | None = None + self._root_link_quat_w_ta: ProxyArray | None = None + self._root_link_lin_vel_w_ta: ProxyArray | None = None + self._root_link_ang_vel_w_ta: ProxyArray | None = None + # Sliced properties (root com) + self._root_com_pos_w_ta: ProxyArray | None = None + self._root_com_quat_w_ta: ProxyArray | None = None + self._root_com_lin_vel_w_ta: ProxyArray | None = None + self._root_com_ang_vel_w_ta: ProxyArray | None = None + # Sliced properties (body link) + self._body_link_pos_w_ta: ProxyArray | None = None + self._body_link_quat_w_ta: ProxyArray | None = None + self._body_link_lin_vel_w_ta: ProxyArray | None = None + self._body_link_ang_vel_w_ta: ProxyArray | None = None + # Sliced properties (body com) + self._body_com_pos_w_ta: ProxyArray | None = None + self._body_com_quat_w_ta: ProxyArray | None = None + self._body_com_lin_vel_w_ta: ProxyArray | None = None + self._body_com_ang_vel_w_ta: ProxyArray | None = None + self._body_com_lin_acc_w_ta: ProxyArray | None = None + self._body_com_ang_acc_w_ta: ProxyArray | None = None + # Sliced properties (body com in body frame) + self._body_com_pos_b_ta: ProxyArray | None = None + self._body_com_quat_b_ta: ProxyArray | None = None + # Deprecated state-concat properties + self._default_root_state_ta: ProxyArray | None = None + self._root_state_w_ta: ProxyArray | None = None + self._root_link_state_w_ta: ProxyArray | None = None + self._root_com_state_w_ta: ProxyArray | None = None + self._body_state_w_ta: ProxyArray | None = None + self._body_link_state_w_ta: ProxyArray | None = None + self._body_com_state_w_ta: ProxyArray | None = None + + """ + Internal helpers. + """ + + def _get_binding(self, tensor_type: int): + """Return the binding for the given tensor type, or None.""" + return self._bindings.get(tensor_type) + + def _read_binding_into(self, tensor_type: int, dst: wp.array) -> None: + """Read the OVPhysX TensorBinding for *tensor_type* into *dst*. + + Adapter that replaces PhysX's view-getter pattern: the wheel exposes + ``binding.read(target)`` rather than a getter returning a wp.array, so + we read into a flat float32 view of *dst*. CPU-only bindings on a + non-CPU sim go through a lazily-allocated pinned-host wp.array to + satisfy the wheel's device match. + """ + binding = self._bindings[tensor_type] + if self._check_shapes: + dst_bytes = dst.size * wp.types.type_size_in_bytes(dst.dtype) + binding_bytes = 4 * math.prod(binding.shape) + assert dst_bytes >= binding_bytes, ( + f"_read_binding_into: dst buffer too small for binding {tensor_type!r} " + f"({dst_bytes} B < {binding_bytes} B). Caller allocated dst with " + f"shape={tuple(dst.shape)}, dtype={dst.dtype}; binding shape={tuple(binding.shape)}." + ) + # Build a flat float32 view of dst matching the binding's shape. + if dst.dtype == wp.float32: + view = dst + else: + view = wp.array( + ptr=dst.ptr, + shape=binding.shape, + dtype=wp.float32, + device=str(dst.device), + copy=False, + ) + if tensor_type in TT._CPU_ONLY_TYPES and str(view.device) != "cpu": + staging = self._cpu_staging_buffers.get(tensor_type) + if staging is None: + staging = wp.zeros(binding.shape, dtype=wp.float32, device="cpu", pinned=True) + self._cpu_staging_buffers[tensor_type] = staging + binding.read(staging) + wp.copy(view, staging) + else: + binding.read(view) + + def _get_pos_from_transform(self, transform: wp.array) -> wp.array: + """Generates a position array from a transform array.""" + return wp.array( + ptr=transform.ptr, + shape=transform.shape, + dtype=wp.vec3f, + strides=transform.strides, + device=self.device, + ) + + def _get_quat_from_transform(self, transform: wp.array) -> wp.array: + """Generates a quaternion array from a transform array.""" + return wp.array( + ptr=transform.ptr + 3 * 4, + shape=transform.shape, + dtype=wp.quatf, + strides=transform.strides, + device=self.device, + ) + + def _get_lin_vel_from_spatial_vector(self, sv: wp.array) -> wp.array: + """Generates a linear velocity array from a spatial vector array.""" + return wp.array( + ptr=sv.ptr, + shape=sv.shape, + dtype=wp.vec3f, + strides=sv.strides, + device=self.device, + ) + + def _get_ang_vel_from_spatial_vector(self, sv: wp.array) -> wp.array: + """Generates an angular velocity array from a spatial vector array.""" + return wp.array( + ptr=sv.ptr + 3 * 4, + shape=sv.shape, + dtype=wp.vec3f, + strides=sv.strides, + device=self.device, + ) + + """ + Deprecated properties. + """ + + @property + def default_root_state(self) -> ProxyArray: + """Default root state ``[pos, quat, lin_vel, ang_vel]`` in local environment frame. + + The position and quaternion are of the rigid body's actor frame. Meanwhile, the linear and angular velocities + are of the center of mass frame. Shape is (num_instances, 13). + """ + warnings.warn( + "Reading the root state directly is deprecated since IsaacLab 3.0 and will be removed in a future version. " + "Please use the default_root_pose and default_root_vel properties instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._default_root_state is None: + self._default_root_state = wp.zeros((self._num_instances), dtype=shared_kernels.vec13f, device=self.device) + wp.launch( + shared_kernels.concat_root_pose_and_vel_to_state, + dim=self._num_instances, + inputs=[ + self._default_root_pose, + self._default_root_vel, + ], + outputs=[ + self._default_root_state, + ], + device=self.device, + ) + if self._default_root_state_ta is None: + self._default_root_state_ta = ProxyArray(self._default_root_state) + return self._default_root_state_ta + + @property + def root_state_w(self) -> ProxyArray: + """Deprecated, same as :attr:`root_link_pose_w` and :attr:`root_com_vel_w`.""" + warnings.warn( + "The `root_state_w` property will be deprecated in IsaacLab 4.0. Please use `root_link_pose_w` and " + "`root_com_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._root_state_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.concat_root_pose_and_vel_to_state, + dim=self._num_instances, + inputs=[ + self.root_link_pose_w, + self.root_com_vel_w, + ], + outputs=[ + self._root_state_w.data, + ], + device=self.device, + ) + self._root_state_w.timestamp = self._sim_timestamp + + if self._root_state_w_ta is None: + self._root_state_w_ta = ProxyArray(self._root_state_w.data) + return self._root_state_w_ta + + @property + def root_link_state_w(self) -> ProxyArray: + """Deprecated, same as :attr:`root_link_pose_w` and :attr:`root_link_vel_w`.""" + warnings.warn( + "The `root_link_state_w` property will be deprecated in IsaacLab 4.0. Please use `root_link_pose_w` and " + "`root_link_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._root_link_state_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.concat_root_pose_and_vel_to_state, + dim=self._num_instances, + inputs=[ + self.root_link_pose_w, + self.root_link_vel_w, + ], + outputs=[ + self._root_link_state_w.data, + ], + device=self.device, + ) + self._root_link_state_w.timestamp = self._sim_timestamp + + if self._root_link_state_w_ta is None: + self._root_link_state_w_ta = ProxyArray(self._root_link_state_w.data) + return self._root_link_state_w_ta + + @property + def root_com_state_w(self) -> ProxyArray: + """Deprecated, same as :attr:`root_com_pose_w` and :attr:`root_com_vel_w`.""" + warnings.warn( + "The `root_com_state_w` property will be deprecated in IsaacLab 4.0. Please use `root_com_pose_w` and " + "`root_com_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._root_com_state_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.concat_root_pose_and_vel_to_state, + dim=self._num_instances, + inputs=[ + self.root_com_pose_w, + self.root_com_vel_w, + ], + outputs=[ + self._root_com_state_w.data, + ], + device=self.device, + ) + self._root_com_state_w.timestamp = self._sim_timestamp + + if self._root_com_state_w_ta is None: + self._root_com_state_w_ta = ProxyArray(self._root_com_state_w.data) + return self._root_com_state_w_ta + + @property + def body_state_w(self) -> ProxyArray: + """Deprecated, same as :attr:`body_link_pose_w` and :attr:`body_com_vel_w`.""" + warnings.warn( + "The `body_state_w` property will be deprecated in IsaacLab 4.0. Please use `body_link_pose_w` and " + "`body_com_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + # Access internal buffer directly to avoid cascading deprecation warnings from root_state_w + if self._root_state_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.concat_root_pose_and_vel_to_state, + dim=self._num_instances, + inputs=[ + self.root_link_pose_w, + self.root_com_vel_w, + ], + outputs=[ + self._root_state_w.data, + ], + device=self.device, + ) + self._root_state_w.timestamp = self._sim_timestamp + if self._body_state_w_ta is None: + self._body_state_w_ta = ProxyArray(self._root_state_w.data.reshape((self._num_instances, 1))) + return self._body_state_w_ta + + @property + def body_link_state_w(self) -> ProxyArray: + """Deprecated, same as :attr:`body_link_pose_w` and :attr:`body_link_vel_w`.""" + warnings.warn( + "The `body_link_state_w` property will be deprecated in IsaacLab 4.0. Please use `body_link_pose_w` and " + "`body_link_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + # Access internal buffer directly to avoid cascading deprecation warnings from root_link_state_w + if self._root_link_state_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.concat_root_pose_and_vel_to_state, + dim=self._num_instances, + inputs=[ + self.root_link_pose_w, + self.root_link_vel_w, + ], + outputs=[ + self._root_link_state_w.data, + ], + device=self.device, + ) + self._root_link_state_w.timestamp = self._sim_timestamp + if self._body_link_state_w_ta is None: + self._body_link_state_w_ta = ProxyArray(self._root_link_state_w.data.reshape((self._num_instances, 1))) + return self._body_link_state_w_ta + + @property + def body_com_state_w(self) -> ProxyArray: + """Deprecated, same as :attr:`body_com_pose_w` and :attr:`body_com_vel_w`.""" + warnings.warn( + "The `body_com_state_w` property will be deprecated in IsaacLab 4.0. Please use `body_com_pose_w` and " + "`body_com_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + # Access internal buffer directly to avoid cascading deprecation warnings from root_com_state_w + if self._root_com_state_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.concat_root_pose_and_vel_to_state, + dim=self._num_instances, + inputs=[ + self.root_com_pose_w, + self.root_com_vel_w, + ], + outputs=[ + self._root_com_state_w.data, + ], + device=self.device, + ) + self._root_com_state_w.timestamp = self._sim_timestamp + if self._body_com_state_w_ta is None: + self._body_com_state_w_ta = ProxyArray(self._root_com_state_w.data.reshape((self._num_instances, 1))) + return self._body_com_state_w_ta diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 6caad37ab7bf..9be415fed577 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -47,6 +47,12 @@ class OvPhysxManager(PhysicsManager): _stage_path: ClassVar[str | None] = None _warmup_done: ClassVar[bool] = False _tmp_dir: ClassVar[tempfile.TemporaryDirectory | None] = None + # Device the process is locked to once :meth:`_warmup_and_load` constructs the + # ``ovphysx.PhysX`` instance for the first time. ``ovphysx<=0.3.7`` enforces + # a process-global device-mode lock at the C++ layer (see HACK note on + # :meth:`_release_physx`); we mirror it here so a clear Python error is raised + # if a later :class:`~isaaclab.sim.SimulationContext` requests a different device. + _locked_device: ClassVar[str | None] = None # Pending (source, targets, parent_positions) triples registered by # ovphysx_replicate() before the PhysX instance exists. Replayed via # physx.clone() in _warmup_and_load(). @@ -54,6 +60,11 @@ class OvPhysxManager(PhysicsManager): _pending_clones: ClassVar[list[tuple[str, list[str], list[tuple[float, float, float]]]]] = [] _atexit_registered: ClassVar[bool] = False + @classmethod + def get_dt(cls) -> float: + """Get the physics timestep. Alias for get_physics_dt().""" + return cls.get_physics_dt() + @classmethod def register_clone( cls, source: str, targets: list[str], parent_positions: list[tuple[float, float, float]] | None = None @@ -80,13 +91,20 @@ def register_clone( def initialize(cls, sim_context: SimulationContext) -> None: """Initialize the physics manager with simulation context. - This stores the config and device but does not create the ovphysx - instance yet -- the USD stage may not be fully populated at this point. - The actual creation happens lazily in :meth:`reset`. + This stores the config and device but does not load the USD stage yet -- + the stage may not be fully populated at this point. The actual load + happens lazily in :meth:`reset`. + + ``cls._physx`` is intentionally not cleared here: the ovphysx C++ instance + is process-global (see HACK on :meth:`_release_physx`). When a previous + :class:`SimulationContext` has already constructed it, we reuse it rather + than dropping the only Python reference (which would trigger the + destructor race) or re-constructing (which would hit the wheel's + device-mode lock). ``cls._locked_device`` carries the device the cached + instance is bound to. """ super().initialize(sim_context) cls._warmup_done = False - cls._physx = None cls._usd_handle = None cls._stage_path = None cls._pending_clones = [] @@ -139,15 +157,27 @@ def close(cls) -> None: @classmethod def _release_physx(cls) -> None: - """Release the ovphysx instance if it exists. Safe to call multiple times. - - With ovphysx<=0.3.7 and Kit's pxr in the same process, physx.release() - deadlocks due to dual-Carbonite static destructor races. Skip the - native release and let os._exit() (registered via atexit) terminate the - process; GPU resources are reclaimed by the driver. + """Soft-reset the ovphysx runtime stage; keep the C++ instance alive. + + Calls ``physx.reset()`` to clear the loaded scene, but does **not** drop + the Python reference. The cached :class:`ovphysx.PhysX` is reused by the + next :class:`~isaaclab.sim.SimulationContext` via the reuse path in + :meth:`_warmup_and_load`. Safe to call multiple times. + + HACK(ovphysx<=0.3.7): the wheel's bundled libcarb.so and Kit's libcarb.so + coexist in the same process whenever ``import pxr`` runs (Kit USD plugins + on ``LD_LIBRARY_PATH`` pull in Kit's Carbonite). Both register C++ static + destructors that race at process exit -- and crucially, also race when + ``ovphysx.PhysX``'s Python destructor fires mid-process via refcount drop. + So we must never let the only Python reference go to zero while the + process is alive. ``os._exit(0)`` (registered via ``atexit`` in + :meth:`_warmup_and_load`) sidesteps the static-destructor phase entirely + at process exit. Remove this workaround once the wheel ships a + namespace-isolated Carbonite (different soname / hidden visibility). """ if cls._physx is not None: - cls._physx = None + op = cls._physx.reset() + cls._physx.wait_op(op) @classmethod def get_physx_instance(cls) -> Any: @@ -160,7 +190,22 @@ def get_physx_instance(cls) -> Any: @classmethod def _warmup_and_load(cls) -> None: - """Export the USD stage, create the ovphysx instance, and load the scene.""" + """Export the USD stage and load it into the ovphysx runtime. + + On the first call per process, constructs the :class:`ovphysx.PhysX` + instance, registers the ``atexit`` handler, and locks the process to + the resolved device. On subsequent calls, reuses the cached instance + (see HACK on :meth:`_release_physx`) -- exporting the new USD, + re-attaching it via ``add_usd``, replaying pending clones, and (on GPU) + re-running ``warmup_gpu`` so the new stage's bodies are resident. + + Raises: + RuntimeError: if ``SimulationContext`` is not set, or if a device + different from the process-locked one is requested. The wheel + enforces a process-global device-mode lock at the C++ layer; + we surface it here as a clear Python error before the wheel + would raise :exc:`ovphysx.types.PhysXDeviceError`. + """ sim = PhysicsManager._sim if sim is None: raise RuntimeError("OvPhysxManager: SimulationContext is not set.") @@ -174,9 +219,16 @@ def _warmup_and_load(cls) -> None: gpu_index = 0 ovphysx_device = "cpu" + if cls._locked_device is not None and ovphysx_device != cls._locked_device: + raise RuntimeError( + f"OvPhysxManager is locked to device {cls._locked_device!r} for the lifetime of this process; " + f"cannot switch to {ovphysx_device!r}. ovphysx<=0.3.7 binds device mode at the C++ layer on the " + "first ovphysx.PhysX(...) construction and it cannot be changed without restarting the process." + ) + scene_prim = sim.stage.GetPrimAtPath(sim.cfg.physics_prim_path) - if scene_prim.IsValid() and ovphysx_device == "gpu": - cls._configure_physx_scene_prim(scene_prim, PhysicsManager._cfg) + if scene_prim.IsValid(): + cls._configure_physx_scene_prim(scene_prim, PhysicsManager._cfg, ovphysx_device) # Export the current USD stage to a temporary file so ovphysx can load it. cls._tmp_dir = tempfile.TemporaryDirectory(prefix="isaaclab_ovphysx_") @@ -185,6 +237,66 @@ def _warmup_and_load(cls) -> None: cls._stage_path = stage_file logger.info("OvPhysxManager: exported USD stage to %s", stage_file) + if cls._physx is None: + cls._construct_physx(ovphysx_device, gpu_index) + cls._locked_device = ovphysx_device + else: + # Reuse path: the cached PhysX may still hold the prior stage (the + # wheel allows only one loaded USD at a time). ``physx.reset()`` is + # idempotent on an already-cleared stage and required when this is + # a second :meth:`_warmup_and_load` within the same SimulationContext + # (e.g. when a caller manually clears ``_warmup_done`` to force a + # re-warmup). + op = cls._physx.reset() + cls._physx.wait_op(op) + + usd_handle, op_idx = cls._physx.add_usd(stage_file) + cls._physx.wait_op(op_idx) + cls._usd_handle = usd_handle + logger.info("OvPhysxManager: loaded USD into ovphysx (device=%s)", ovphysx_device) + + # Replay pending physics clones registered by ovphysx_replicate(). + # The USD stage contains only env_0's physics; env_1..N are empty + # Xform containers. physx.clone() creates the remaining environments + # in the physics runtime without modifying the USD file. + if cls._pending_clones: + # ovphysx_replicate() only registers pending clones when clone_usd=False, + # meaning the USD contains only env_0 physics and physx.clone() is required + # to populate env_1..N in the physics runtime. Execute unconditionally — + # no USD content heuristic is needed. + for source, targets, parent_positions in cls._pending_clones: + logger.info( + "OvPhysxManager: cloning %s -> %d targets (%s ... %s)", + source, + len(targets), + targets[0], + targets[-1], + ) + if parent_positions: + transforms = [(x, y, z, 0.0, 0.0, 0.0, 1.0) for x, y, z in parent_positions] + else: + transforms = None + op_idx = cls._physx.clone(source, targets, transforms) + cls._physx.wait_op(op_idx) + cls._pending_clones = [] + + # GPU bodies must be re-warmed after every add_usd: the cached PhysX + # instance carries its old buffer layout from the previous stage. + if ovphysx_device == "gpu": + cls._physx.warmup_gpu() + + cls.dispatch_event(PhysicsEvent.MODEL_INIT, payload={}) + cls._warmup_done = True + + @classmethod + def _construct_physx(cls, ovphysx_device: str, gpu_index: int) -> None: + """Bootstrap the ``ovphysx`` wheel and create the :class:`ovphysx.PhysX` instance. + + Runs once per process. Configures worker threads, registers the + process-exit ``os._exit(0)`` handler, and stores the result on + ``cls._physx``. See HACK on :meth:`_release_physx` for why the + instance must outlive every individual :class:`SimulationContext`. + """ # HACK (temporary): hide pxr from sys.modules during ovphysx bootstrap. # IsaacSim's pxr reports version 0.25.5 (pip convention) while ovphysx # expects 25.11 (OpenUSD release convention). Hiding pxr causes @@ -270,52 +382,25 @@ def _atexit_release_and_exit(): atexit.register(_atexit_release_and_exit) cls._atexit_registered = True - usd_handle, op_idx = cls._physx.add_usd(stage_file) - cls._physx.wait_op(op_idx) - cls._usd_handle = usd_handle - logger.info("OvPhysxManager: loaded USD into ovphysx (device=%s)", ovphysx_device) - - # Replay pending physics clones registered by ovphysx_replicate(). - # The USD stage contains only env_0's physics; env_1..N are empty - # Xform containers. physx.clone() creates the remaining environments - # in the physics runtime without modifying the USD file. - if cls._pending_clones: - # ovphysx_replicate() only registers pending clones when clone_usd=False, - # meaning the USD contains only env_0 physics and physx.clone() is required - # to populate env_1..N in the physics runtime. Execute unconditionally — - # no USD content heuristic is needed. - for source, targets, parent_positions in cls._pending_clones: - logger.info( - "OvPhysxManager: cloning %s -> %d targets (%s ... %s)", - source, - len(targets), - targets[0], - targets[-1], - ) - if parent_positions: - transforms = [(x, y, z, 0.0, 0.0, 0.0, 1.0) for x, y, z in parent_positions] - else: - transforms = None - op_idx = cls._physx.clone(source, targets, transforms) - cls._physx.wait_op(op_idx) - cls._pending_clones = [] - - if ovphysx_device == "gpu": - cls._physx.warmup_gpu() - - cls.dispatch_event(PhysicsEvent.MODEL_INIT, payload={}) - cls._warmup_done = True - @staticmethod - def _configure_physx_scene_prim(scene_prim, cfg) -> None: - """Apply PhysxSceneAPI schema and GPU dynamics attributes to a scene prim. + def _configure_physx_scene_prim(scene_prim, cfg, device: str) -> None: + """Apply PhysxSceneAPI schema and device-specific scene attributes to the + scene prim. The PhysxSchema USD plugin may not be loaded in standalone ovphysx mode, so we write the apiSchemas list entry and scene attributes directly via raw Sdf metadata manipulation instead of using the high-level USD API. - Without these attributes PhysX defaults to CPU broadphase even when - ovphysx is created with device="gpu". + The schema and scene-query-support attribute are applied regardless of + device. The GPU-specific dynamics/broadphase/capacity attributes are + applied only when ``device == "gpu"`` — without them PhysX defaults to + CPU broadphase even when ovphysx is created with ``device="gpu"``. + + Args: + scene_prim: The /World/PhysicsScene prim to configure. + cfg: The :class:`OvPhysxCfg` carrying GPU buffer-capacity values. + Only consulted when ``device == "gpu"``. + device: Resolved physics device — one of ``"cpu"`` or ``"gpu"``. """ from pxr import Sdf @@ -326,22 +411,24 @@ def _configure_physx_scene_prim(scene_prim, cfg) -> None: items.append("PhysxSceneAPI") schemas.prependedItems = items scene_prim.SetMetadata("apiSchemas", schemas) - scene_prim.CreateAttribute("physxScene:enableGPUDynamics", Sdf.ValueTypeNames.Bool).Set(True) - scene_prim.CreateAttribute("physxScene:broadphaseType", Sdf.ValueTypeNames.String).Set("GPU") - - if cfg is not None: - for attr, val in [ - ("gpuMaxRigidContactCount", cfg.gpu_max_rigid_contact_count), - ("gpuMaxRigidPatchCount", cfg.gpu_max_rigid_patch_count), - ("gpuFoundLostPairsCapacity", cfg.gpu_found_lost_pairs_capacity), - ("gpuFoundLostAggregatePairsCapacity", cfg.gpu_found_lost_aggregate_pairs_capacity), - ("gpuTotalAggregatePairsCapacity", cfg.gpu_total_aggregate_pairs_capacity), - ("gpuCollisionStackSize", cfg.gpu_collision_stack_size), - ]: - scene_prim.CreateAttribute(f"physxScene:{attr}", Sdf.ValueTypeNames.UInt).Set(val) # Propagate scene query support from SimulationCfg so omni.physx creates # the scene with the correct query mode. OvPhysxCfg does not carry this field. sim_cfg = PhysicsManager._sim.cfg if PhysicsManager._sim is not None else None enable_sq = getattr(sim_cfg, "enable_scene_query_support", False) scene_prim.CreateAttribute("physxScene:enableSceneQuerySupport", Sdf.ValueTypeNames.Bool).Set(enable_sq) + + if device == "gpu": + scene_prim.CreateAttribute("physxScene:enableGPUDynamics", Sdf.ValueTypeNames.Bool).Set(True) + scene_prim.CreateAttribute("physxScene:broadphaseType", Sdf.ValueTypeNames.String).Set("GPU") + + if cfg is not None: + for attr, val in [ + ("gpuMaxRigidContactCount", cfg.gpu_max_rigid_contact_count), + ("gpuMaxRigidPatchCount", cfg.gpu_max_rigid_patch_count), + ("gpuFoundLostPairsCapacity", cfg.gpu_found_lost_pairs_capacity), + ("gpuFoundLostAggregatePairsCapacity", cfg.gpu_found_lost_aggregate_pairs_capacity), + ("gpuTotalAggregatePairsCapacity", cfg.gpu_total_aggregate_pairs_capacity), + ("gpuCollisionStackSize", cfg.gpu_collision_stack_size), + ]: + scene_prim.CreateAttribute(f"physxScene:{attr}", Sdf.ValueTypeNames.UInt).Set(val) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py index 44a5cadeeb0a..41afe07cf09c 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py @@ -191,6 +191,65 @@ Shape is ``[N, L, 9]``, dtype ``float32``. """ +""" +Rigid-body TensorTypes + +Shapes assume N = number of rigid actor instances matched by the binding +pattern. Components and units are stated per alias below. +""" + +RIGID_BODY_POSE = _TT.RIGID_BODY_POSE +"""Rigid actor root transform — read/write, GPU. Shape ``(N, 7)``, +components ``(px, py, pz, qx, qy, qz, qw)`` [m, dimensionless].""" + +RIGID_BODY_VELOCITY = _TT.RIGID_BODY_VELOCITY +"""Rigid actor root spatial velocity — read/write, GPU. Shape ``(N, 6)``, +components ``(vx, vy, vz, wx, wy, wz)`` [m/s, rad/s].""" + +RIGID_BODY_WRENCH = _TT.RIGID_BODY_WRENCH +"""External wrench applied at a world-frame point — write-only, GPU. +Shape ``(N, 9)``, components ``(fx, fy, fz, tx, ty, tz, px, py, pz)`` +[N, N·m, m]. Cleared after each sim step (instantaneous semantics).""" + +RIGID_BODY_MASS = _TT.RIGID_BODY_MASS +"""Rigid actor mass — read/write, CPU. Shape ``(N,)`` [kg].""" + +RIGID_BODY_COM_POSE = _TT.RIGID_BODY_COM_POSE +"""Center-of-mass pose in actor-link frame — read/write, CPU. Shape +``(N, 7)``, components ``(px, py, pz, qx, qy, qz, qw)`` [m, dimensionless].""" + +RIGID_BODY_INERTIA = _TT.RIGID_BODY_INERTIA +"""Rigid actor inertia tensor in COM frame — read/write, CPU. Shape +``(N, 9)``, row-major flatten of the 3×3 inertia matrix +``(Ixx, Ixy, Ixz, Iyx, Iyy, Iyz, Izx, Izy, Izz)`` [kg·m²].""" + +# These three aliases are pending an upcoming ovphysx wheel update. +# When the wheel ships them, the corresponding ``hasattr`` checks below +# in IsaacLab consumers will start returning True and the bindings will +# become usable; until then, ``isaaclab_ovphysx.tensor_types`` simply +# does not expose the alias. +try: + RIGID_BODY_ACCELERATION = _TT.RIGID_BODY_ACCELERATION + """Rigid actor spatial acceleration — read-only, GPU. Shape ``(N, 6)``, + components ``(ax, ay, az, αx, αy, αz)`` [m/s², rad/s²].""" +except AttributeError: + pass + +try: + RIGID_BODY_INV_MASS = _TT.RIGID_BODY_INV_MASS + """Rigid actor inverse mass — read-only, CPU. Shape ``(N,)`` [1/kg]. + Zero indicates an immovable actor.""" +except AttributeError: + pass + +try: + RIGID_BODY_INV_INERTIA = _TT.RIGID_BODY_INV_INERTIA + """Rigid actor inverse inertia tensor in COM frame — read-only, CPU. + Shape ``(N, 9)``, row-major flatten of the 3×3 matrix [1/(kg·m²)]. + Zero rows indicate locked rotational DOFs.""" +except AttributeError: + pass + """ Dynamics tensors (GPU) """ @@ -306,29 +365,36 @@ # fmt: on # DOF/body property tensor types are CPU-resident even in GPU simulations. # Write helpers check this set to route data through CPU, not self._device. -_CPU_ONLY_TYPES: frozenset[TensorType] = frozenset( - { - DOF_STIFFNESS, - DOF_DAMPING, - DOF_LIMIT, - DOF_MAX_VELOCITY, - DOF_MAX_FORCE, - DOF_ARMATURE, - DOF_FRICTION_PROPERTIES, - BODY_MASS, - BODY_COM_POSE, - BODY_INERTIA, - BODY_INV_MASS, - BODY_INV_INERTIA, - FIXED_TENDON_STIFFNESS, - FIXED_TENDON_DAMPING, - FIXED_TENDON_LIMIT_STIFFNESS, - FIXED_TENDON_LIMIT, - FIXED_TENDON_REST_LENGTH, - FIXED_TENDON_OFFSET, - SPATIAL_TENDON_STIFFNESS, - SPATIAL_TENDON_DAMPING, - SPATIAL_TENDON_LIMIT_STIFFNESS, - SPATIAL_TENDON_OFFSET, - } +_CPU_ONLY_TYPES_CANDIDATES: tuple = ( + DOF_STIFFNESS, + DOF_DAMPING, + DOF_LIMIT, + DOF_MAX_VELOCITY, + DOF_MAX_FORCE, + DOF_ARMATURE, + DOF_FRICTION_PROPERTIES, + BODY_MASS, + BODY_COM_POSE, + BODY_INERTIA, + BODY_INV_MASS, + BODY_INV_INERTIA, + FIXED_TENDON_STIFFNESS, + FIXED_TENDON_DAMPING, + FIXED_TENDON_LIMIT_STIFFNESS, + FIXED_TENDON_LIMIT, + FIXED_TENDON_REST_LENGTH, + FIXED_TENDON_OFFSET, + SPATIAL_TENDON_STIFFNESS, + SPATIAL_TENDON_DAMPING, + SPATIAL_TENDON_LIMIT_STIFFNESS, + SPATIAL_TENDON_OFFSET, + # Rigid-body CPU-only entries (always available) + RIGID_BODY_MASS, + RIGID_BODY_COM_POSE, + RIGID_BODY_INERTIA, +) +# Optional rigid-body CPU entries: only included when the wheel exposes them. +_RIGID_BODY_OPTIONAL_CPU: tuple = tuple( + globals()[name] for name in ("RIGID_BODY_INV_MASS", "RIGID_BODY_INV_INERTIA") if name in globals() ) +_CPU_ONLY_TYPES: frozenset[TensorType] = frozenset(_CPU_ONLY_TYPES_CANDIDATES + _RIGID_BODY_OPTIONAL_CPU) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/views/mock_ovphysx_bindings.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/views/mock_ovphysx_bindings.py index 29472ce74fd0..51e96d9bb427 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/views/mock_ovphysx_bindings.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/views/mock_ovphysx_bindings.py @@ -7,6 +7,8 @@ from __future__ import annotations +from typing import Literal + import numpy as np from isaaclab_ovphysx import tensor_types as TT @@ -161,6 +163,10 @@ class MockOvPhysxBindingSet: for a given articulation configuration. Mirrors the tensor types that ``Articulation._initialize_impl`` creates. + + With ``asset_kind='rigid_object'`` it produces the smaller set + consumed by ``RigidObject._initialize_impl``: ``RIGID_BODY_*`` only, + ``num_joints`` must be 0, ``num_bodies`` must be 1, no tendons. """ def __init__( @@ -173,7 +179,54 @@ def __init__( body_names: list[str] | None = None, num_fixed_tendons: int = 0, num_spatial_tendons: int = 0, + *, + asset_kind: Literal["articulation", "rigid_object"] = "articulation", ): + if asset_kind == "rigid_object": + if num_joints != 0 or num_bodies != 1 or num_fixed_tendons != 0 or num_spatial_tendons != 0: + raise ValueError( + "asset_kind='rigid_object' requires num_joints=0, num_bodies=1, " + "num_fixed_tendons=0, num_spatial_tendons=0; got " + f"num_joints={num_joints}, num_bodies={num_bodies}, " + f"num_fixed_tendons={num_fixed_tendons}, " + f"num_spatial_tendons={num_spatial_tendons}" + ) + N = num_instances + if body_names is None: + body_names = ["base_link"] + common = dict( + count=N, + dof_count=0, + body_count=1, + joint_count=0, + is_fixed_base=is_fixed_base, + dof_names=[], + body_names=body_names, + joint_names=[], + fixed_tendon_count=0, + spatial_tendon_count=0, + ) + self.bindings: dict[int, MockTensorBinding] = { + TT.RIGID_BODY_POSE: MockTensorBinding(TT.RIGID_BODY_POSE, (N, 7), **common), + TT.RIGID_BODY_VELOCITY: MockTensorBinding(TT.RIGID_BODY_VELOCITY, (N, 6), **common), + TT.RIGID_BODY_WRENCH: MockTensorBinding(TT.RIGID_BODY_WRENCH, (N, 9), write_only=True, **common), + TT.RIGID_BODY_MASS: MockTensorBinding(TT.RIGID_BODY_MASS, (N,), **common), + TT.RIGID_BODY_COM_POSE: MockTensorBinding(TT.RIGID_BODY_COM_POSE, (N, 7), **common), + TT.RIGID_BODY_INERTIA: MockTensorBinding(TT.RIGID_BODY_INERTIA, (N, 9), **common), + } + # Optional bindings: only present when the wheel exposes the alias. + if hasattr(TT, "RIGID_BODY_ACCELERATION"): + self.bindings[TT.RIGID_BODY_ACCELERATION] = MockTensorBinding( + TT.RIGID_BODY_ACCELERATION, (N, 6), **common + ) + if hasattr(TT, "RIGID_BODY_INV_MASS"): + self.bindings[TT.RIGID_BODY_INV_MASS] = MockTensorBinding(TT.RIGID_BODY_INV_MASS, (N,), **common) + if hasattr(TT, "RIGID_BODY_INV_INERTIA"): + self.bindings[TT.RIGID_BODY_INV_INERTIA] = MockTensorBinding( + TT.RIGID_BODY_INV_INERTIA, (N, 9), **common + ) + return + N = num_instances D = num_joints L = num_bodies @@ -259,17 +312,25 @@ def set_random_data(self) -> None: for b in self.bindings.values(): if not b._write_only: b.set_random_data() - lim = self.bindings[TT.DOF_LIMIT] - lim._data[..., 0] = -3.14 - lim._data[..., 1] = 3.14 - for tt in (TT.ROOT_POSE, TT.LINK_POSE, TT.BODY_COM_POSE): + if TT.DOF_LIMIT in self.bindings: + lim = self.bindings[TT.DOF_LIMIT] + lim._data[..., 0] = -3.14 + lim._data[..., 1] = 3.14 + pose_keys = [ + k + for k in (TT.ROOT_POSE, TT.LINK_POSE, TT.BODY_COM_POSE, TT.RIGID_BODY_POSE, TT.RIGID_BODY_COM_POSE) + if k in self.bindings + ] + for tt in pose_keys: b = self.bindings[tt] b._data[..., 3:6] = 0.0 b._data[..., 6] = 1.0 - self.bindings[TT.BODY_MASS]._data = np.abs(self.bindings[TT.BODY_MASS]._data) + 0.1 - self.bindings[TT.DOF_MAX_VELOCITY]._data = np.abs(self.bindings[TT.DOF_MAX_VELOCITY]._data) + 1.0 - self.bindings[TT.DOF_MAX_FORCE]._data = np.abs(self.bindings[TT.DOF_MAX_FORCE]._data) + 1.0 - # Set sensible defaults for fixed tendon limits + for mass_key in (TT.BODY_MASS, TT.RIGID_BODY_MASS): + if mass_key in self.bindings: + self.bindings[mass_key]._data = np.abs(self.bindings[mass_key]._data) + 0.1 + for max_key in (TT.DOF_MAX_VELOCITY, TT.DOF_MAX_FORCE): + if max_key in self.bindings: + self.bindings[max_key]._data = np.abs(self.bindings[max_key]._data) + 1.0 if TT.FIXED_TENDON_LIMIT in self.bindings: tlim = self.bindings[TT.FIXED_TENDON_LIMIT] tlim._data[..., 0] = -1.0 diff --git a/source/isaaclab_ovphysx/test/assets/test_rigid_object.py b/source/isaaclab_ovphysx/test/assets/test_rigid_object.py new file mode 100644 index 000000000000..407cf4b41e22 --- /dev/null +++ b/source/isaaclab_ovphysx/test/assets/test_rigid_object.py @@ -0,0 +1,1134 @@ +# 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 + +# ignore private usage of variables warning +# pyright: reportPrivateUsage=none + + +"""Real-backend tests for the OVPhysX RigidObject. + +Run via ``./scripts/run_ovphysx.sh -m pytest`` (kitless, no ``AppLauncher``). + +``ovphysx<=0.3.7`` binds device mode (CPU vs GPU) at the C++ layer on the +first ``ovphysx.PhysX(device=...)`` construction and cannot swap it without a +process restart. Full coverage therefore requires two separate pytest +invocations -- once with ``-k 'cpu'`` and once with ``-k 'cuda:0'``. The +``_ovphysx_skip_other_device`` autouse fixture below preempts the manager's +:exc:`RuntimeError` by ``pytest.skip``-ing on the unlocked device so +single-device runs finish cleanly. +""" + +from __future__ import annotations + +import logging +import sys +from typing import Literal +from unittest.mock import MagicMock + +import pytest +import torch +import warp as wp +from flaky import flaky + +# The CI isaaclab_ov* pattern unintentionally collects isaaclab_ovphysx tests, +# but the ovphysx wheel is not installed in that environment. Skip gracefully +# so the isaaclab_ov CI pipeline is not blocked by an unrelated dependency. +pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") + +from isaaclab_ovphysx.assets import RigidObject # noqa: E402 +from isaaclab_ovphysx.physics import OvPhysxCfg, OvPhysxManager # noqa: E402 + +import isaaclab.sim as sim_utils # noqa: E402 +from isaaclab.assets import RigidObjectCfg # noqa: E402 +from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR # noqa: E402 +from isaaclab.utils.math import ( # noqa: E402 + combine_frame_transforms, + default_orientation, + quat_apply_inverse, + quat_inv, + quat_mul, + quat_rotate, + random_orientation, +) + +wp.init() + +_logger = logging.getLogger(__name__) + + +_LOCKED_DEVICE: list[str | None] = [None] +"""Device the session pins to on the first parametrized test that runs.""" + + +@pytest.fixture(autouse=True) +def _ovphysx_skip_other_device(request): + """Skip parametrized tests on the device the session is not pinned to. + + See the module docstring for the wheel's process-global device-mode lock. + """ + callspec = getattr(request.node, "callspec", None) + device = callspec.params.get("device") if callspec is not None else None + if device is None: + # Test does not parametrize on device (e.g. test_warmup_attach_stage_not_called_for_cpu). + return + locked = _LOCKED_DEVICE[0] + if locked is None: + _LOCKED_DEVICE[0] = device + return + if device != locked: + pytest.skip( + f"ovphysx process-global device lock is held by '{locked}'; cannot run '{device}' " + "tests in the same session. Run pytest twice (once per device) for full coverage." + ) + + +def _ovphysx_sim_context(device: str, **kwargs): + """Wrapper around :func:`build_simulation_context` that injects OVPhysX cfg. + + PhysX tests pass ``device=device`` directly and let + :func:`build_simulation_context` build a default :class:`SimulationCfg`. + OVPhysX needs ``physics=OvPhysxCfg()`` set on the cfg so the manager + dispatches to OVPhysX rather than PhysX, so we build the cfg here and + pass it through. ``gravity_enabled`` is consumed locally (it is ignored + by ``build_simulation_context`` once a ``sim_cfg`` is provided). + ``add_ground_plane``, ``auto_add_lighting``, and other kwargs continue + to flow through ``build_simulation_context`` as before. + """ + dt = kwargs.pop("dt", 1.0 / 60.0) + gravity_enabled = kwargs.pop("gravity_enabled", True) + gravity = (0.0, 0.0, -9.81) if gravity_enabled else (0.0, 0.0, 0.0) + sim_cfg = SimulationCfg(physics=OvPhysxCfg(), device=device, dt=dt, gravity=gravity) + return build_simulation_context(device=device, sim_cfg=sim_cfg, **kwargs) + + +def generate_cubes_scene( + num_cubes: int = 1, + height=1.0, + api: Literal["none", "rigid_body", "articulation_root"] = "rigid_body", + kinematic_enabled: bool = False, + device: str = "cuda:0", +) -> tuple[RigidObject, torch.Tensor]: + """Generate a scene with the provided number of cubes. + + Args: + num_cubes: Number of cubes to generate. + height: Height of the cubes. + api: The type of API that the cubes should have. + kinematic_enabled: Whether the cubes are kinematic. + device: Device to use for the simulation. + + Returns: + A tuple containing the rigid object representing the cubes and the origins of the cubes. + + """ + origins = torch.tensor([(i * 1.0, 0, height) for i in range(num_cubes)]).to(device) + # Create Top-level Xforms, one for each cube + for i, origin in enumerate(origins): + sim_utils.create_prim(f"/World/Table_{i}", "Xform", translation=origin) + + # Resolve spawn configuration + if api == "none": + # since no rigid body properties defined, this is just a static collider + spawn_cfg = sim_utils.CuboidCfg( + size=(0.1, 0.1, 0.1), + collision_props=sim_utils.CollisionPropertiesCfg(), + ) + elif api == "rigid_body": + spawn_cfg = sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/DexCube/dex_cube_instanceable.usd", + rigid_props=sim_utils.RigidBodyPropertiesCfg(kinematic_enabled=kinematic_enabled), + ) + elif api == "articulation_root": + spawn_cfg = sim_utils.UsdFileCfg( + usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Tests/RigidObject/Cube/dex_cube_instanceable_with_articulation_root.usd", + rigid_props=sim_utils.RigidBodyPropertiesCfg(kinematic_enabled=kinematic_enabled), + ) + else: + raise ValueError(f"Unknown api: {api}") + + # Create rigid object. OVPhysX matches prim paths via fnmatch globs (not regex), + # so use ``Table_*`` rather than the PhysX ``Table_.*`` form. + 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 + + +# --------------------------------------------------------------------------- +# Material-property gap (xfail reason shared by 5 tests below) +# --------------------------------------------------------------------------- + +_MATERIAL_GAP_REASON = ( + "Requires RIGID_BODY_MATERIAL TensorType (or a view-helper) on the ovphysx " + "wheel side. RigidObject.root_view is a per-tensor-type bindings dict on " + "OVPhysX, so root_view.get_material_properties() / set_material_properties() " + "are not available. See " + "docs/superpowers/specs/2026-04-28-ovphysx-wheel-gaps-for-marco.md." +) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_initialization(num_cubes, device): + """Test initialization for prim with rigid body API at the provided prim path.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + # Generate cubes scene + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(cube_object) < 10 + + # Play sim + sim.reset() + + # Check if object is initialized + assert cube_object.is_initialized + assert len(cube_object.body_names) == 1 + + # Check buffers that exists and have correct shapes + assert cube_object.data.root_pos_w.torch.shape == (num_cubes, 3) + assert cube_object.data.root_quat_w.torch.shape == (num_cubes, 4) + assert cube_object.data.body_mass.torch.shape == (num_cubes, 1) + assert cube_object.data.body_inertia.torch.shape == (num_cubes, 1, 9) + + # Simulate physics + for _ in range(2): + # perform rendering + sim.step() + # update object + cube_object.update(sim.cfg.dt) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_initialization_with_kinematic_enabled(num_cubes, device): + """Test that initialization for prim with kinematic flag enabled.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + # Generate cubes scene + cube_object, origins = generate_cubes_scene(num_cubes=num_cubes, kinematic_enabled=True, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(cube_object) < 10 + + # Play sim + sim.reset() + + # Check if object is initialized + assert cube_object.is_initialized + assert len(cube_object.body_names) == 1 + + # Check buffers that exists and have correct shapes + assert cube_object.data.root_pos_w.torch.shape == (num_cubes, 3) + assert cube_object.data.root_quat_w.torch.shape == (num_cubes, 4) + + # Simulate physics + for _ in range(2): + # perform rendering + sim.step() + # update object + cube_object.update(sim.cfg.dt) + # check that the object is kinematic + default_root_pose = cube_object.data.default_root_pose.torch.clone() + default_root_vel = cube_object.data.default_root_vel.torch.clone() + default_root_pose[:, :3] += origins + torch.testing.assert_close(cube_object.data.root_link_pose_w.torch, default_root_pose) + torch.testing.assert_close(cube_object.data.root_com_vel_w.torch, default_root_vel) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_initialization_with_no_rigid_body(num_cubes, device): + """Test that initialization fails when no rigid body is found at the provided prim path.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + # Generate cubes scene + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, api="none", device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(cube_object) < 10 + + # Play sim + with pytest.raises(RuntimeError): + sim.reset() + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_initialization_with_articulation_root(num_cubes, device): + """Test that initialization fails when an articulation root is found at the provided prim path.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + # Generate cubes scene + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, api="articulation_root", device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(cube_object) < 10 + + # Play sim + with pytest.raises(RuntimeError): + sim.reset() + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_external_force_buffer(device): + """Test if external force buffer correctly updates in the force value is zero case. + + In this test, we apply a non-zero force, then a zero force, then finally a non-zero force + to an object. We check if the force buffer is properly updated at each step. + """ + + # Generate cubes scene + with _ovphysx_sim_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: + cube_object, origins = generate_cubes_scene(num_cubes=1, device=device) + + # play the simulator + sim.reset() + + # find bodies to apply the force + body_ids, body_names = cube_object.find_bodies(".*") + + # reset object + cube_object.reset() + + # perform simulation + for step in range(5): + # initiate force tensor + external_wrench_b = torch.zeros(cube_object.num_instances, len(body_ids), 6, device=sim.device) + + if step == 0 or step == 3: + # set a non-zero force + force = 1 + else: + # set a zero force + force = 0 + + # set force value + external_wrench_b[:, :, 0] = force + external_wrench_b[:, :, 3] = force + + # apply force + cube_object.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + body_ids=body_ids, + ) + + # check if the cube's force and torque buffers are correctly updated + for i in range(cube_object.num_instances): + assert cube_object._permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force + assert cube_object._permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force + + # Check if the instantaneous wrench is correctly added to the permanent wrench + cube_object.permanent_wrench_composer.add_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + body_ids=body_ids, + ) + + # apply action to the object + cube_object.write_data_to_sim() + + # perform step + sim.step() + + # update buffers + cube_object.update(sim.cfg.dt) + + +@pytest.mark.parametrize("num_cubes", [2, 4]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_external_force_on_single_body(num_cubes, device): + """Test application of external force on the base of the object. + + In this test, we apply a force equal to the weight of an object on the base of + one of the objects. We check that the object does not move. For the other object, + we do not apply any force and check that it falls down. + + We validate that this works when we apply the force in the global frame and in the local frame. + """ + # Generate cubes scene + with _ovphysx_sim_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: + cube_object, origins = generate_cubes_scene(num_cubes=num_cubes, device=device) + + # Play the simulator + sim.reset() + + # Find bodies to apply the force + body_ids, body_names = cube_object.find_bodies(".*") + + # Sample a force equal to the weight of the object. PhysX reads the mass + # from ``root_view.get_masses()``; OVPhysX exposes the same value via + # ``cube_object.data.body_mass`` (shape ``(N, 1)``). + external_wrench_b = torch.zeros(cube_object.num_instances, len(body_ids), 6, device=sim.device) + # Every 2nd cube should have a force applied to it + external_wrench_b[0::2, :, 2] = 9.81 * cube_object.data.body_mass.torch[0] + + # Now we are ready! + for i in range(5): + # reset root state + root_pose = cube_object.data.default_root_pose.torch.clone() + root_vel = cube_object.data.default_root_vel.torch.clone() + + # need to shift the position of the cubes otherwise they will be on top of each other + root_pose[:, :3] = origins + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) + + # reset object + cube_object.reset() + + is_global = False + if i % 2 == 0: + is_global = True + positions = cube_object.data.body_com_pos_w.torch[:, body_ids, :3] + else: + positions = None + + # apply force + cube_object.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + positions=positions, + body_ids=body_ids, + is_global=is_global, + ) + # perform simulation + for _ in range(5): + # apply action to the object + cube_object.write_data_to_sim() + + # perform step + sim.step() + + # update buffers + cube_object.update(sim.cfg.dt) + + # First object should still be at the same Z position (1.0) + torch.testing.assert_close( + cube_object.data.root_pos_w.torch[0::2, 2], torch.ones(num_cubes // 2, device=sim.device) + ) + # Second object should have fallen, so it's Z height should be less than initial height of 1.0 + assert torch.all(cube_object.data.root_pos_w.torch[1::2, 2] < 1.0) + + +@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): + """Test application of external force on the base of the object at a specific position. + + In this test, we apply a force equal to the weight of an object on the base of + one of the objects at 1m in the Y direction, we check that the object rotates around it's X axis. + For the other object, we do not apply any force and check that it falls down. + + We validate that this works when we apply the force in the global frame and in the local frame. + """ + # Generate cubes scene + with _ovphysx_sim_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: + cube_object, origins = generate_cubes_scene(num_cubes=num_cubes, device=device) + + # Play the simulator + sim.reset() + + # Find bodies to apply the force + body_ids, body_names = cube_object.find_bodies(".*") + + # Sample a force equal to the weight of the object + 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_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 + root_pose = cube_object.data.default_root_pose.torch.clone() + root_vel = cube_object.data.default_root_vel.torch.clone() + + # need to shift the position of the cubes otherwise they will be on top of each other + root_pose[:, :3] = origins + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) + + # reset object + cube_object.reset() + + is_global = False + if i % 2 == 0: + is_global = True + body_com_pos_w = cube_object.data.body_com_pos_w.torch[:, body_ids, :3] + external_wrench_positions_b[..., 0] = 0.0 + external_wrench_positions_b[..., 1] = 1.0 + external_wrench_positions_b[..., 2] = 0.0 + external_wrench_positions_b += body_com_pos_w + else: + external_wrench_positions_b[..., 0] = 0.0 + external_wrench_positions_b[..., 1] = 1.0 + external_wrench_positions_b[..., 2] = 0.0 + + # apply force + cube_object.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + positions=external_wrench_positions_b, + body_ids=body_ids, + is_global=is_global, + ) + cube_object.permanent_wrench_composer.add_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + positions=external_wrench_positions_b, + body_ids=body_ids, + is_global=is_global, + ) + torch.testing.assert_close( + cube_object._permanent_wrench_composer.composed_force.torch[:, 0, :], + desired_force[:, 0, :], + rtol=1e-6, + atol=1e-7, + ) + torch.testing.assert_close( + cube_object._permanent_wrench_composer.composed_torque.torch[:, 0, :], + desired_torque[:, 0, :], + rtol=1e-6, + atol=1e-7, + ) + # perform simulation + for _ in range(5): + # apply action to the object + cube_object.write_data_to_sim() + + # perform step + sim.step() + + # update buffers + cube_object.update(sim.cfg.dt) + + # The first object should be rotating around it's X axis + assert torch.all(torch.abs(cube_object.data.root_ang_vel_b.torch[0::2, 0]) > 0.1) + # Second object should have fallen, so it's Z height should be less than initial height of 1.0 + assert torch.all(cube_object.data.root_pos_w.torch[1::2, 2] < 1.0) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_set_rigid_object_state(num_cubes, device): + """Test setting the state of the rigid object. + + In this test, we set the state of the rigid object to a random state and check + that the object is in that state after simulation. We set gravity to zero as + we don't want any external forces acting on the object to ensure state remains static. + """ + # Turn off gravity for this test as we don't want any external forces acting on the object + # to ensure state remains static + with _ovphysx_sim_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + # Generate cubes scene + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) + + # Play the simulator + sim.reset() + + state_types = ["root_pos_w", "root_quat_w", "root_lin_vel_w", "root_ang_vel_w"] + + # Set each state type individually as they are dependent on each other + for state_type_to_randomize in state_types: + state_dict = { + "root_pos_w": torch.zeros_like(cube_object.data.root_pos_w.torch, device=sim.device), + "root_quat_w": default_orientation(num=num_cubes, device=sim.device), + "root_lin_vel_w": torch.zeros_like(cube_object.data.root_lin_vel_w.torch, device=sim.device), + "root_ang_vel_w": torch.zeros_like(cube_object.data.root_ang_vel_w.torch, device=sim.device), + } + + # Now we are ready! + for _ in range(5): + # reset object + cube_object.reset() + + # Set random state + if state_type_to_randomize == "root_quat_w": + state_dict[state_type_to_randomize] = random_orientation(num=num_cubes, device=sim.device) + else: + state_dict[state_type_to_randomize] = torch.randn(num_cubes, 3, device=sim.device) + + # perform simulation + for _ in range(5): + root_pose = torch.cat( + [state_dict["root_pos_w"], state_dict["root_quat_w"]], + dim=-1, + ) + root_vel = torch.cat( + [state_dict["root_lin_vel_w"], state_dict["root_ang_vel_w"]], + dim=-1, + ) + # reset root state + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) + + sim.step() + + # assert that set root quantities are equal to the ones set in the state_dict + for key, expected_value in state_dict.items(): + value = getattr(cube_object.data, key).torch + torch.testing.assert_close(value, expected_value, rtol=1e-3, atol=1e-3) + + cube_object.update(sim.cfg.dt) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_reset_rigid_object(num_cubes, device): + """Test resetting the state of the rigid object.""" + with _ovphysx_sim_context(device=device, gravity_enabled=True, auto_add_lighting=True) as sim: + # Generate cubes scene + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) + + # Play the simulator + sim.reset() + + for i in range(5): + # perform rendering + sim.step() + + # update object + cube_object.update(sim.cfg.dt) + + # Move the object to a random position + root_pose = cube_object.data.default_root_pose.torch.clone() + root_pose[:, :3] = torch.randn(num_cubes, 3, device=sim.device) + + # Random orientation + root_pose[:, 3:7] = random_orientation(num=num_cubes, device=sim.device) + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + root_vel = cube_object.data.default_root_vel.torch.clone() + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) + + if i % 2 == 0: + # reset object + cube_object.reset() + + # Reset should zero external forces and torques + assert not cube_object._instantaneous_wrench_composer.active + assert not cube_object._permanent_wrench_composer.active + assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.composed_force.torch) == 0 + assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.composed_torque.torch) == 0 + assert torch.count_nonzero(cube_object._permanent_wrench_composer.composed_force.torch) == 0 + assert torch.count_nonzero(cube_object._permanent_wrench_composer.composed_torque.torch) == 0 + + +@pytest.mark.xfail(reason=_MATERIAL_GAP_REASON, strict=False) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_rigid_body_set_material_properties(num_cubes, device): + """Test getting and setting material properties of rigid object.""" + raise NotImplementedError(_MATERIAL_GAP_REASON) + + +@pytest.mark.xfail(reason=_MATERIAL_GAP_REASON, strict=False) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_set_material_properties_via_view(num_cubes, device): + """Test setting material properties via the PhysX view-level API.""" + raise NotImplementedError(_MATERIAL_GAP_REASON) + + +@pytest.mark.xfail(reason=_MATERIAL_GAP_REASON, strict=False) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_rigid_body_no_friction(num_cubes, device): + """Test that a rigid object with no friction will maintain it's velocity when sliding across a plane.""" + raise NotImplementedError(_MATERIAL_GAP_REASON) + + +@pytest.mark.xfail(reason=_MATERIAL_GAP_REASON, strict=False) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda", "cpu"]) +@pytest.mark.isaacsim_ci +def test_rigid_body_with_static_friction(num_cubes, device): + """Test that static friction applied to rigid object works as expected. + + This test works by applying a force to the object and checking if the object moves or not based on the + mu (coefficient of static friction) value set for the object. We set the static friction to be non-zero and + apply a force to the object. When the force applied is below mu, the object should not move. When the force + applied is above mu, the object should move. + """ + raise NotImplementedError(_MATERIAL_GAP_REASON) + + +@pytest.mark.xfail(reason=_MATERIAL_GAP_REASON, strict=False) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_rigid_body_with_restitution(num_cubes, device): + """Test that restitution when applied to rigid object works as expected. + + This test works by dropping a block from a height and checking if the block bounces or not based on the + restitution value set for the object. We set the restitution to be non-zero and drop the block from a height. + When the restitution is 0, the block should not bounce. When the restitution is between 0 and 1, the block + should bounce with less energy. + """ + raise NotImplementedError(_MATERIAL_GAP_REASON) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_rigid_body_set_mass(num_cubes, device): + """Test getting and setting mass of rigid object.""" + with _ovphysx_sim_context( + device=device, gravity_enabled=False, add_ground_plane=True, auto_add_lighting=True + ) as sim: + # Create a scene with random cubes + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, height=1.0, device=device) + + # Play sim + sim.reset() + + # Get masses before increasing + original_masses = cube_object.data.body_mass.torch.clone() + + assert original_masses.shape == (num_cubes, 1) + + # Randomize mass of the object + masses = original_masses + torch.FloatTensor(num_cubes, 1).uniform_(4, 8).to(sim.device) + + indices = torch.tensor(range(num_cubes), dtype=torch.int32) + + # Set the new masses via the OVPhysX writer (matches PhysX/Newton). + cube_object.set_masses_index( + masses=wp.from_torch(masses.contiguous(), dtype=wp.float32), + env_ids=wp.from_torch(indices, dtype=wp.int32), + ) + + torch.testing.assert_close(cube_object.data.body_mass.torch, masses) + + # Simulate physics + # perform rendering + sim.step() + # update object + cube_object.update(sim.cfg.dt) + + masses_to_check = cube_object.data.body_mass.torch + + # Check if mass is set correctly + torch.testing.assert_close(masses, masses_to_check) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("gravity_enabled", [True, False]) +@pytest.mark.isaacsim_ci +def test_gravity_vec_w(num_cubes, device, gravity_enabled): + """Test that gravity vector direction is set correctly for the rigid object.""" + with _ovphysx_sim_context(device=device, gravity_enabled=gravity_enabled) as sim: + # Create a scene with random cubes + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) + + # Obtain gravity direction + if gravity_enabled: + gravity_dir = (0.0, 0.0, -1.0) + else: + gravity_dir = (0.0, 0.0, 0.0) + + # Play sim + sim.reset() + + # Check that gravity is set correctly + assert cube_object.data.GRAVITY_VEC_W.torch[0, 0] == gravity_dir[0] + assert cube_object.data.GRAVITY_VEC_W.torch[0, 1] == gravity_dir[1] + assert cube_object.data.GRAVITY_VEC_W.torch[0, 2] == gravity_dir[2] + + # Simulate physics + for _ in range(2): + # perform rendering + sim.step() + # update object + cube_object.update(sim.cfg.dt) + + # Expected gravity value is the acceleration of the body + gravity = torch.zeros(num_cubes, 1, 6, device=device) + if gravity_enabled: + gravity[:, :, 2] = -9.81 + # Check the body accelerations are correct + torch.testing.assert_close(cube_object.data.body_acc_w.torch, gravity) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("with_offset", [True, False]) +@pytest.mark.isaacsim_ci +@flaky(max_runs=3, min_passes=1) +def test_body_root_state_properties(num_cubes, device, with_offset): + """Test the root_com_state_w, root_link_state_w, body_com_state_w, and body_link_state_w properties.""" + with _ovphysx_sim_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + # Create a scene with random cubes + cube_object, env_pos = generate_cubes_scene(num_cubes=num_cubes, height=0.0, device=device) + env_idx = torch.tensor([x for x in range(num_cubes)], dtype=torch.int32) + + # Play sim + sim.reset() + + # Check if cube_object is initialized + assert cube_object.is_initialized + + # change center of mass offset from link frame + if with_offset: + offset = torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_cubes, 1) + else: + offset = torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_cubes, 1) + + # Read current COMs, mutate the translation, write back via the OVPhysX + # ``set_coms_index`` setter (PhysX uses ``root_view.set_coms`` for the same + # operation; OVPhysX wraps the wheel ``RIGID_BODY_COM_POSE`` write in + # :meth:`set_coms_index`, which follows the PhysX ``wp.transformf`` contract). + com = cube_object.data.body_com_pose_b.torch.clone() # shape (N, 1, 7) + com[..., :3] = offset.to(com.device).unsqueeze(1) + cube_object.set_coms_index( + coms=wp.from_torch(com.contiguous(), dtype=wp.transformf), + env_ids=wp.from_torch(env_idx, dtype=wp.int32), + ) + + # check ceter of mass has been set + torch.testing.assert_close(cube_object.data.body_com_pose_b.torch, com) + + # random z spin velocity + spin_twist = torch.zeros(6, device=device) + spin_twist[5] = torch.randn(1, device=device) + + # Simulate physics + for _ in range(100): + # spin the object around Z axis (com) + cube_object.write_root_velocity_to_sim_index(root_velocity=spin_twist.repeat(num_cubes, 1)) + # perform rendering + sim.step() + # update object + cube_object.update(sim.cfg.dt) + + # get state properties + root_link_pose_w = cube_object.data.root_link_pose_w.torch + root_link_vel_w = cube_object.data.root_link_vel_w.torch + root_com_pose_w = cube_object.data.root_com_pose_w.torch + root_com_vel_w = cube_object.data.root_com_vel_w.torch + body_link_pose_w = cube_object.data.body_link_pose_w.torch + body_link_vel_w = cube_object.data.body_link_vel_w.torch + body_com_pose_w = cube_object.data.body_com_pose_w.torch + body_com_vel_w = cube_object.data.body_com_vel_w.torch + + # if offset is [0,0,0] all root_state_%_w will match and all body_%_w will match + if not with_offset: + torch.testing.assert_close(root_link_pose_w, root_com_pose_w) + torch.testing.assert_close(root_com_vel_w, root_link_vel_w) + torch.testing.assert_close(root_link_pose_w, root_link_pose_w) + torch.testing.assert_close(root_com_vel_w, root_link_vel_w) + torch.testing.assert_close(body_link_pose_w, body_com_pose_w) + torch.testing.assert_close(body_com_vel_w, body_link_vel_w) + torch.testing.assert_close(body_link_pose_w, body_link_pose_w) + torch.testing.assert_close(body_com_vel_w, body_link_vel_w) + else: + # cubes are spinning around center of mass + # position will not match + # center of mass position will be constant (i.e. spinning around com) + torch.testing.assert_close(env_pos + offset, root_com_pose_w[..., :3]) + torch.testing.assert_close(env_pos + offset, body_com_pose_w[..., :3].squeeze(-2)) + # link position will be moving but should stay constant away from center of mass + root_link_state_pos_rel_com = quat_apply_inverse( + root_link_pose_w[..., 3:], + root_link_pose_w[..., :3] - root_com_pose_w[..., :3], + ) + torch.testing.assert_close(-offset, root_link_state_pos_rel_com) + body_link_state_pos_rel_com = quat_apply_inverse( + body_link_pose_w[..., 3:], + body_link_pose_w[..., :3] - body_com_pose_w[..., :3], + ) + torch.testing.assert_close(-offset, body_link_state_pos_rel_com.squeeze(-2)) + + # orientation of com will be a constant rotation from link orientation + com_quat_b = cube_object.data.body_com_quat_b.torch + com_quat_w = quat_mul(body_link_pose_w[..., 3:], com_quat_b) + torch.testing.assert_close(com_quat_w, body_com_pose_w[..., 3:]) + torch.testing.assert_close(com_quat_w.squeeze(-2), root_com_pose_w[..., 3:]) + + # orientation of link will match root state will always match + torch.testing.assert_close(root_link_pose_w[..., 3:], root_link_pose_w[..., 3:]) + torch.testing.assert_close(body_link_pose_w[..., 3:], body_link_pose_w[..., 3:]) + + # lin_vel will not match + # center of mass vel will be constant (i.e. spinning around com) + torch.testing.assert_close(torch.zeros_like(root_com_vel_w[..., :3]), root_com_vel_w[..., :3]) + torch.testing.assert_close(torch.zeros_like(body_com_vel_w[..., :3]), body_com_vel_w[..., :3]) + # link frame will be moving, and should be equal to input angular velocity cross offset + lin_vel_rel_root_gt = quat_apply_inverse(root_link_pose_w[..., 3:], root_link_vel_w[..., :3]) + lin_vel_rel_body_gt = quat_apply_inverse(body_link_pose_w[..., 3:], body_link_vel_w[..., :3]) + lin_vel_rel_gt = torch.linalg.cross(spin_twist.repeat(num_cubes, 1)[..., 3:], -offset) + torch.testing.assert_close(lin_vel_rel_gt, lin_vel_rel_root_gt, atol=1e-4, rtol=1e-4) + torch.testing.assert_close(lin_vel_rel_gt, lin_vel_rel_body_gt.squeeze(-2), atol=1e-4, rtol=1e-4) + + # ang_vel will always match + torch.testing.assert_close(root_com_vel_w[..., 3:], root_com_vel_w[..., 3:]) + torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) + torch.testing.assert_close(body_com_vel_w[..., 3:], body_com_vel_w[..., 3:]) + torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("with_offset", [True, False]) +@pytest.mark.parametrize("state_location", ["com", "link"]) +@pytest.mark.isaacsim_ci +def test_write_root_state(num_cubes, device, with_offset, state_location): + """Test the setters for root_state using both the link frame and center of mass as reference frame.""" + with _ovphysx_sim_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + # Create a scene with random cubes + cube_object, env_pos = generate_cubes_scene(num_cubes=num_cubes, height=0.0, device=device) + env_idx = torch.tensor([x for x in range(num_cubes)], dtype=torch.int32) + + # Play sim + sim.reset() + + # Check if cube_object is initialized + assert cube_object.is_initialized + + # change center of mass offset from link frame + if with_offset: + offset = torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_cubes, 1) + else: + offset = torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_cubes, 1) + + com = cube_object.data.body_com_pose_b.torch.clone() # shape (N, 1, 7) + com[..., :3] = offset.to(com.device).unsqueeze(1) + cube_object.set_coms_index( + coms=wp.from_torch(com.contiguous(), dtype=wp.transformf), + env_ids=wp.from_torch(env_idx, dtype=wp.int32), + ) + + # check center of mass has been set + torch.testing.assert_close(cube_object.data.body_com_pose_b.torch, com) + + rand_state = torch.zeros(num_cubes, 13, device=device) + rand_state[..., :7] = cube_object.data.default_root_pose.torch + rand_state[..., :3] += env_pos + # make quaternion a unit vector + rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) + + env_idx = env_idx.to(device) + for i in range(10): + # perform step + sim.step() + # update buffers + cube_object.update(sim.cfg.dt) + + if state_location == "com": + if i % 2 == 0: + cube_object.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7]) + cube_object.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) + else: + cube_object.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=env_idx) + cube_object.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:], env_ids=env_idx) + elif state_location == "link": + if i % 2 == 0: + cube_object.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7]) + cube_object.write_root_link_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) + else: + cube_object.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=env_idx) + cube_object.write_root_link_velocity_to_sim_index( + root_velocity=rand_state[..., 7:], env_ids=env_idx + ) + + if state_location == "com": + torch.testing.assert_close(rand_state[..., :7], cube_object.data.root_com_pose_w.torch) + torch.testing.assert_close(rand_state[..., 7:], cube_object.data.root_com_vel_w.torch) + elif state_location == "link": + torch.testing.assert_close(rand_state[..., :7], cube_object.data.root_link_pose_w.torch) + torch.testing.assert_close(rand_state[..., 7:], cube_object.data.root_link_vel_w.torch) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("with_offset", [True]) +@pytest.mark.parametrize("state_location", ["com", "link", "root"]) +@pytest.mark.isaacsim_ci +def test_write_state_functions_data_consistency(num_cubes, device, with_offset, state_location): + """Test the setters for root_state using both the link frame and center of mass as reference frame.""" + with _ovphysx_sim_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + # Create a scene with random cubes + cube_object, env_pos = generate_cubes_scene(num_cubes=num_cubes, height=0.0, device=device) + env_idx = torch.tensor([x for x in range(num_cubes)], dtype=torch.int32) + + # Play sim + sim.reset() + + # Check if cube_object is initialized + assert cube_object.is_initialized + + # change center of mass offset from link frame + if with_offset: + offset = torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_cubes, 1) + else: + offset = torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_cubes, 1) + + com = cube_object.data.body_com_pose_b.torch.clone() # shape (N, 1, 7) + com[..., :3] = offset.to(com.device).unsqueeze(1) + cube_object.set_coms_index( + coms=wp.from_torch(com.contiguous(), dtype=wp.transformf), + env_ids=wp.from_torch(env_idx, dtype=wp.int32), + ) + + # check ceter of mass has been set + torch.testing.assert_close(cube_object.data.body_com_pose_b.torch, com) + + rand_state = torch.rand(num_cubes, 13, device=device) + rand_state[..., :3] += env_pos + # make quaternion a unit vector + rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) + + env_idx = env_idx.to(device) + + # perform step + sim.step() + # update buffers + cube_object.update(sim.cfg.dt) + + if state_location == "com": + cube_object.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7]) + cube_object.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) + elif state_location == "link": + cube_object.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7]) + cube_object.write_root_link_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) + elif state_location == "root": + cube_object.write_root_pose_to_sim_index(root_pose=rand_state[..., :7]) + cube_object.write_root_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) + + if state_location == "com": + root_com_pose_w = cube_object.data.root_com_pose_w.torch + root_com_vel_w = cube_object.data.root_com_vel_w.torch + body_com_pose_b = cube_object.data.body_com_pose_b.torch + expected_root_link_pos, expected_root_link_quat = combine_frame_transforms( + root_com_pose_w[:, :3], + root_com_pose_w[:, 3:], + quat_rotate(quat_inv(body_com_pose_b[:, 0, 3:7]), -body_com_pose_b[:, 0, :3]), + quat_inv(body_com_pose_b[:, 0, 3:7]), + ) + expected_root_link_pose = torch.cat((expected_root_link_pos, expected_root_link_quat), dim=1) + root_link_pose_w = cube_object.data.root_link_pose_w.torch + root_link_vel_w = cube_object.data.root_link_vel_w.torch + # test both root_pose and root_link successfully updated when root_com updates + torch.testing.assert_close(expected_root_link_pose, root_link_pose_w) + # skip lin_vel because it differs from link frame, this should be fine because we are only checking + # if velocity update is triggered, which can be determined by comparing angular velocity + torch.testing.assert_close(root_com_vel_w[:, 3:], root_link_vel_w[:, 3:]) + torch.testing.assert_close(expected_root_link_pose, root_link_pose_w) + torch.testing.assert_close(root_com_vel_w[:, 3:], cube_object.data.root_com_vel_w.torch[:, 3:]) + elif state_location == "link": + root_link_pose_w = cube_object.data.root_link_pose_w.torch + root_link_vel_w = cube_object.data.root_link_vel_w.torch + body_com_pose_b = cube_object.data.body_com_pose_b.torch + expected_com_pos, expected_com_quat = combine_frame_transforms( + root_link_pose_w[:, :3], + root_link_pose_w[:, 3:], + body_com_pose_b[:, 0, :3], + body_com_pose_b[:, 0, 3:7], + ) + expected_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1) + root_com_pose_w = cube_object.data.root_com_pose_w.torch + root_com_vel_w = cube_object.data.root_com_vel_w.torch + # test both root_pose and root_com successfully updated when root_link updates + torch.testing.assert_close(expected_com_pose, root_com_pose_w) + # skip lin_vel because it differs from link frame, this should be fine because we are only checking + # if velocity update is triggered, which can be determined by comparing angular velocity + torch.testing.assert_close(root_link_vel_w[:, 3:], root_com_vel_w[:, 3:]) + torch.testing.assert_close(root_link_pose_w, cube_object.data.root_link_pose_w.torch) + torch.testing.assert_close(root_link_vel_w[:, 3:], cube_object.data.root_com_vel_w.torch[:, 3:]) + elif state_location == "root": + root_link_pose_w = cube_object.data.root_link_pose_w.torch + root_com_vel_w = cube_object.data.root_com_vel_w.torch + body_com_pose_b = cube_object.data.body_com_pose_b.torch + expected_com_pos, expected_com_quat = combine_frame_transforms( + root_link_pose_w[:, :3], + root_link_pose_w[:, 3:], + body_com_pose_b[:, 0, :3], + body_com_pose_b[:, 0, 3:7], + ) + expected_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1) + root_com_pose_w = cube_object.data.root_com_pose_w.torch + root_link_vel_w = cube_object.data.root_link_vel_w.torch + # test both root_com and root_link successfully updated when root_pose updates + torch.testing.assert_close(expected_com_pose, root_com_pose_w) + torch.testing.assert_close(root_com_vel_w, cube_object.data.root_com_vel_w.torch) + torch.testing.assert_close(root_link_pose_w, cube_object.data.root_link_pose_w.torch) + torch.testing.assert_close(root_com_vel_w[:, 3:], root_link_vel_w[:, 3:]) + + +@pytest.mark.isaacsim_ci +def test_warmup_attach_stage_not_called_for_cpu(): + """Regression test: ``physx.warmup_gpu()`` must not be called for CPU. + + OVPhysX-equivalent of PhysX's ``test_warmup_attach_stage_not_called_for_cpu``: + PhysX guards :meth:`attach_stage` with ``if is_gpu:`` so the CPU MBP + broadphase is not double-initialised. The OVPhysX manager has the same + structural guard around :meth:`OvPhysxManager._physx.warmup_gpu`: it is + only invoked when ``ovphysx_device == "gpu"``. + + We monkey-patch ``OvPhysxManager._physx`` with a :class:`MagicMock` + wrapping the live PhysX object so that ``warmup_gpu`` becomes a spy while + other calls continue to forward, then assert ``warmup_gpu.call_count == 0`` + after a CPU-mode :meth:`sim.reset`. + + The test always runs CPU regardless of session parametrization, so it is + skipped when the session-locked device is anything other than CPU. The + skip is enforced inline (rather than in the autouse fixture) so the rest + of the suite can still pin to GPU when invoked together. + """ + if _LOCKED_DEVICE[0] not in (None, "cpu"): + pytest.skip( + f"ovphysx process-global device lock is held by '{_LOCKED_DEVICE[0]}'; cannot run " + "CPU-only regression test in the same session." + ) + _LOCKED_DEVICE[0] = "cpu" + + with _ovphysx_sim_context(device="cpu", add_ground_plane=True, dt=0.01, auto_add_lighting=True) as sim: + # Allocate a single rigid body so the manager has something to load. + generate_cubes_scene(num_cubes=1, height=1.0, device="cpu") + + # First reset constructs (or reuses) the real ovphysx.PhysX so we have + # a live instance to wrap. The PhysX object is a C++ binding, so we + # cannot patch attributes directly — replace the class-level reference + # with a MagicMock(wraps=...) that forwards every call. + sim.reset() + original_physx = OvPhysxManager._physx + assert original_physx is not None, "PhysX should be constructed after sim.reset()" + spy = MagicMock(wraps=original_physx) + OvPhysxManager._physx = spy + # Force _warmup_and_load to run again on the next reset so the spy + # observes the warmup_gpu (or non-call) decision; close() resets + # _warmup_done back to False but we just called sim.reset() above. + OvPhysxManager._warmup_done = False + try: + sim.reset() + finally: + OvPhysxManager._physx = original_physx + + assert spy.warmup_gpu.call_count == 0, ( + f"warmup_gpu() was called {spy.warmup_gpu.call_count} time(s) during CPU warmup. " + "OvPhysxManager._warmup_and_load() must guard warmup_gpu() with " + "ovphysx_device == 'gpu' so the CPU pipeline is not mis-initialised." + ) diff --git a/source/isaaclab_ovphysx/test/assets/test_rigid_object_helpers.py b/source/isaaclab_ovphysx/test/assets/test_rigid_object_helpers.py new file mode 100644 index 000000000000..e57d8d2651d7 --- /dev/null +++ b/source/isaaclab_ovphysx/test/assets/test_rigid_object_helpers.py @@ -0,0 +1,45 @@ +# 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 + +"""OVPhysX-only unit tests for rigid-object helpers. + +These tests cover OVPhysX-specific scaffolding (mock binding-set shape +contracts for ``asset_kind="rigid_object"``) that has no PhysX equivalent +and therefore does not appear in the PhysX-mirrored ``test_rigid_object.py``. +""" + +from __future__ import annotations + +import pytest +import warp as wp + +# The CI isaaclab_ov* pattern unintentionally collects isaaclab_ovphysx tests, +# but the ovphysx wheel is not installed in that environment. Skip gracefully +# so the isaaclab_ov CI pipeline is not blocked by an unrelated dependency. +pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") + +from isaaclab_ovphysx import tensor_types as TT # noqa: E402 +from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet # noqa: E402 + +wp.init() + + +def test_mock_binding_set_rigid_object_shapes(): + pytest.importorskip("isaaclab_ovphysx.tensor_types").RIGID_BODY_POSE # gates on wheel + + bindings = MockOvPhysxBindingSet( + num_instances=4, + num_joints=0, + num_bodies=1, + asset_kind="rigid_object", + ) + assert bindings.bindings[TT.RIGID_BODY_POSE].shape == (4, 7) + assert bindings.bindings[TT.RIGID_BODY_VELOCITY].shape == (4, 6) + assert bindings.bindings[TT.RIGID_BODY_WRENCH].shape == (4, 9) + assert bindings.bindings[TT.RIGID_BODY_MASS].shape == (4,) + assert bindings.bindings[TT.RIGID_BODY_INERTIA].shape == (4, 9) + # Articulation-only bindings must be absent + assert TT.DOF_POSITION not in bindings.bindings + assert TT.LINK_WRENCH not in bindings.bindings From 3ece85ba492b8a69eee4377e2b17f4008389f041 Mon Sep 17 00:00:00 2001 From: hujc Date: Wed, 13 May 2026 11:25:42 -0700 Subject: [PATCH 049/133] [Newton] Backend-agnostic task-space accessors for IK/OSC (#5400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. Summary Make IK / OSC / RMPFlow task-space controllers backend-agnostic so Franka manipulation envs run under Newton. The action terms previously called PhysX-only methods on `asset.root_view` directly, which crashed under Newton with `AttributeError`. The Franka reach envs worked around this by hardcoding `self.sim.physics = PhysxCfg(...)` with the comment *"{IK,OSC} control is not supported with Newton physics; use PhysX only"*. This PR removes that workaround. End-to-end result: `Isaac-Reach-Franka-{IK-Abs,IK-Rel,OSC}-v0` run on either backend, picked via `presets=newton`. ## 2. Design Four new properties on `BaseArticulationData`: - `body_link_jacobian_w` — geometric Jacobian, linear rows at the link origin (USD prim frame). What IK / OSC consumers want. - `body_com_jacobian_w` — geometric Jacobian, linear rows at the center of mass. Engine-natural form; useful for dynamics reasoning. - `mass_matrix` — joint-space generalized mass matrix `M(q)`. - `gravity_compensation_forces` — joint-space gravity-loading torques `g(q)`. Plus one metadata property on `BaseArticulation`: - `num_base_dofs` — number of free DoFs of the floating base. `0` for fixed-base, `6` for floating-base. Maps an actuated-joint id `j` to its column in J / M / g via `j + num_base_dofs`. All four data properties are concrete-with-`NotImplementedError` defaults; backends override what they support. ### 2.1 DoF axis: `num_joints + num_base_dofs` (industry-standard) The DoF axis prepends `num_base_dofs` columns at the front of the joint axis: `0` for fixed-base, `6` for floating-base. The 6 columns are the floating-base spatial velocity in world frame, ordered `[lin_x, lin_y, lin_z, ang_x, ang_y, ang_z]`. This matches every major rigid-body library: | Library | Floating-base layout | |---------|----------------------| | Pinocchio | Free-flyer joint contributes 6 to `nv`; reduced-coordinate `nv = 6 + n_actuated`. `pinocchio::computeJointJacobians` writes `(6, nv)`. | | Drake | `MultibodyPlant` ephemeral floating joint; `MultibodyPlant::CalcJacobianSpatialVelocity` returns `(6, nv)` with leading 6 free-floating cols. | | MuJoCo | `` introduces 6 DoFs at the front of `qvel`; `mj_jac*` returns `(_, nv)`. | | RBDL | `JointTypeFloatingBase` adds 6 to `dof_count`; `CalcPointJacobian` returns `(_, dof_count)`. | | OCS2 | `generalizedCoordinatesNum = 6 + actuatedJointsNum` for floating-base robots. | | iDynTree | `getFreeFloatingMassMatrix` returns `(6 + dofs, 6 + dofs)`. | Consumers operating in actuated-joint space (action terms keyed by `joint_ids`) compute `[j + asset.num_base_dofs for j in joint_ids]` once at init — same application-level reduction as every surveyed library. ### 2.2 Output shapes by backend Concrete shapes for each engine output and the wrapper transform applied. `N` = num_instances, `B` = num_bodies (full count incl. root), `J` = num_joints (actuated only). "Default" is the engine-native per-view shape (post-gather). "passthrough" = wrapper applies no shape change. The COM→origin shift is values-only (does not change shape). | Property | Base | Newton (default ⟶ transform) | PhysX (default ⟶ transform) | Aligned | |---|---|---|---|---| | `body_link_jacobian_w` | fixed | `(N, B, 6, J)` ⟶ drop fixed-root row + COM→origin shift | `(N, B−1, 6, J)` ⟶ COM→origin shift | `(N, B−1, 6, J)` | | `body_link_jacobian_w` | floating | `(N, B, 6, J+6)` ⟶ COM→origin shift | `(N, B, 6, J+6)` ⟶ COM→origin shift | `(N, B, 6, J+6)` | | `body_com_jacobian_w` | fixed | `(N, B, 6, J)` ⟶ drop fixed-root row | `(N, B−1, 6, J)` ⟶ passthrough | `(N, B−1, 6, J)` | | `body_com_jacobian_w` | floating | `(N, B, 6, J+6)` ⟶ passthrough | `(N, B, 6, J+6)` ⟶ passthrough | `(N, B, 6, J+6)` | | `mass_matrix` | fixed | `(N, J, J)` ⟶ passthrough | `(N, J, J)` ⟶ passthrough | `(N, J, J)` | | `mass_matrix` | floating | `(N, J+6, J+6)` ⟶ passthrough | `(N, J+6, J+6)` ⟶ passthrough | `(N, J+6, J+6)` | | `gravity_compensation_forces` | fixed | (no upstream primitive) ⟶ `NotImplementedError` | `(N, J)` ⟶ passthrough | `(N, J)` | | `gravity_compensation_forces` | floating | (no upstream primitive) ⟶ `NotImplementedError` | `(N, J+6)` ⟶ passthrough | `(N, J+6)` | The aligned column collapses across base type using two derived symbols: - `num_base_dofs` = `0` fixed | `6` floating — exposed as `BaseArticulation.num_base_dofs` - `num_jacobi_bodies` = `B − 1` fixed | `B` floating — fixed-root row excluded for fixed-base | Property | Aligned (generalized) | |---|---| | `body_link_jacobian_w`, `body_com_jacobian_w` | `(N, num_jacobi_bodies, 6, J + num_base_dofs)` | | `mass_matrix` | `(N, J + num_base_dofs, J + num_base_dofs)` | | `gravity_compensation_forces` | `(N, J + num_base_dofs)` | Two observations: 1. The only body-axis asymmetry is **Newton's fixed-base Jacobian**: Newton's `eval_jacobian` includes a zero row for the fixed-root joint that the wrapper drops. PhysX's engine already drops it. 2. The DoF axis is in the industry-standard `J + num_base_dofs` form on both engines natively. The only DoF-axis asymmetry is `gravity_compensation_forces` (Newton NIE pending upstream primitive). ### 2.3 Why on `BaseArticulationData`, not `BaseArticulation` `BaseArticulationData` already exposes per-body / per-joint state as cached lazy `@property` accessors with `TimestampedBuffer` invalidation (`body_link_pose_w`, `joint_pos`, etc.). The four new accessors fit the same shape — read-only state indexed by body / joint, refreshed per sim step — so they reuse that infrastructure and let consumer code stay symmetric: ```python # IK action term ee_pose = articulation.data.body_link_pose_w.torch[:, ee_idx] ee_jac = articulation.data.body_link_jacobian_w.torch[:, ee_jac_idx] # same prefix ``` ## 3. Fixed: latent PhysX IK / OSC frame mismatch PhysX's `_root_view.get_jacobians()` returns linear rows referenced at each body's **center of mass**, not the link origin. Undocumented behavior — verified empirically by bypassing the IsaacLab wrapper and confirming `J · q̇` matches `body_com_lin_vel_w` to 1e-8 and differs from `body_link_lin_vel_w` by exactly `ω × r_com_world` (the rigid-body shift). The PhysX data layer already encoded this convention for velocities: `body_com_vel_w` is the raw passthrough of `_root_view.get_link_velocities()`; `body_link_vel_w` is **derived** via a shift kernel. Before this PR, IK / OSC / RMPFlow action terms on PhysX consumed `_root_view.get_jacobians()` directly while using `data.body_link_pose_w` as the EE pose setpoint. The frame mismatch contributes `ω × r_com_world` per body to the linear-row contract — undetected in CI because no existing test compared `J · q̇` against `body_link_lin_vel_w` directly. The new contract test `test_get_jacobians_link_origin_contract` parametrized to `anymal` catches it explicitly: 0.32 m/s residual on PhysX without the fix. After this PR, PhysX's `body_link_jacobian_w` applies the same COM→origin shift kernel that Newton uses, mirroring the existing `body_link_vel_w` derivation. Both backends now satisfy `body_link_jacobian_w · q̇ == body_link_vel_w` to numerical precision (test tolerance 5e-3 absolute, 1e-2 relative). The COM-referenced sibling `body_com_jacobian_w` is exposed for callers that intentionally want the engine-native form (e.g. dynamics-side reasoning at the COM). Also: the three PhysX passthrough properties (`body_com_jacobian_w`, `mass_matrix`, `gravity_compensation_forces`) now pin a single `ProxyArray` in `_create_buffers` instead of allocating one per property read. PhysX tensor-view getters return pointer-stable buffers for the articulation's lifetime — verified manually across `sim.step`, a manual joint write, and `sim.reset`. ## 4. Newton-side details ### 4.1 Pre-allocation for capture safety Dynamics-scratch allocation is grouped in a private `ArticulationData._create_jacobian_buffers(model)` helper called from `_create_buffers`. The helper is sectioned by which property each buffer feeds, and names reflect each buffer's physical role: - **Shared scratch** (eval_jacobian output, reused as eval_mass_matrix's `J` input to skip a re-compute): `_jacobian_buf_flat`, `_joint_S_s_buf` (Featherstone motion subspace — shared between the two evals, hence the property-prefix-free name). - **Per-view gather config**: `_jacobian_link_offset` (fixed-base row-0 skip), `_jacobian_view_art_ids` (flattened view-to-model index map). - **`body_com_jacobian_w`**: `_jacobian_buf` (zero-copy 4-D view of `_jacobian_buf_flat`, kernel input) and `_body_com_jacobian_w_buf` (gather output, per-view). - **`body_link_jacobian_w`**: `_body_link_jacobian_w_buf` (Center-Of-Mass-to-link-origin shift kernel output, per-view). - **`mass_matrix`**: `_mass_matrix_full_buf` (model-wide `H` scratch — Composite Rigid Body Algorithm (CRBA) output), `_mass_matrix_body_I_s_buf` (Featherstone per-body spatial inertia aux, CRBA-only), `_mass_matrix_buf` (gather output, per-view). Properties are allocation-free at step time. The kernel-launch sequence runs against fixed buffer pointers, which is what makes the per-step path safe under CUDA-graph capture. ### 4.2 View-level row gather Newton's `eval_jacobian` / `eval_mass_matrix` write every articulation in the model into a single buffer (shape includes `model.articulation_count`), regardless of which `ArticulationView` invoked them. PhysX returns view-scoped data already. Two Warp kernels (`gather_jacobian_rows` 4-D, `gather_mass_matrix_rows` 3-D) gather just this view's rows into a contiguous view-sized destination so the caller-facing shape contract matches PhysX. The view-to-model index map is reused across both gathers. ### 4.3 `eval_mass_matrix` "J as input" gotcha Newton's `eval_mass_matrix(state, H, J=None, body_I_s=None, joint_S_s=None)` treats `J` as **input** when provided — it skips the internal `eval_jacobian` and uses the buffer as-is. Passing an empty pre-allocated `J` produces `H = J^T·M·J = 0` → singular → `LinAlgError` in OSC's `torch.inverse(mass_matrix)`. The wrapper explicitly populates `J` by calling `eval_jacobian` first; we reuse `_jacobian_buf_flat` (same shape) so no separate scratch is needed. ### 4.4 COM→origin shift on `body_link_jacobian_w` Newton's `eval_jacobian` writes linear-velocity rows at each link's **center of mass**. After `gather_jacobian_rows`, the `shift_jacobian_com_to_origin` Warp kernel applies `v_origin = v_com - ω × (R · body_com_pos_b)` per `(env, body, dof)` thread, writing to `_body_link_jacobian_w_buf`. The COM-referenced source buffer is reused as-is for `body_com_jacobian_w` and `mass_matrix`. ### 4.5 FK staleness `eval_jacobian` and `eval_mass_matrix` read `state.body_q` (per-body world transforms). After a manual `write_joint_position_to_sim_*` (no sim step), `state.joint_q` is updated but `state.body_q` is stale until `eval_fk` runs. The new properties match the existing `body_link_pose_w` convention — refresh FK lazily via `_ensure_fk_fresh()` (Python-guarded `SimulationManager.forward()`) before invoking the eval kernels. PhysX has its own internal refresh on the equivalent getters (verified empirically by the new manual-write tests passing on PhysX without an explicit trigger). ## 5. Action-term gating `OperationalSpaceControllerAction._compute_dynamic_quantities` previously fetched mass matrix and gravity-compensation forces unconditionally on every step. Under the new abstraction this would still call Newton's gravity-comp stub even when the user disabled gravity compensation in the controller config. The fetches are now gated to match what the controller actually consumes: - Mass matrix fetched when `inertial_dynamics_decoupling=True` **or** `nullspace_control != "none"` (the null-space torque term in `OperationalSpaceController.compute()` consumes mass matrix independently of inertial decoupling). - Gravity comp fetched only when `gravity_compensation=True`. Side benefit: skips a per-step engine call on PhysX when neither flag is set. ## 6. Known limitation: gravity compensation on Newton Newton's `ArticulationView` has no gravity-compensation primitive (only `eval_fk` / `eval_jacobian` / `eval_mass_matrix`). `gravity_compensation_forces` raises `NotImplementedError` on Newton; OSC users on Newton must set `gravity_compensation=False` until upstream lands the primitive (newton-physics/newton#2497, #2529, #2625). The strict-xfail test `test_get_gravity_compensation_forces_not_implemented_on_newton` flips to XPASS when that happens, signaling the maintainer to remove the wrapper stub and OSC guidance. ## 7. Reach-env cfg cleanup Removed the `self.sim.physics = PhysxCfg(bounce_threshold_velocity=0.2)` override from `Isaac-Reach-Franka-{IK-Abs,IK-Rel,OSC}-v0`. Tasks now inherit the parent `ReachPhysicsCfg` preset, so `presets=newton` selects `NewtonCfg` and `presets=physx` (the default) keeps the previous behavior — same `bounce_threshold_velocity=0.2` lives on `ReachPhysicsCfg.default = PhysxCfg(bounce_threshold_velocity=0.2)`. No information lost. ## 8. Test plan ### 8.1 Existing controller tests migrated - [x] `test_differential_ik.py` migrated to `robot.data.body_link_jacobian_w.torch`. Passes on PhysX (2/2). - [x] `test_operational_space.py` migrated to `robot.data.body_link_jacobian_w.torch`, `robot.data.mass_matrix.torch`, `robot.data.gravity_compensation_forces.torch`. PhysX (12/18 — the 6 failures are pre-existing `ContactSensor` env issues unrelated to this PR; affected tests fail on `omni.physics.tensors.api` import which is independent of the bridge). - [x] `test_floating_base_osc_action_term_indexing` cfg unchanged. ### 8.2 New tests in this PR **Shape contracts** — lock the public DoF / body axes: | Test | Backend | Purpose | Result | |------|---------|---------|--------| | `test_get_jacobians_shape_fixed_base` | PhysX + Newton | `body_link_jacobian_w` drops the fixed-root row → `(N, B−1, 6, J)`. | PASSES | | `test_get_jacobians_shape_floating_base` | PhysX + Newton | Floating base keeps root row + prepends 6 base cols → `(N, B, 6, J+6)`. | PASSES | | `test_get_mass_matrix_shape_and_nonsingular_fixed_base` | PhysX + Newton | `(N, J, J)` + strictly positive diagonal (catches the model-wide-padding bug that masks heterogeneous-scene mismatch as a singular matrix). | PASSES | | `test_get_mass_matrix_shape_floating_base` | Newton | `(N, J+6, J+6)` — floating-base includes the 6 free-root DoFs. | PASSES | | `test_heterogeneous_scene_per_view_shapes` | Newton | Mixed Franka+Anymal scene: each view returns its OWN asset shape, not `model.max_*`. Direct regression test for the heterogeneous-padding bug. | PASSES | **Math / physics contracts** — values, not just shapes: | Test | Backend | Purpose | Result | |------|---------|---------|--------| | `test_get_jacobians_link_origin_contract[panda \| anymal]` | PhysX | `J · q̇ == body_link_lin_vel_w` identity (sharp J reference-point check). Anymal exercise catches the latent COM/link mismatch this PR fixes. | PASSES | | `test_get_jacobians_link_origin_contract[panda \| anymal]` | Newton | Same identity, ground truth from `state.body_qd` minus the COM offset shift. | PASSES | | `test_get_mass_matrix_symmetry_pd[panda \| anymal]` | PhysX + Newton | `M(q)` is square, symmetric, positive-definite. | PASSES (4/4) | **Freshness contracts** — Forward Kinematics (FK) refresh after manual writes: | Test | Backend | Purpose | Result | |------|---------|---------|--------| | `test_jacobian_refreshes_after_manual_joint_write[panda \| anymal]` | PhysX + Newton | After `write_joint_position_to_sim_index` (no sim step), reading the Jacobian reflects the new joint state. Locks in the FK-staleness contract on the manual-write code path. | PASSES (4/4) | | `test_mass_matrix_refreshes_after_manual_joint_write[panda \| anymal]` | PhysX + Newton | Same contract for mass matrix. | PASSES (4/4) | **Gravity compensation** — accessor + Operational Space Control (OSC) integration + negative control: | Test | Backend | Purpose | Result | |------|---------|---------|--------| | `test_get_gravity_compensation_forces_static_equilibrium` | PhysX | Apply only `τ_gc` to a non-trivial Franka pose; assert it stays static. Pins the accessor in isolation, no controller masking. | PASSES | | `test_franka_osc_gravity_compensation_holds_under_gravity` | PhysX | OSC + `gravity=g(q)` under scene gravity holds the EE pose. Pins (a) `_jacobi_joint_idx + num_base_dofs` indexing, (b) `OSC.compute(gravity=...)` torque math, (c) reachability through the action-term pipeline. | PASSES | | `test_franka_osc_no_gravity_compensation_sags_under_gravity` | PhysX | Negative control — OSC **without** gravity comp DOES drift under gravity. Proves the with-comp test isn't passing because `g(q)=0`. | PASSES | | `test_get_gravity_compensation_forces_not_implemented_on_newton` | Newton | Strict-xfail pin for the upstream Newton gap. Flips to XPASS when upstream lands the primitive. | XFAIL (correct) | **End-to-end Inverse Kinematics (IK) and OSC accuracy** — production sentinels: | Test | Backend | Purpose | Result | |------|---------|---------|--------| | `test_franka_ik_tracking_accuracy` | PhysX + Newton | Damped-Least-Squares IK convergence sentinel via the new accessors. | PASSES (PhysX 0.01 mm, Newton 0.00 mm) | | `test_franka_osc_tracking_accuracy` | PhysX + Newton | OSC convergence sentinel via Jacobian + mass matrix. | PASSES (both at machine precision) | Latest CI: `isaaclab_physx` and `isaaclab_newton` both green on the most recent push. ### 8.3 Determinism and stability The accuracy sentinels are bit-for-bit deterministic on both backends at the 5 cm short-target setup: 20× consecutive runs each give the same `pos_mean` to 5 decimal places. Both tests assert on tail mean rather than tail min — the latter is the bottom of any oscillation envelope and can pass spuriously while the actual tracking error is much larger. Tight regression sentinels rather than flaky bounds. No `pytest-rerunfailures` retry decoration — a CI failure should be a real regression, not noise to retry away. ### 8.4 Smoke runs - [x] `random_agent` on `Isaac-Reach-Franka-IK-Abs-v0` under Newton: 5,205 physics substeps zero-error. - [x] `random_agent` on `Isaac-Reach-Franka-IK-Rel-v0` under Newton: 306 substeps zero-error. - [x] `random_agent` on `Isaac-Reach-Franka-OSC-v0` under Newton: 290 substeps zero-error. ### 8.5 Test-setup notes The accuracy tests need two fixes that the standalone path doesn't get for free (production envs do): - Teleport to `init_state.joint_pos` post-`sim.reset()`. Without it, the robot sits at the URDF-neutral pose where Franka's wrist axes nearly align — rank-deficient Jacobian, multi-cm DLS plateau. - Override `sim_cfg.gravity = (0, 0, 0)` in the Newton `sim` fixture (`build_simulation_context(gravity_enabled=False)` is silently ignored when an explicit `sim_cfg` is passed). The OSC test additionally zeros actuator PD gains so OSC's joint-effort output isn't opposed by `kp·(target − q)` (same way OSC is wired in production action terms). With these in place, Newton hits machine precision and PhysX hits ~10 µm. Newton's PD does have a real `g_torque/kp` gravity-sag that PhysX's TGS masks via constraint projection — surfaces only when gravity is on without gravity compensation, which is the upstream gap in § 6. ## 9. Files Touched five extensions; per-package changelog fragments under `source//changelog.d/jichuanh-ik-newton-compat-mvp*.rst`: - `isaaclab` (minor): four new properties on `BaseArticulationData`; `num_base_dofs` on `BaseArticulation`; controller-action gating + migration to data-layer accessors; doc updates. - `isaaclab_physx`: data-layer impls + `shift_jacobian_com_to_origin` kernel; docs the latent frame-mismatch fix. - `isaaclab_newton` (minor): data-layer impls (eval + gather + shift); model-sized scratch and view-sized output buffers migrated from articulation to data layer; gravity-comp NIE stub. - `isaaclab_ovphysx`: bridge methods removed (inherits NIE). - `isaaclab_tasks`: hardcoded `PhysxCfg` removals + direct-workflow caller migrations to data-layer accessors. --------- Signed-off-by: aravind s kumar Co-authored-by: aravind s kumar Co-authored-by: Kelly Guo --- scripts/demos/haply_teleoperation.py | 10 +- .../tutorials/05_controllers/run_diff_ik.py | 7 +- scripts/tutorials/05_controllers/run_osc.py | 10 +- .../jichuanh-ik-newton-compat-mvp.minor.rst | 60 ++ .../assets/articulation/base_articulation.py | 13 + .../articulation/base_articulation_data.py | 77 ++ .../mdp/actions/pink_task_space_actions.py | 30 +- .../mdp/actions/rmpflow_task_space_actions.py | 14 +- .../envs/mdp/actions/task_space_actions.py | 79 +- .../test/assets/test_articulation_iface.py | 8 + .../test/controllers/test_differential_ik.py | 3 +- .../controllers/test_operational_space.py | 42 +- .../jichuanh-ik-newton-compat-mvp.minor.rst | 40 + .../assets/articulation/articulation_data.py | 231 +++++- .../assets/articulation/kernels.py | 139 ++++ .../test/assets/test_articulation.py | 753 +++++++++++++++++- .../jichuanh-ik-newton-compat-mvp.rst | 12 + .../jichuanh-ik-newton-compat-mvp.rst | 31 + .../assets/articulation/articulation.py | 16 + .../assets/articulation/articulation_data.py | 121 +++ .../assets/articulation/kernels.py | 62 ++ .../test/assets/test_articulation.py | 742 ++++++++++++++++- .../jichuanh-ik-newton-compat-mvp.rst | 13 + .../direct/automate/assembly_env.py | 4 +- .../direct/automate/disassembly_env.py | 4 +- .../direct/factory/factory_env.py | 5 +- .../manipulation/deploy/mdp/events.py | 9 +- .../reach/config/franka/ik_abs_env_cfg.py | 5 - .../reach/config/franka/ik_rel_env_cfg.py | 5 - .../reach/config/franka/osc_env_cfg.py | 5 - 30 files changed, 2418 insertions(+), 132 deletions(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst create mode 100644 source/isaaclab_newton/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/jichuanh-ik-newton-compat-mvp.rst create mode 100644 source/isaaclab_physx/changelog.d/jichuanh-ik-newton-compat-mvp.rst create mode 100644 source/isaaclab_tasks/changelog.d/jichuanh-ik-newton-compat-mvp.rst diff --git a/scripts/demos/haply_teleoperation.py b/scripts/demos/haply_teleoperation.py index a1c5a7cec677..0f24ebd4d759 100644 --- a/scripts/demos/haply_teleoperation.py +++ b/scripts/demos/haply_teleoperation.py @@ -191,6 +191,9 @@ def run_simulator( ee_body_name = "panda_hand" ee_body_idx = robot.body_names.index(ee_body_name) + # ``body_link_jacobian_w`` drops the fixed-root body row for fixed-base assets, + # so the Jacobian-axis body index is ``body_idx - 1`` in that case. + ee_jacobi_body_idx = ee_body_idx - 1 if robot.is_fixed_base else ee_body_idx joint_pos = robot.data.default_joint_pos.torch.clone() joint_pos[0, :7] = torch.tensor([0.0, -0.569, 0.0, -2.81, 0.0, 3.037, 0.741], device=robot.device) @@ -307,8 +310,11 @@ def run_simulator( ee_pos_w = robot.data.body_pos_w.torch[:, ee_body_idx] ee_quat_w = robot.data.body_quat_w.torch[:, ee_body_idx] - # get jacobian to IK controller - jacobian = robot.root_view.get_jacobians()[:, ee_body_idx, :, arm_joint_indices] + # get jacobian to IK controller. The DoF axis prepends ``num_base_dofs`` + # floating-base columns (0 for fixed-base, 6 for floating-base); shift the + # actuated-joint ids by ``num_base_dofs`` to address the actuated columns. + jacobi_joint_ids = [j + robot.num_base_dofs for j in arm_joint_indices] + jacobian = robot.data.body_link_jacobian_w.torch[:, ee_jacobi_body_idx, :, jacobi_joint_ids] ik_controller.set_command(command=target_pos_tensor, ee_quat=ee_quat_w) joint_pos_des = ik_controller.compute(ee_pos_w, ee_quat_w, jacobian, current_joint_pos) diff --git a/scripts/tutorials/05_controllers/run_diff_ik.py b/scripts/tutorials/05_controllers/run_diff_ik.py index 2922265e7b9b..a4e88b9d215e 100644 --- a/scripts/tutorials/05_controllers/run_diff_ik.py +++ b/scripts/tutorials/05_controllers/run_diff_ik.py @@ -159,8 +159,11 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): # change goal current_goal_idx = (current_goal_idx + 1) % len(ee_goals) else: - # obtain quantities from simulation - jacobian = robot.root_view.get_jacobians()[:, ee_jacobi_idx, :, robot_entity_cfg.joint_ids] + # obtain quantities from simulation. The Jacobian DoF axis prepends + # ``num_base_dofs`` floating-base columns (0 for fixed-base, 6 for + # floating-base); shift the actuated-joint ids accordingly. + jacobi_joint_ids = [j + robot.num_base_dofs for j in robot_entity_cfg.joint_ids] + jacobian = robot.data.body_link_jacobian_w.torch[:, ee_jacobi_idx, :, jacobi_joint_ids] ee_pose_w = robot.data.body_pose_w.torch[:, robot_entity_cfg.body_ids[0]] root_pose_w = robot.data.root_pose_w.torch joint_pos = robot.data.joint_pos.torch[:, robot_entity_cfg.joint_ids] diff --git a/scripts/tutorials/05_controllers/run_osc.py b/scripts/tutorials/05_controllers/run_osc.py index 09e16c40c70e..362efd5f5ee4 100644 --- a/scripts/tutorials/05_controllers/run_osc.py +++ b/scripts/tutorials/05_controllers/run_osc.py @@ -314,9 +314,13 @@ def update_states( """ # obtain dynamics related quantities from simulation ee_jacobi_idx = ee_frame_idx - 1 - jacobian_w = robot.root_view.get_jacobians()[:, ee_jacobi_idx, :, arm_joint_ids] - mass_matrix = robot.root_view.get_generalized_mass_matrices()[:, arm_joint_ids, :][:, :, arm_joint_ids] - gravity = robot.root_view.get_gravity_compensation_forces()[:, arm_joint_ids] + # The J / M / g DoF axis prepends ``num_base_dofs`` floating-base columns + # (0 for fixed-base, 6 for floating-base); shift the actuated-joint ids by + # ``num_base_dofs`` to address the actuated-joint columns directly. + jacobi_joint_ids = [j + robot.num_base_dofs for j in arm_joint_ids] + jacobian_w = robot.data.body_link_jacobian_w.torch[:, ee_jacobi_idx, :, jacobi_joint_ids] + mass_matrix = robot.data.mass_matrix.torch[:, jacobi_joint_ids, :][:, :, jacobi_joint_ids] + gravity = robot.data.gravity_compensation_forces.torch[:, jacobi_joint_ids] # Convert the Jacobian from world to root frame jacobian_b = jacobian_w.clone() root_rot_matrix = matrix_from_quat(quat_inv(robot.data.root_quat_w.torch)) diff --git a/source/isaaclab/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst b/source/isaaclab/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst new file mode 100644 index 000000000000..b68d62e6b744 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst @@ -0,0 +1,60 @@ +Added +^^^^^ + +* Added :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` and + :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w` properties, + exposing the per-body geometric Jacobian referenced at the link origin and + body center of mass respectively. The pair mirrors the existing + :attr:`~isaaclab.assets.BaseArticulationData.body_link_pose_w` / + :attr:`~isaaclab.assets.BaseArticulationData.body_com_pose_w` and + :attr:`~isaaclab.assets.BaseArticulationData.body_link_vel_w` / + :attr:`~isaaclab.assets.BaseArticulationData.body_com_vel_w` exposure pattern. + Backends without a native primitive raise :class:`NotImplementedError`. +* Added :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix` property, + exposing the joint-space generalized mass matrix ``M(q)``. +* Added :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` + property, exposing the joint-space gravity-loading torque vector ``g(q)``. +* Added :attr:`~isaaclab.assets.BaseArticulation.num_base_dofs` — number of + free DoFs of the floating base (``0`` for fixed-base, ``6`` for floating- + base). Use it to map an actuated-joint index ``j`` to its column in the + Jacobian / mass matrix / gravity vector via ``j + num_base_dofs``. + +The Jacobian / mass-matrix / gravity-comp DoF axis includes the floating- +base DoFs at the front: shape ``(N, num_jacobi_bodies, 6, num_joints + +num_base_dofs)`` for the Jacobian and ``(N, num_joints + num_base_dofs, +num_joints + num_base_dofs)`` for the mass matrix. This matches the +cross-library industry convention (Pinocchio's ``nv = 6 + n_actuated``, +Drake's ephemeral floating joint, MuJoCo's ````, RBDL's +``JointTypeFloatingBase``, OCS2's ``generalizedCoordinatesNum = +6 + actuatedJointsNum``, iDynTree's ``getFreeFloatingMassMatrix`` +returning ``(6 + dofs, 6 + dofs)``). + +Changed +^^^^^^^ + +* Migrated :class:`~isaaclab.envs.mdp.actions.task_space_actions.DifferentialInverseKinematicsAction`, + :class:`~isaaclab.envs.mdp.actions.task_space_actions.OperationalSpaceControllerAction`, + and :class:`~isaaclab.envs.mdp.actions.rmpflow_task_space_actions.RMPFlowAction` + to fetch dynamic quantities through the new + :class:`~isaaclab.assets.BaseArticulationData` properties instead of the + PhysX-only ``root_view``. The OSC action term now also gates the + per-step mass-matrix and gravity-compensation fetches behind the + controller cfg's :attr:`inertial_dynamics_decoupling`, + :attr:`nullspace_control`, and :attr:`gravity_compensation` flags + so backends without a native primitive are not invoked when the + controller does not consume the result. +* Action terms (DiffIK / OSC / RMPFlow / Pink) compute their Jacobian + joint-axis indices via + ``[j + asset.num_base_dofs for j in joint_ids]``, which is ``0`` for + fixed-base and ``+6`` for floating-base. Pink IK previously hardcoded + a private ``_physx_floating_joint_indices_offset = 6``; that was + removed in favor of the cross-backend property. +* PhysX backend's :attr:`body_link_jacobian_w` applies the COM→origin shift to + PhysX's natively COM-referenced Jacobian. The previously-exposed + ``Articulation.get_jacobians()`` was a passthrough that returned the raw + COM-referenced Jacobian, while IK / OSC consumers also read + :attr:`body_link_pose_w` as the EE pose setpoint — a frame mismatch that + produced a ``ω × r_com_w`` per-body bias in tracking. The new property + reads the same engine buffer and applies the shift so ``J · q_dot`` matches + ``body_link_lin_vel_w``. Consumers that intentionally want the raw + COM-referenced form can read :attr:`body_com_jacobian_w`. diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py index dc9ddc6cb7ad..ff3f99e8a024 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py @@ -174,6 +174,19 @@ def root_view(self): """ raise NotImplementedError() + @property + def num_base_dofs(self) -> int: + """Number of free DoFs of the floating base. + + A floating-base articulation can translate and rotate freely in space, so + its base contributes 6 DoFs (3 linear, 3 angular). A fixed-base articulation + is bolted to the world and contributes 0. + + Use this to map an actuated-joint index ``j`` to its column in the Jacobian + / mass matrix / gravity vector: ``column = j + num_base_dofs``. + """ + return 0 if self.is_fixed_base else 6 + @property @abstractmethod def instantaneous_wrench_composer(self) -> WrenchComposer: diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py index b902db7d29bb..894bb6bd8f44 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py @@ -669,6 +669,83 @@ def body_com_pose_b(self) -> ProxyArray: """ raise NotImplementedError + ## + # Dynamics quantities (task-space controllers). + ## + + @property + def body_link_jacobian_w(self) -> ProxyArray: + """Per-body geometric Jacobian referenced at each body's link origin in world frame. + + Shape: ``(num_instances, num_jacobi_bodies, 6, num_joints + num_base_dofs)``, + dtype ``wp.float32``. Linear rows ``[0:3]`` [m/s per unit DoF velocity]; + angular rows ``[3:6]`` [rad/s per unit DoF velocity]. + + Contract: for any generalized velocity ``v`` of length + ``num_joints + num_base_dofs``, + + .. code-block:: text + + J[:, jacobi_body_idx, 0:3, :] @ v == body_link_lin_vel_w[:, body_idx] + J[:, jacobi_body_idx, 3:6, :] @ v == body_link_ang_vel_w[:, body_idx] + + Conventions: + * Body axis: ``jacobi_body_idx == body_idx - 1`` for fixed-base (fixed-root + row excluded); ``jacobi_body_idx == body_idx`` for floating-base. + * DoF axis: leading + :attr:`~isaaclab.assets.BaseArticulation.num_base_dofs` floating-base + columns (world-frame ``[lin_x, lin_y, lin_z, ang_x, ang_y, ang_z]``), + then actuated-joint columns in :attr:`joint_names` order. + """ + raise NotImplementedError(f"{type(self).__name__} does not implement body_link_jacobian_w.") + + @property + def body_com_jacobian_w(self) -> ProxyArray: + """Per-body geometric Jacobian referenced at each body's center of mass in world frame. + + Same shape and indexing conventions as :attr:`body_link_jacobian_w`. Linear + rows ``[0:3]`` give the velocity at the body's center of mass; angular rows + ``[3:6]`` are reference-point invariant (identical to + :attr:`body_link_jacobian_w`). + + Contract: for any generalized velocity ``v``, + + .. code-block:: text + + J[:, jacobi_body_idx, 0:3, :] @ v == body_com_lin_vel_w[:, body_idx] + J[:, jacobi_body_idx, 3:6, :] @ v == body_com_ang_vel_w[:, body_idx] + """ + raise NotImplementedError(f"{type(self).__name__} does not implement body_com_jacobian_w.") + + @property + def mass_matrix(self) -> ProxyArray: + """Per-env generalized mass matrix ``M(q)`` in joint space. + + Shape: ``(num_instances, num_joints + num_base_dofs, num_joints + num_base_dofs)``, + dtype ``wp.float32`` [kg·m² or kg, per DoF type]. DoF-axis convention matches + :attr:`body_link_jacobian_w`. + + ``M(q)`` is symmetric positive-definite. ``M[i, j]`` is the coefficient + relating DoF ``j``'s acceleration to the inertial torque on DoF ``i`` in + ``M(q) q_ddot + C(q, q_dot) q_dot + g(q) = tau``. + """ + raise NotImplementedError(f"{type(self).__name__} does not implement mass_matrix.") + + @property + def gravity_compensation_forces(self) -> ProxyArray: + """Per-env gravity compensation torques ``g(q)`` in joint space. + + Shape: ``(num_instances, num_joints + num_base_dofs)``, dtype ``wp.float32`` + [N·m or N, per DoF type]. DoF-axis convention matches + :attr:`body_link_jacobian_w`. + + ``g(q)`` is the gravity-loading term in + ``M(q) q_ddot + C(q, q_dot) q_dot + g(q) = tau``. Applying ``tau = g(q)`` at + ``q_dot = 0`` with no external load yields ``q_ddot = 0`` (static equilibrium + under gravity). + """ + raise NotImplementedError(f"{type(self).__name__} does not implement gravity_compensation_forces.") + ## # Joint state properties. ## diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py index f826c80d51eb..1612c6621c71 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING import torch -import warp as wp from pink.tasks import FrameTask import isaaclab.utils.math as math_utils @@ -60,9 +59,6 @@ def __init__(self, cfg: pink_actions_cfg.PinkInverseKinematicsActionCfg, env: Ma self._raw_actions = torch.zeros(self.num_envs, self.action_dim, device=self.device) self._processed_actions = torch.zeros_like(self._raw_actions) - # PhysX Articulation Floating joint indices offset from IsaacLab Articulation joint indices - self._physx_floating_joint_indices_offset = 6 - # Pre-allocate tensors for runtime use self._initialize_helper_tensors() @@ -324,20 +320,24 @@ def apply_actions(self) -> None: ) def _apply_gravity_compensation(self) -> None: - """Apply gravity compensation to arm joints if not disabled in props.""" + """Apply gravity compensation to arm joints if not disabled in props. + + Reads :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces`, + which raises :class:`NotImplementedError` on the Newton backend (no upstream + primitive). That is intentional — if a user opts into gravity compensation on + Newton via ``enable_gravity_compensation=True``, they should see a loud failure + rather than silently receive zeros. To use Pink IK on Newton, keep + ``enable_gravity_compensation=False``. + """ if not self._asset.cfg.spawn.rigid_props.disable_gravity: - # Get gravity compensation forces using cached tensor + # ``gravity_compensation_forces`` shape is ``(N, num_joints + num_base_dofs)``. + # Shift actuated-joint ids by ``num_base_dofs`` to skip the leading floating- + # base columns (0 for fixed-base, 6 for floating-base). + jacobi_ids = self._controlled_joint_ids_tensor + self._asset.num_base_dofs if self._asset.is_fixed_base: - gravity = torch.zeros_like( - wp.to_torch(self._asset.root_view.get_gravity_compensation_forces())[ - :, self._controlled_joint_ids_tensor - ] - ) + gravity = torch.zeros_like(self._asset.data.gravity_compensation_forces.torch[:, jacobi_ids]) else: - # If floating base, then need to skip the first 6 joints (base) - gravity = wp.to_torch(self._asset.root_view.get_gravity_compensation_forces())[ - :, self._controlled_joint_ids_tensor + self._physx_floating_joint_indices_offset - ] + gravity = self._asset.data.gravity_compensation_forces.torch[:, jacobi_ids] # Apply gravity compensation to arm joints self._asset.set_joint_effort_target_index(target=gravity, joint_ids=self._controlled_joint_ids) diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_task_space_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_task_space_actions.py index 922db073a300..6cccf308e1d5 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_task_space_actions.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_task_space_actions.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING import torch -import warp as wp import isaaclab.utils.math as math_utils import isaaclab.utils.string as string_utils @@ -56,15 +55,8 @@ def __init__(self, cfg: rmpflow_actions_cfg.RMPFlowActionCfg, env: ManagerBasedE self._body_idx = body_ids[0] self._body_name = body_names[0] - # check if articulation is fixed-base - # if fixed-base then the jacobian for the base is not computed - # this means that number of bodies is one less than the articulation's number of bodies - if self._asset.is_fixed_base: - self._jacobi_body_idx = self._body_idx - 1 - self._jacobi_joint_ids = self._joint_ids - else: - self._jacobi_body_idx = self._body_idx - self._jacobi_joint_ids = [i + 6 for i in self._joint_ids] + self._jacobi_body_idx = self._body_idx - 1 if self._asset.is_fixed_base else self._body_idx + self._jacobi_joint_ids = [j + self._asset.num_base_dofs for j in self._joint_ids] # log info for debugging logger.info( @@ -128,7 +120,7 @@ def processed_actions(self) -> torch.Tensor: @property def jacobian_w(self) -> torch.Tensor: - return wp.to_torch(self._asset.root_view.get_jacobians())[:, self._jacobi_body_idx, :, self._jacobi_joint_ids] + return self._asset.data.body_link_jacobian_w.torch[:, self._jacobi_body_idx, :, self._jacobi_joint_ids] @property def jacobian_b(self) -> torch.Tensor: diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py index 4aa66367d2c2..507a2053b585 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING import torch -import warp as wp from pxr import UsdPhysics @@ -72,15 +71,8 @@ def __init__(self, cfg: actions_cfg.DifferentialInverseKinematicsActionCfg, env: # save only the first body index self._body_idx = body_ids[0] self._body_name = body_names[0] - # check if articulation is fixed-base - # if fixed-base then the jacobian for the base is not computed - # this means that number of bodies is one less than the articulation's number of bodies - if self._asset.is_fixed_base: - self._jacobi_body_idx = self._body_idx - 1 - self._jacobi_joint_ids = self._joint_ids - else: - self._jacobi_body_idx = self._body_idx - self._jacobi_joint_ids = [i + 6 for i in self._joint_ids] + self._jacobi_body_idx = self._body_idx - 1 if self._asset.is_fixed_base else self._body_idx + self._jacobi_joint_ids = [j + self._asset.num_base_dofs for j in self._joint_ids] # log info for debugging logger.info( @@ -143,7 +135,7 @@ def processed_actions(self) -> torch.Tensor: @property def jacobian_w(self) -> torch.Tensor: - return wp.to_torch(self._asset.root_view.get_jacobians())[:, self._jacobi_body_idx, :, self._jacobi_joint_ids] + return self._asset.data.body_link_jacobian_w.torch[:, self._jacobi_body_idx, :, self._jacobi_joint_ids] @property def jacobian_b(self) -> torch.Tensor: @@ -300,15 +292,8 @@ def __init__(self, cfg: actions_cfg.OperationalSpaceControllerActionCfg, env: Ma # save only the first ee body index self._ee_body_idx = body_ids[0] self._ee_body_name = body_names[0] - # check if articulation is fixed-base - # if fixed-base then the jacobian for the base is not computed - # this means that number of bodies is one less than the articulation's number of bodies - if self._asset.is_fixed_base: - self._jacobi_ee_body_idx = self._ee_body_idx - 1 - self._jacobi_joint_idx = self._joint_ids - else: - self._jacobi_ee_body_idx = self._ee_body_idx - self._jacobi_joint_idx = [i + 6 for i in self._joint_ids] + self._jacobi_ee_body_idx = self._ee_body_idx - 1 if self._asset.is_fixed_base else self._ee_body_idx + self._jacobi_joint_idx = [j + self._asset.num_base_dofs for j in self._joint_ids] # log info for debugging logger.info( @@ -379,6 +364,15 @@ def __init__(self, cfg: actions_cfg.OperationalSpaceControllerActionCfg, env: Ma self._mass_matrix = torch.zeros(self.num_envs, self._num_DoF, self._num_DoF, device=self.device) self._gravity = torch.zeros(self.num_envs, self._num_DoF, device=self.device) + # Cache the per-step fetch decisions: cfg is immutable after init, so + # mass-matrix and gravity-comp needs are constant across steps. + # Mass matrix is consumed by inertial-decoupling and (when nullspace + # control is enabled) the null-space torque term in OSC.compute(). + self._needs_mass_matrix = self.cfg.controller_cfg.inertial_dynamics_decoupling or ( + self.cfg.controller_cfg.nullspace_control != "none" + ) + self._needs_gravity = self.cfg.controller_cfg.gravity_compensation + # create tensors for the ee states self._ee_pose_w = torch.zeros(self.num_envs, 7, device=self.device) self._ee_pose_b = torch.zeros(self.num_envs, 7, device=self.device) @@ -435,9 +429,7 @@ def processed_actions(self) -> torch.Tensor: @property def jacobian_w(self) -> torch.Tensor: - return wp.to_torch(self._asset.root_view.get_jacobians())[ - :, self._jacobi_ee_body_idx, :, self._jacobi_joint_idx - ] + return self._asset.data.body_link_jacobian_w.torch[:, self._jacobi_ee_body_idx, :, self._jacobi_joint_idx] @property def jacobian_b(self) -> torch.Tensor: @@ -527,14 +519,18 @@ def apply_actions(self): self._compute_ee_velocity() self._compute_ee_force() self._compute_joint_states() - # Calculate the joint efforts + # Calculate the joint efforts. Pass ``None`` for mass matrix / gravity + # when the controller cfg doesn't require them, instead of forwarding + # the (stale-zero) buffers — the controller's own ``None`` checks then + # raise immediately on any misconfiguration rather than silently + # operating on zeros. self._joint_efforts[:] = self._osc.compute( jacobian_b=self._jacobian_b, current_ee_pose_b=self._ee_pose_b, current_ee_vel_b=self._ee_vel_b, current_ee_force_b=self._ee_force_b, - mass_matrix=self._mass_matrix, - gravity=self._gravity, + mass_matrix=self._mass_matrix if self._needs_mass_matrix else None, + gravity=self._gravity if self._needs_gravity else None, current_joint_pos=self._joint_pos, current_joint_vel=self._joint_vel, nullspace_joint_pos_target=self._nullspace_joint_pos_target, @@ -648,18 +644,27 @@ def _resolve_nullspace_joint_pos_targets(self): def _compute_dynamic_quantities(self): """Computes the dynamic quantities for operational space control. - Note: For floating-base robots, PhysX prepends 6 virtual DOFs (base position and orientation) - to the generalized mass matrix and gravity compensation forces. We use ``self._jacobi_joint_idx`` - (which applies the +6 offset for floating-base robots) instead of ``self._joint_ids`` to correctly - index into these quantities. For fixed-base robots, the two are identical. + Mass matrix and gravity-compensation forces are only fetched when the + controller actually consumes them — gated by + :attr:`~isaaclab.controllers.OperationalSpaceControllerCfg.inertial_dynamics_decoupling` + / :attr:`~isaaclab.controllers.OperationalSpaceControllerCfg.nullspace_control` + and + :attr:`~isaaclab.controllers.OperationalSpaceControllerCfg.gravity_compensation` + respectively. This avoids an unconditional engine call on backends + that don't expose the corresponding primitive (Newton has no + gravity-compensation API). + + Note: For floating-base robots the Jacobian / mass-matrix / gravity-compensation + DoF axis prepends 6 floating-base columns. We use ``self._jacobi_joint_idx`` + (which applies the ``+ num_base_dofs`` shift) instead of ``self._joint_ids`` to + correctly index into these quantities. For fixed-base robots the two are identical. """ - - self._mass_matrix[:] = wp.to_torch(self._asset.root_view.get_generalized_mass_matrices())[ - :, self._jacobi_joint_idx, : - ][:, :, self._jacobi_joint_idx] - self._gravity[:] = wp.to_torch(self._asset.root_view.get_gravity_compensation_forces())[ - :, self._jacobi_joint_idx - ] + if self._needs_mass_matrix: + self._mass_matrix[:] = self._asset.data.mass_matrix.torch[:, self._jacobi_joint_idx, :][ + :, :, self._jacobi_joint_idx + ] + if self._needs_gravity: + self._gravity[:] = self._asset.data.gravity_compensation_forces.torch[:, self._jacobi_joint_idx] def _compute_ee_jacobian(self): """Computes the geometric Jacobian of the ee body frame in root frame. diff --git a/source/isaaclab/test/assets/test_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index 498091f51058..8dcc2b0ebc43 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -346,6 +346,14 @@ def create_newton_articulation( # Mock NewtonManager (aliased as SimulationManager in Newton modules) mock_model = MagicMock() mock_model.gravity = wp.array(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), dtype=wp.vec3f, device=device) + # Sizes consumed by the task-space scratch buffers in NewtonArticulationData.__init__. + # Model-wide counts equal the per-articulation counts here because the mock contains a + # single homogeneous world. + mock_model.articulation_count = num_instances + mock_model.max_joints_per_articulation = num_bodies + mock_model.max_dofs_per_articulation = num_joints + mock_model.joint_dof_count = num_instances * num_joints + mock_model.body_count = num_instances * num_bodies mock_state = MagicMock() mock_control = MagicMock() diff --git a/source/isaaclab/test/controllers/test_differential_ik.py b/source/isaaclab/test/controllers/test_differential_ik.py index 47abc78e0de6..2ba7af0ec028 100644 --- a/source/isaaclab/test/controllers/test_differential_ik.py +++ b/source/isaaclab/test/controllers/test_differential_ik.py @@ -14,7 +14,6 @@ import pytest import torch -import warp as wp import isaaclab.sim as sim_utils from isaaclab import cloner @@ -199,7 +198,7 @@ def _run_ik_controller( # at reset, the jacobians are not updated to the latest state # so we MUST skip the first step # obtain quantities from simulation - jacobian = wp.to_torch(robot.root_view.get_jacobians())[:, ee_jacobi_idx, :, arm_joint_ids] + jacobian = robot.data.body_link_jacobian_w.torch[:, ee_jacobi_idx, :, arm_joint_ids] ee_pose_w = robot.data.body_pose_w.torch[:, ee_frame_idx] root_pose_w = robot.data.root_pose_w.torch base_rot = root_pose_w[:, 3:7] diff --git a/source/isaaclab/test/controllers/test_operational_space.py b/source/isaaclab/test/controllers/test_operational_space.py index badf51543e17..80f1f8952623 100644 --- a/source/isaaclab/test/controllers/test_operational_space.py +++ b/source/isaaclab/test/controllers/test_operational_space.py @@ -14,7 +14,6 @@ import pytest import torch -import warp as wp from flaky import flaky import isaaclab.envs.mdp as mdp @@ -1299,8 +1298,11 @@ class _FloatingBaseOscActionsCfg: controller_cfg=OperationalSpaceControllerCfg( target_types=["pose_abs"], impedance_mode="fixed", + # Both flags enabled so the action term fetches mass matrix AND + # gravity each step, exercising the floating-base +6 indexing on + # both quantities. inertial_dynamics_decoupling=True, - gravity_compensation=False, + gravity_compensation=True, motion_stiffness_task=500.0, motion_damping_ratio_task=1.0, ), @@ -1330,14 +1332,16 @@ def test_floating_base_osc_action_term_indexing(): """Regression test for #4999 / PR #5107: verify OperationalSpaceControllerAction uses correct indices for mass matrix and gravity on floating-base robots. - For floating-base robots, PhysX prepends 6 virtual DOFs to the generalized mass matrix and - gravity vectors. The action term's ``_compute_dynamic_quantities()`` must use - ``_jacobi_joint_idx`` (with +6 offset) instead of ``_joint_ids``. This test instantiates the - real action term via a ManagerBasedEnv, triggers ``_compute_dynamic_quantities()``, and verifies - the extracted mass matrix and gravity match a manual extraction using the correct PhysX indices. + The Jacobian / mass-matrix / gravity-comp DoF axis prepends ``num_base_dofs`` + floating-base columns (``6`` for floating-base, ``0`` for fixed-base). The action + term's ``_compute_dynamic_quantities()`` must use ``_jacobi_joint_idx`` (with the + ``+ num_base_dofs`` shift) instead of ``_joint_ids``. This test instantiates the + real action term via a ManagerBasedEnv, triggers ``_compute_dynamic_quantities()``, + and verifies the extracted mass matrix and gravity match a manual extraction using + the correct indices. - If someone reverts ``_jacobi_joint_idx`` back to ``_joint_ids`` in ``_compute_dynamic_quantities``, - this test will fail. + If someone reverts ``_jacobi_joint_idx`` back to ``_joint_ids`` in + ``_compute_dynamic_quantities``, this test will fail. """ env_cfg = _FloatingBaseOscEnvCfg() env_cfg.sim.device = "cuda:0" @@ -1365,8 +1369,8 @@ def test_floating_base_osc_action_term_indexing(): # --- 5. Manually extract using the CORRECT indices (what the fix does) --- jacobi_joint_idx = action_term._jacobi_joint_idx - full_mass_matrix = wp.to_torch(robot.root_view.get_generalized_mass_matrices()) - full_gravity = wp.to_torch(robot.root_view.get_gravity_compensation_forces()) + full_mass_matrix = robot.data.mass_matrix.torch + full_gravity = robot.data.gravity_compensation_forces.torch manual_mass = full_mass_matrix[:, jacobi_joint_idx, :][:, :, jacobi_joint_idx] manual_gravity = full_gravity[:, jacobi_joint_idx] @@ -1375,10 +1379,10 @@ def test_floating_base_osc_action_term_indexing(): torch.testing.assert_close(term_mass, manual_mass, atol=1e-5, rtol=0) torch.testing.assert_close(term_gravity, manual_gravity, atol=1e-5, rtol=0) - # --- 7. Verify the full PhysX tensor has +6 virtual DOFs --- - expected_physx_dofs = robot.num_joints + 6 - assert full_mass_matrix.shape[1] == expected_physx_dofs, ( - f"Mass matrix should have {expected_physx_dofs} DOFs, got {full_mass_matrix.shape[1]}" + # --- 7. Verify the data-layer tensor exposes the full DoF axis (J + num_base_dofs) --- + expected_dofs = robot.num_joints + robot.num_base_dofs + assert full_mass_matrix.shape[1] == expected_dofs, ( + f"Mass matrix should have {expected_dofs} DoFs, got {full_mass_matrix.shape[1]}" ) # --- 8. Verify correct indices differ from raw joint_ids (the old bug) --- @@ -1386,7 +1390,7 @@ def test_floating_base_osc_action_term_indexing(): original_joint_ids, _ = robot.find_joints(_G1_ARM_JOINT_NAMES) buggy_mass = full_mass_matrix[:, original_joint_ids, :][:, :, original_joint_ids] assert not torch.allclose(term_mass, buggy_mass, atol=1e-6), ( - "Action term mass matrix should NOT match extraction with raw joint_ids (no +6 offset)" + "Action term mass matrix should NOT match extraction with raw joint_ids (no num_base_dofs offset)" ) # --- 9. Verify physically reasonable values --- @@ -1591,9 +1595,9 @@ def _update_states( """ # obtain dynamics related quantities from simulation ee_jacobi_idx = ee_frame_idx - 1 - jacobian_w = wp.to_torch(robot.root_view.get_jacobians())[:, ee_jacobi_idx, :, arm_joint_ids] - mass_matrix = wp.to_torch(robot.root_view.get_generalized_mass_matrices())[:, arm_joint_ids, :][:, :, arm_joint_ids] - gravity = wp.to_torch(robot.root_view.get_gravity_compensation_forces())[:, arm_joint_ids] + jacobian_w = robot.data.body_link_jacobian_w.torch[:, ee_jacobi_idx, :, arm_joint_ids] + mass_matrix = robot.data.mass_matrix.torch[:, arm_joint_ids, :][:, :, arm_joint_ids] + gravity = robot.data.gravity_compensation_forces.torch[:, arm_joint_ids] # Convert the Jacobian from world to root frame jacobian_b = jacobian_w.clone() root_rot_matrix = matrix_from_quat(quat_inv(robot.data.root_quat_w.torch)) diff --git a/source/isaaclab_newton/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst b/source/isaaclab_newton/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst new file mode 100644 index 000000000000..aea1e28e52b9 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst @@ -0,0 +1,40 @@ +Added +^^^^^ + +* Added Newton implementations of + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w`, + :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w`, and + :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix` on + :class:`~isaaclab_newton.assets.ArticulationData`. The properties wrap + ``ArticulationView.eval_jacobian`` and ``ArticulationView.eval_mass_matrix`` + with view-sized output buffers cached via the standard timestamped-buffer + pattern. Per-step behavior is allocation-free and safe under CUDA-graph + capture: source / scratch / output buffers are pre-allocated in + ``_create_buffers``, and + :func:`~isaaclab_newton.assets.articulation.kernels.gather_jacobian_rows` + and :func:`~isaaclab_newton.assets.articulation.kernels.gather_mass_matrix_rows` + Warp kernels gather just this view's rows from the model-sized buffers + Newton populates. The DoF axis preserves the leading 6 floating-base + columns Newton fills for floating-base articulations (matching the + cross-library industry convention and PhysX's layout). +* Added the + :func:`~isaaclab_newton.assets.articulation.kernels.shift_jacobian_com_to_origin` + Warp kernel applying the + ``v_origin = v_com - omega x (R · body_com_pos_b)`` shift to the + linear-velocity rows of the gathered, view-sized Jacobian, so the link- + origin form matches the cross-backend + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` + contract. + +Changed +^^^^^^^ + +* :attr:`~isaaclab_newton.assets.ArticulationData.gravity_compensation_forces` + raises :class:`NotImplementedError` with a message pointing at the + upstream gap. Newton's ``ArticulationView`` does not expose an + inverse-dynamics primitive yet (upstream Newton issues + `#2497 `_, + `#2529 `_, + `#2625 `_). + OSC users on Newton must set ``gravity_compensation=False`` until + upstream lands the primitive. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py index 2da95b49b21d..0a2a84392619 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -127,6 +127,19 @@ def update(self, dt: float) -> None: self.joint_acc self.body_com_acc_w + def _ensure_fk_fresh(self) -> None: + """Run forward kinematics if joint state has changed since the last FK update. + + Newton's ``state.body_q`` (per-body world transforms) is updated by ``eval_fk``, + invoked here through ``SimulationManager.forward()``. After a manual joint or root + write that bypassed the sim step (``write_*_to_sim_*``), ``_fk_timestamp`` is set + to ``-1.0`` to force a refresh on the next read of any property that depends on + body poses (``body_link_pose_w``, the Jacobian properties, ``mass_matrix``). + """ + if self._fk_timestamp < self._sim_timestamp: + SimulationManager.forward() + self._fk_timestamp = self._sim_timestamp + """ Names. """ @@ -667,9 +680,7 @@ def body_link_pose_w(self) -> ProxyArray: This quantity is the pose of the articulation links' actor frame relative to the world. The orientation is provided in (x, y, z, w) format. """ - if self._fk_timestamp < self._sim_timestamp: - SimulationManager.forward() - self._fk_timestamp = self._sim_timestamp + self._ensure_fk_fresh() return self._body_link_pose_w_ta @property @@ -812,6 +823,130 @@ def body_com_pose_b(self) -> ProxyArray: self._body_com_pose_b.timestamp = self._sim_timestamp return self._body_com_pose_b_ta + """ + Dynamics quantities (task-space controllers). + """ + + @property + def body_com_jacobian_w(self) -> ProxyArray: + """See :attr:`isaaclab.assets.BaseArticulationData.body_com_jacobian_w`. + + Newton implementation: ``eval_jacobian`` (writes the model-wide buffer) then a + gather kernel extracts this view's rows. ``link_offset`` drops Newton's fixed- + root row for fixed-base; the DoF axis is preserved in full. + """ + # Newton's eval_jacobian reads ``state.body_q`` (link poses); refresh FK if stale. + # Matches the convention in ``body_link_pose_w`` — Python-guarded lazy refresh. + self._ensure_fk_fresh() + # eval_jacobian writes every articulation in the model; gather kernel extracts this + # view's rows. ``link_offset`` skips Newton's fixed-root row for fixed-base; the DoF + # axis is preserved in full (free-root joint's 6 columns up front for floating-base), + # matching the PhysX layout and the cross-library industry convention. + self._root_view.eval_jacobian( + SimulationManager.get_state_0(), + J=self._jacobian_buf_flat, + joint_S_s=self._joint_S_s_buf, + ) + wp.launch( + articulation_kernels.gather_jacobian_rows, + dim=self._body_com_jacobian_w_buf.shape, + inputs=[ + self._jacobian_buf, + self._jacobian_view_art_ids, + self._jacobian_link_offset, + ], + outputs=[self._body_com_jacobian_w_buf], + device=self.device, + ) + return self._body_com_jacobian_w_ta + + @property + def body_link_jacobian_w(self) -> ProxyArray: + """See :attr:`isaaclab.assets.BaseArticulationData.body_link_jacobian_w`. + + Newton implementation: applies the COM→origin shift kernel to + :attr:`body_com_jacobian_w` (Newton's ``eval_jacobian`` is COM-referenced). + """ + # ``body_link_pose_w`` accessor triggers ``SimulationManager.forward()`` if FK is + # stale (after a manual joint / root write that bypassed the sim step). Reading the + # property here — not ``_sim_bind_body_link_pose_w`` directly — keeps the shift + # kernel from using stale link rotations during reset / IK-warm-start paths. + link_pose_w = self.body_link_pose_w.warp + com_jac = self.body_com_jacobian_w + wp.launch( + articulation_kernels.shift_jacobian_com_to_origin, + dim=self._body_link_jacobian_w_buf.shape[:2] + (self._body_link_jacobian_w_buf.shape[3],), + inputs=[ + link_pose_w, + self._sim_bind_body_com_pos_b, + self._jacobian_link_offset, + com_jac.warp, + ], + outputs=[self._body_link_jacobian_w_buf], + device=self.device, + ) + return self._body_link_jacobian_w_ta + + @property + def mass_matrix(self) -> ProxyArray: + """See :attr:`isaaclab.assets.BaseArticulationData.mass_matrix`. + + Newton implementation: ``eval_mass_matrix`` (writes the model-wide buffer) then a + gather kernel extracts this view's rows. + """ + # eval_jacobian / eval_mass_matrix read ``state.body_q``; refresh FK if stale. + # Matches the convention in ``body_link_pose_w`` — Python-guarded lazy refresh. + self._ensure_fk_fresh() + # eval_mass_matrix treats ``J`` as an input (skips its own jacobian compute when + # provided), so we must populate the scratch first via eval_jacobian. Reusing + # ``_jacobian_buf_flat`` (same shape) avoids a second allocation. All scratch buffers + # are pre-allocated for CUDA-graph capture safety. + state = SimulationManager.get_state_0() + self._root_view.eval_jacobian( + state, + J=self._jacobian_buf_flat, + joint_S_s=self._joint_S_s_buf, + ) + self._root_view.eval_mass_matrix( + state, + H=self._mass_matrix_full_buf, + J=self._jacobian_buf_flat, + body_I_s=self._mass_matrix_body_I_s_buf, + joint_S_s=self._joint_S_s_buf, + ) + wp.launch( + articulation_kernels.gather_mass_matrix_rows, + dim=self._mass_matrix_buf.shape, + inputs=[ + self._mass_matrix_full_buf, + self._jacobian_view_art_ids, + ], + outputs=[self._mass_matrix_buf], + device=self.device, + ) + return self._mass_matrix_ta + + @property + def gravity_compensation_forces(self) -> ProxyArray: + """See :attr:`isaaclab.assets.BaseArticulationData.gravity_compensation_forces`. + + Newton implementation: raises :class:`NotImplementedError` — Newton's + ``ArticulationView`` exposes only ``eval_fk`` / ``eval_jacobian`` / + ``eval_mass_matrix``. Use PhysX, or set the controller's + ``gravity_compensation=False`` until upstream Newton adds the primitive. + Tracking upstream: `newton#2497 `_, + `newton#2529 `_, + `newton#2625 `_. + """ + raise NotImplementedError( + "Newton has no gravity-compensation primitive. Use PhysX, or set the controller's" + " ``gravity_compensation=False`` until upstream Newton adds an" + " ``eval_gravity_compensation`` API. Tracking upstream:" + " https://github.com/newton-physics/newton/issues/2497," + " https://github.com/newton-physics/newton/issues/2529," + " https://github.com/newton-physics/newton/issues/2625." + ) + """ Joint state properties. """ @@ -1301,18 +1436,21 @@ def _create_simulation_bindings(self) -> None: # -- root properties self._sim_bind_root_link_pose_w = self._root_view.get_root_transforms(SimulationManager.get_state_0())[:, 0] - self._sim_bind_root_com_vel_w = self._root_view.get_root_velocities(SimulationManager.get_state_0()) - if self._sim_bind_root_com_vel_w is not None: + # ``get_root_velocities`` returns ``None`` for fixed-base articulations; the + # ``wp.zeros`` fallback set by :meth:`_create_buffers` must survive subsequent + # resets, so only overwrite when the solver actually exposes the binding. + root_vel_w = self._root_view.get_root_velocities(SimulationManager.get_state_0()) + if root_vel_w is not None: if self._root_view.is_fixed_base: - self._sim_bind_root_com_vel_w = self._sim_bind_root_com_vel_w[:, 0, 0] + self._sim_bind_root_com_vel_w = root_vel_w[:, 0, 0] else: - self._sim_bind_root_com_vel_w = self._sim_bind_root_com_vel_w[:, 0] + self._sim_bind_root_com_vel_w = root_vel_w[:, 0] # -- body properties self._sim_bind_body_com_pos_b = self._root_view.get_attribute("body_com", SimulationManager.get_model())[:, 0] self._sim_bind_body_link_pose_w = self._root_view.get_link_transforms(SimulationManager.get_state_0())[:, 0] - self._sim_bind_body_com_vel_w = self._root_view.get_link_velocities(SimulationManager.get_state_0()) - if self._sim_bind_body_com_vel_w is not None: - self._sim_bind_body_com_vel_w = self._sim_bind_body_com_vel_w[:, 0] + body_com_vel_w = self._root_view.get_link_velocities(SimulationManager.get_state_0()) + if body_com_vel_w is not None: + self._sim_bind_body_com_vel_w = body_com_vel_w[:, 0] self._sim_bind_body_mass = self._root_view.get_attribute("body_mass", SimulationManager.get_model())[:, 0] # Newton stores body_inertia as (N, 1, B) mat33f — the [:, 0] removes the padding dim # giving (N, B) mat33f. Reinterpret as (N, B, 9) float32 via pointer aliasing. @@ -1516,6 +1654,8 @@ def _create_buffers(self) -> None: self._joint_acc = TimestampedBuffer( shape=(self._num_instances, self._num_joints), dtype=wp.float32, device=self.device ) + # -- dynamics quantities for task-space controllers + self._create_jacobian_buffers(SimulationManager.get_model()) # Empty memory pre-allocations self._root_link_lin_vel_b = None self._root_link_ang_vel_b = None @@ -1552,6 +1692,74 @@ def _create_buffers(self) -> None: # Pin all ProxyArray wrappers to current buffers. self._pin_proxy_arrays() + def _create_jacobian_buffers(self, model) -> None: + """Allocate the scratch + view-sized buffers used by task-space accessors. + + Newton's :meth:`eval_jacobian` / :meth:`eval_mass_matrix` write into model-sized + scratch buffers spanning every articulation in the model; the gather kernels in + :attr:`body_com_jacobian_w` / :attr:`mass_matrix` extract this view's rows. The + output buffers are sized using THIS articulation's body / DoF counts (not the + model-wide ``max_*``) so heterogeneous scenes do not leak zero-padded rows / cols + into the returned tensor. The DoF axis includes ``num_base_dofs`` floating-base + columns up front (0 for fixed-base, 6 for floating-base), matching the cross- + library industry convention (PhysX, Pinocchio, Drake, MuJoCo, RBDL, OCS2, iDynTree). + + Args: + model: Newton ``Model`` from :meth:`SimulationManager.get_model`. Read for + ``articulation_count``, ``max_joints_per_articulation``, + ``max_dofs_per_articulation``, ``joint_dof_count``, ``body_count``. + """ + max_links = model.max_joints_per_articulation + max_dofs = model.max_dofs_per_articulation + + # -- shared scratch (eval_jacobian outputs; consumed by ``body_com_jacobian_w`` + # and reused as ``eval_mass_matrix``'s ``J`` input to skip a re-compute) + self._jacobian_buf_flat = wp.zeros( + (model.articulation_count, max_links * 6, max_dofs), dtype=wp.float32, device=self.device + ) + # Motion subspace (Featherstone ``S``, spatial frame); produced by eval_jacobian, + # also consumed by eval_mass_matrix. + self._joint_S_s_buf = wp.zeros(model.joint_dof_count, dtype=wp.spatial_vector, device=self.device) + + # -- per-view gather config (shared by every gather/shift kernel below) + # Link-row offset: fixed-base skips Newton's row-0 fixed-root row; floating-base keeps it. + self._jacobian_link_offset = 1 if self._root_view.is_fixed_base else 0 + num_jacobi_bodies = self._num_bodies - self._jacobian_link_offset + # Free-root DoF columns Newton fills for floating-base (0 fixed-base, 6 floating-base); + # included in the DoF axis to match the cross-library industry convention. + num_base_dofs = 0 if self._root_view.is_fixed_base else 6 + # Flattened (num_worlds*num_per_view,) view-to-model index map for the gather kernels. + self._jacobian_view_art_ids = self._root_view.articulation_ids.reshape((-1,)) + + # -- ``body_com_jacobian_w``: 4-D reshape view of the shared scratch (kernel input + # to the gather) and the per-view output buffer (gather output) + self._jacobian_buf = self._jacobian_buf_flat.reshape((model.articulation_count, max_links, 6, max_dofs)) + self._body_com_jacobian_w_buf = wp.zeros( + (self._num_instances, num_jacobi_bodies, 6, self._num_joints + num_base_dofs), + dtype=wp.float32, + device=self.device, + ) + + # -- ``body_link_jacobian_w``: output of the COM→origin shift kernel applied to + # the COM-referenced Jacobian above; same shape, link-origin reference + self._body_link_jacobian_w_buf = wp.zeros( + (self._num_instances, num_jacobi_bodies, 6, self._num_joints + num_base_dofs), + dtype=wp.float32, + device=self.device, + ) + + # -- ``mass_matrix``: model-wide ``H`` scratch (eval_mass_matrix output), per-body + # spatial-inertia aux (Featherstone ``I``), and per-view output (gather output) + self._mass_matrix_full_buf = wp.zeros( + (model.articulation_count, max_dofs, max_dofs), dtype=wp.float32, device=self.device + ) + self._mass_matrix_body_I_s_buf = wp.zeros(model.body_count, dtype=wp.spatial_matrix, device=self.device) + self._mass_matrix_buf = wp.zeros( + (self._num_instances, self._num_joints + num_base_dofs, self._num_joints + num_base_dofs), + dtype=wp.float32, + device=self.device, + ) + def _pin_proxy_arrays(self) -> None: """Create or rebind all pinned ProxyArray wrappers. @@ -1625,6 +1833,9 @@ def _pin_proxy_arrays(self) -> None: self._projected_gravity_b_ta = ProxyArray(self._projected_gravity_b.data) self._heading_w_ta = ProxyArray(self._heading_w.data) self._joint_acc_ta = ProxyArray(self._joint_acc.data) + self._body_com_jacobian_w_ta = ProxyArray(self._body_com_jacobian_w_buf) + self._body_link_jacobian_w_ta = ProxyArray(self._body_link_jacobian_w_buf) + self._mass_matrix_ta = ProxyArray(self._mass_matrix_buf) # -- deprecated state properties (lazy); type annotations declared once here self._root_state_w_ta: ProxyArray | None = None diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/kernels.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/kernels.py index 5e66b867c09a..a928bd524761 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/kernels.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/kernels.py @@ -618,3 +618,142 @@ def concat_joint_pos_limits_lower_and_upper( """ i, j = wp.tid() joint_pos_limits[i, j] = wp.vec2f(joint_pos_limits_lower[i, j], joint_pos_limits_upper[i, j]) + + +@wp.kernel +def gather_jacobian_rows( + src: wp.array4d(dtype=wp.float32), + art_ids: wp.array(dtype=wp.int32), + link_offset: wp.int32, + dst: wp.array4d(dtype=wp.float32), +): + """Copy per-view articulation jacobian rows from a model-sized buffer into a view-sized buffer. + + Newton's ``eval_jacobian`` writes every articulation in the model (across all + :class:`~newton.selection.ArticulationView` instances) into a single 4-D output + shaped ``(model.articulation_count, max_links, 6, max_dofs)``. An + ``ArticulationView`` owns only the subset indexed by ``articulation_ids``. This + kernel gathers those rows into a contiguous view-sized destination so the + caller-facing + :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w` contract + ``(num_instances, num_jacobi_bodies, 6, num_joints + num_base_dofs)`` is + preserved. + + For fixed-base articulations Newton fills link row 0 with the fixed root joint + (zero motion subspace), so we skip it with ``link_offset = 1``. For floating- + base, ``link_offset = 0`` and the full DoF axis is preserved including the 6 + leading free-root joint columns, matching the cross-library industry + convention used by PhysX, Pinocchio, Drake, MuJoCo, RBDL, OCS2, and iDynTree. + + The gather is in-place on a pre-allocated ``dst`` buffer, so the kernel launch + is safe under CUDA graph capture. + + Args: + src: Input jacobian buffer reshaped to 4-D. Shape is + (model.articulation_count, max_links, 6, max_dofs). + art_ids: Model-level articulation indices owned by this view. Shape is + (num_instances,). + link_offset: Constant offset added to the destination link index when + reading from ``src``. ``1`` for fixed-base views, ``0`` for + floating-base. + dst: Output jacobian buffer for this view. Shape is + (num_instances, num_jacobi_bodies, 6, num_joints + num_base_dofs), + where ``num_jacobi_bodies = this asset's num_bodies - link_offset`` + (per-asset count, not the model-wide ``max_links``). + """ + i, link, s, d = wp.tid() + dst[i, link, s, d] = src[art_ids[i], link + link_offset, s, d] + + +@wp.kernel +def gather_mass_matrix_rows( + src: wp.array3d(dtype=wp.float32), + art_ids: wp.array(dtype=wp.int32), + dst: wp.array3d(dtype=wp.float32), +): + """Copy per-view articulation mass-matrix rows from a model-sized buffer into a view-sized buffer. + + 3-D analogue of :func:`gather_jacobian_rows` for the joint-space mass + matrix written by :func:`newton.sim.articulation.eval_mass_matrix`. The + DoF axis is preserved in full (including the leading 6 free-root rows/cols + for floating-base articulations), matching the cross-library industry + convention used by PhysX, Pinocchio, Drake, MuJoCo, RBDL, OCS2, and iDynTree. + + Args: + src: Input mass-matrix buffer. Shape is + (model.articulation_count, max_dofs, max_dofs). + art_ids: Model-level articulation indices owned by this view. Shape is + (num_instances,). + dst: Output mass-matrix buffer for this view. Shape is + (num_instances, num_joints + num_base_dofs, + num_joints + num_base_dofs). + """ + i, r, c = wp.tid() + dst[i, r, c] = src[art_ids[i], r, c] + + +@wp.kernel +def shift_jacobian_com_to_origin( + body_link_pose: wp.array2d(dtype=wp.transformf), + body_com_pos_b: wp.array2d(dtype=wp.vec3f), + link_offset: wp.int32, + src: wp.array4d(dtype=wp.float32), + dst: wp.array4d(dtype=wp.float32), +): + """Shift the linear-velocity rows of the Jacobian from COM to link origin. + + Newton's ``eval_jacobian`` returns ``J · q_dot = [v_com_world, omega_world]`` + per link — the linear rows are the velocity of the link's center of mass, + expressed in world frame. The + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` contract + requires the linear rows to be the velocity at the link **origin** + (USD prim transform) so that ``J · q_dot[body_idx]`` matches + :attr:`~isaaclab.assets.BaseArticulationData.body_link_lin_vel_w` / + :attr:`~isaaclab.assets.BaseArticulationData.body_link_ang_vel_w`. + + The shift is the same one applied per-body by + :func:`get_link_vel_from_root_com_vel_func`, but layered onto every + Jacobian column: each column represents the spatial velocity contribution + of one DoF, and shifting a spatial velocity from COM to link origin uses + the same ``v_origin = v_com - omega x (R · body_com_pos_b)`` identity. + + Notes on layout: + * Jacobian rows ``[0:3]`` are linear velocity, ``[3:6]`` are angular. + * ``body_link_pose`` and ``body_com_pos_b`` are indexed by the + articulation's full body count, so ``link_offset`` must be applied + to map a row in the (already-gathered) ``src`` to its body index in + the asset data. ``link_offset = 1`` for fixed-base (Newton's row 0 + fixed-root row was dropped during the prior gather); + ``link_offset = 0`` for floating-base. + + Args: + body_link_pose: Per-body link pose in world frame. Shape is + (num_instances, num_bodies). + body_com_pos_b: Per-body center-of-mass offset expressed in the body's + link frame. Shape is (num_instances, num_bodies). + link_offset: Offset added to the jacobian-row body index to reach the + full body index. ``1`` for fixed-base, ``0`` for floating-base. + src: COM-referenced Jacobian (read-only). Shape is + (num_instances, num_jacobi_bodies, 6, num_joints + num_base_dofs). + dst: Output buffer for the link-origin Jacobian. Same shape as + ``src``. Linear rows ``[0:3]`` are written with the shifted + velocity; angular rows ``[3:6]`` are copied unchanged (angular + velocity is reference-point invariant). + """ + n, b, dof = wp.tid() + full_body_idx = b + link_offset + + R = wp.transform_get_rotation(body_link_pose[n, full_body_idx]) + c_world = wp.quat_rotate(R, body_com_pos_b[n, full_body_idx]) + + v_com = wp.vec3(src[n, b, 0, dof], src[n, b, 1, dof], src[n, b, 2, dof]) + omega = wp.vec3(src[n, b, 3, dof], src[n, b, 4, dof], src[n, b, 5, dof]) + + v_origin = v_com - wp.cross(omega, c_world) + + dst[n, b, 0, dof] = v_origin[0] + dst[n, b, 1, dof] = v_origin[1] + dst[n, b, 2, dof] = v_origin[2] + dst[n, b, 3, dof] = omega[0] + dst[n, b, 4, dof] = omega[1] + dst[n, b, 5, dof] = omega[2] diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index cd0f2dcb03b9..5202da63418b 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -18,6 +18,7 @@ """Rest everything follows.""" import sys +from copy import deepcopy import pytest import torch @@ -32,16 +33,23 @@ import isaaclab.utils.string as string_utils from isaaclab.actuators import ActuatorBase, IdealPDActuatorCfg, ImplicitActuatorCfg from isaaclab.assets import ArticulationCfg +from isaaclab.controllers import ( + DifferentialIKController, + DifferentialIKControllerCfg, + OperationalSpaceController, + OperationalSpaceControllerCfg, +) from isaaclab.envs.mdp.terminations import joint_effort_out_of_limit from isaaclab.managers import SceneEntityCfg from isaaclab.sim import SimulationCfg, build_simulation_context from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.math import compute_pose_error, matrix_from_quat, quat_inv, subtract_frame_transforms from isaaclab.utils.version import get_isaac_sim_version, has_kit ## # Pre-defined configs ## -from isaaclab_assets import ANYMAL_C_CFG, FRANKA_PANDA_CFG # isort:skip +from isaaclab_assets import ANYMAL_C_CFG, FRANKA_PANDA_CFG, FRANKA_PANDA_HIGH_PD_CFG # isort:skip # , SHADOW_HAND_CFG # isort:skip SIM_CFGs = { @@ -353,6 +361,98 @@ def generate_articulation( return articulation, translations +# --------------------------------------------------------------------------- +# Franka task-space tracking helpers (shared between IK and OSC tests). +# --------------------------------------------------------------------------- + + +def _setup_franka_at_home_pose(sim, *, zero_actuator_pd: bool = False): + """Build a Franka articulation at its configured home pose. + + Constructs :data:`FRANKA_PANDA_HIGH_PD_CFG`, optionally zeroes the + arm-actuator PD gains, resets the simulator, and teleports the + arm joints to :attr:`default_joint_pos` (the env reset path that + normally does this is not invoked for standalone tests, so the + robot would otherwise sit at the URDF-neutral pose where the + Franka wrist is near-singular). + + Args: + sim: The simulation context to use. + zero_actuator_pd: If True, sets the panda_shoulder/panda_forearm + actuator stiffness and damping to zero. Used by the OSC test + so OSC's joint-effort output is not opposed by the + implicit-PD's residual ``kp·(target − q)``. + + Returns: + Tuple of ``(robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids)``. + """ + cfg = FRANKA_PANDA_HIGH_PD_CFG.copy().replace(prim_path="/World/Env_.*/Robot") + if zero_actuator_pd: + cfg.actuators["panda_shoulder"].stiffness = 0.0 + cfg.actuators["panda_shoulder"].damping = 0.0 + cfg.actuators["panda_forearm"].stiffness = 0.0 + cfg.actuators["panda_forearm"].damping = 0.0 + sim_utils.create_prim("/World/Env_0", "Xform", translation=(0.0, 0.0, 0.0)) + robot = Articulation(cfg) + sim.reset() + assert robot.is_initialized + + ee_frame_idx = robot.find_bodies("panda_hand")[0][0] + ee_jacobi_idx = ee_frame_idx - 1 + arm_joint_ids = robot.find_joints(["panda_joint.*"])[0] + + robot.write_joint_position_to_sim_index(position=robot.data.default_joint_pos.torch[:, :].clone()) + robot.write_joint_velocity_to_sim_index(velocity=robot.data.default_joint_vel.torch[:, :].clone()) + return robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids + + +def _compute_ee_pose_root(robot, ee_frame_idx): + """Return ``(ee_pos_b, ee_quat_b, root_pose_w)`` in the root frame.""" + ee_pose_w = robot.data.body_pose_w.torch[:, ee_frame_idx] + root_pose_w = robot.data.root_pose_w.torch + ee_pos_b, ee_quat_b = subtract_frame_transforms( + root_pose_w[:, 0:3], root_pose_w[:, 3:7], ee_pose_w[:, 0:3], ee_pose_w[:, 3:7] + ) + return ee_pos_b, ee_quat_b, root_pose_w + + +def _compute_jacobian_root_frame(robot, ee_jacobi_idx, arm_joint_ids): + """Return the EE Jacobian sliced to ``arm_joint_ids`` and rotated to the root frame.""" + jacobian = robot.data.body_link_jacobian_w.torch[:, ee_jacobi_idx, :, arm_joint_ids] + base_rot_matrix = matrix_from_quat(quat_inv(robot.data.root_pose_w.torch[:, 3:7])) + jacobian[:, :3, :] = torch.bmm(base_rot_matrix, jacobian[:, :3, :]) + jacobian[:, 3:, :] = torch.bmm(base_rot_matrix, jacobian[:, 3:, :]) + return jacobian + + +def _compute_ee_vel_root(jacobian_b, joint_vel): + """Return the EE 6D velocity in the root frame as ``J · q_dot``. + + Required to make OSC's ``kd * ee_vel_b`` damping term meaningful. + Passing zero EE velocity (the convenient hack) leaves the impedance + undamped and the EE oscillates around the target. We use ``J · q_dot`` + rather than reading ``data.body_vel_w`` because Newton's lazy + velocity buffers can return stale/zero values until forced + materialization, while ``joint_vel`` and ``J`` are already pulled + by the loop. ``J`` correctness is pinned independently by + ``test_get_jacobians_link_origin_contract``. + """ + return torch.bmm(jacobian_b, joint_vel.unsqueeze(-1)).squeeze(-1) + + +def _build_relative_pose_target(robot, ee_frame_idx, delta_xyz, device): + """Build a target pose = (current EE pose) + ``delta_xyz``, preserving orientation.""" + initial_ee_pos_b, initial_ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) + target_pos_b = initial_ee_pos_b + torch.tensor([list(delta_xyz)], device=device, dtype=initial_ee_pos_b.dtype) + return torch.cat([target_pos_b, initial_ee_quat_b], dim=-1) + + +def _summarize_history(history, tail: int = 200): + """Return ``(min, mean)`` over the last ``tail`` samples.""" + tail_slice = history[-tail:] + return min(tail_slice), sum(tail_slice) / len(tail_slice) + + @pytest.fixture def sim(request): """Create simulation context with the specified device.""" @@ -366,8 +466,13 @@ def sim(request): else: add_ground_plane = False # default to no ground plane articulation_type = request.getfixturevalue("articulation_type") - sim_cfg = SIM_CFGs[articulation_type] + sim_cfg = deepcopy(SIM_CFGs[articulation_type]) sim_cfg.device = device + # ``gravity_enabled`` is silently ignored by ``build_simulation_context`` + # when an explicit ``sim_cfg`` is also passed; apply it here so the + # fixture honors what its parameter advertises. + if not gravity_enabled: + sim_cfg.gravity = (0.0, 0.0, 0.0) with build_simulation_context( device=device, auto_add_lighting=True, @@ -715,7 +820,7 @@ def test_initialization_fixed_base_made_floating_base( sim: The simulation fixture num_articulations: Number of articulations to test """ - articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type).copy() # Unfix root link by making it non-kinematic articulation_cfg.spawn.articulation_props.fix_root_link = False articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) @@ -2442,5 +2547,647 @@ def test_randomize_rigid_body_collider_offsets(sim, num_articulations, device, a torch.testing.assert_close(updated_gap, new_gap) +@pytest.mark.parametrize("num_articulations", [1]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.isaacsim_ci +@pytest.mark.xfail( + strict=True, + raises=NotImplementedError, + reason=( + "Newton's ArticulationView exposes eval_fk / eval_jacobian /" + " eval_mass_matrix only — no inverse-dynamics primitive yet." + " Upstream Newton is actively working on this through the inverse-" + " dynamics feature request (https://github.com/newton-physics/newton/issues/2497)" + " and its sub-task for Coriolis + gravity compensation" + " (https://github.com/newton-physics/newton/issues/2529). A known" + " correctness bug for floating-base + non-identity root pose is" + " tracked separately at" + " https://github.com/newton-physics/newton/issues/2625, and" + " informs why we deliberately do NOT roll our own J^T·m·g shim in" + " this PR — Newton's eventual primitive is going through RNEA via" + " MuJoCo Warp and may differ at corner cases we wouldn't catch." + " Once the wrapper at" + " isaaclab_newton.assets.ArticulationData.gravity_compensation_forces" + " switches from a NotImplementedError stub to a real implementation" + " (likely calling the new Newton primitive), this XFAIL will turn" + " into XPASS and fail under strict=True. The maintainer should" + " then: (1) drop this xfail or invert it into a positive value" + " assertion against PhysX (the cross-backend accuracy diff), and" + " (2) remove the OSC config-time guidance about setting" + " gravity_compensation=False on Newton." + ), +) +def test_get_gravity_compensation_forces_not_implemented_on_newton(sim, num_articulations, device, articulation_type): + """Pin the known Newton gravity-compensation gap. + + See the ``xfail`` marker for full rationale. The body simply invokes the + wrapper and lets the strict-xfail marker handle the expected failure. + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + + _ = articulation.data.gravity_compensation_forces + + +## +# Shape-contract regression tests for the new BaseArticulation accessors. +# These pin the public shape contract so future regressions (e.g., reverting +# to model-wide max sizing or to the wrong fixed-base row offset) fail fast. +## + + +@pytest.mark.parametrize("num_articulations", [1, 4]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.isaacsim_ci +def test_get_jacobians_shape_fixed_base(sim, num_articulations, device, articulation_type): + """Fixed-base ``body_link_jacobian_w`` must drop the fixed-root row. + + Contract: shape ``(N, num_bodies - 1, 6, num_joints)``. Catches + regressions of (a) the link_offset fix that drops Newton's row 0 for + fixed-base, and (b) the per-articulation output sizing — using + model-wide ``max_links`` here would over-allocate in heterogeneous + scenes. + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + assert articulation.is_fixed_base, "panda fixture must be fixed-base for this test" + + J = articulation.data.body_link_jacobian_w.torch + + expected_shape = (num_articulations, articulation.num_bodies - 1, 6, articulation.num_joints) + assert J.shape == torch.Size(expected_shape), f"expected {expected_shape}, got {tuple(J.shape)}" + assert J.dtype == torch.float32 + + +@pytest.mark.parametrize("num_articulations", [1, 4]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.isaacsim_ci +def test_get_mass_matrix_shape_and_nonsingular_fixed_base(sim, num_articulations, device, articulation_type): + """Fixed-base ``mass_matrix`` shape + non-singularity. + + Contract: shape ``(N, num_joints, num_joints)`` and the matrix must be + non-singular. The non-singularity check catches the heterogeneous + padding bug — if the wrapper accidentally returns ``model.max_dofs`` + sized output, the padded zero rows/cols make the matrix rank-deficient. + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + + sim.step() + articulation.update(sim.cfg.dt) + + M = articulation.data.mass_matrix.torch + + expected_shape = (num_articulations, articulation.num_joints, articulation.num_joints) + assert M.shape == torch.Size(expected_shape), f"expected {expected_shape}, got {tuple(M.shape)}" + assert M.dtype == torch.float32 + + # Each diagonal entry is a joint's effective inertia and must be strictly + # positive for any physical articulation. Padded zero rows/cols (the + # heterogeneous bug) would surface as zero diagonal entries — much more + # sensitive than checking the determinant, which can be small purely from + # numerical conditioning of a well-formed 9x9 mass matrix (Franka det + # is ~1e-13 in practice). + diag = M.diagonal(dim1=-2, dim2=-1) + assert (diag > 1e-6).all(), f"mass matrix has non-positive diagonal entries: min={diag.min()}" + + +@pytest.mark.parametrize("num_articulations", [1, 4]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.parametrize("articulation_type", ["anymal"]) +@pytest.mark.isaacsim_ci +def test_get_jacobians_shape_floating_base(sim, num_articulations, device, add_ground_plane, articulation_type): + """Floating-base ``body_link_jacobian_w`` keeps every body row and prepends 6 base-DoF columns. + + Contract for floating-base: shape + ``(N, num_bodies, 6, num_joints + num_base_dofs)`` — no fixed-root row + to drop, and the leading 6 DoF columns are the floating-base spatial- + velocity columns Newton's ``eval_jacobian`` writes for the free root + joint. Matches the cross-library industry convention (Pinocchio, Drake, + MuJoCo, RBDL, OCS2, iDynTree). + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + assert not articulation.is_fixed_base, "anymal fixture must be floating-base for this test" + + J = articulation.data.body_link_jacobian_w.torch + + expected_shape = ( + num_articulations, + articulation.num_bodies, + 6, + articulation.num_joints + articulation.num_base_dofs, + ) + assert J.shape == torch.Size(expected_shape), f"expected {expected_shape}, got {tuple(J.shape)}" + assert J.dtype == torch.float32 + + +@pytest.mark.parametrize("num_articulations", [1, 4]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.parametrize("articulation_type", ["anymal"]) +@pytest.mark.isaacsim_ci +def test_get_mass_matrix_shape_floating_base(sim, num_articulations, device, add_ground_plane, articulation_type): + """Floating-base ``mass_matrix`` shape ``(N, num_joints + 6, num_joints + 6)``. + + Includes the 6 floating-base rows/cols on the DoF axis, matching the + cross-library industry convention. + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + + sim.step() + articulation.update(sim.cfg.dt) + + M = articulation.data.mass_matrix.torch + + expected_dofs = articulation.num_joints + articulation.num_base_dofs + expected_shape = (num_articulations, expected_dofs, expected_dofs) + assert M.shape == torch.Size(expected_shape), f"expected {expected_shape}, got {tuple(M.shape)}" + assert M.dtype == torch.float32 + + +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.parametrize("articulation_type", ["anymal"]) +@pytest.mark.isaacsim_ci +def test_heterogeneous_scene_per_view_shapes(sim, device, add_ground_plane, articulation_type): + """Mixed-articulation scene: each view returns ITS OWN asset's shape. + + Direct regression test for the Codex round-2 finding. With Franka + (9 DoFs) and Anymal-C (18 DoFs) co-resident in the model, + ``model.max_dofs_per_articulation == 18`` and + ``model.max_joints_per_articulation == anymal.num_bodies``. The Franka + view's ``body_link_jacobian_w`` / ``mass_matrix`` outputs must use + Franka's per-asset counts, NOT the model-wide maxima — otherwise + Franka's mass matrix would carry zero-padded rows/cols and be + singular. + + Uses the ``anymal`` ``SIM_CFGs`` entry (more capable solver settings) + for the host sim; the ``articulation_type`` parametrize is only there + so the ``sim`` fixture picks a config — the test itself constructs + both Anymal and Franka articulations directly. + """ + # ``num_per_type=1`` keeps the actuator-default replication path off — + # Newton's USD default loader hits a (1, num_joints) vs (num_envs, + # num_joints) shape mismatch with multi-instance multi-type scenes; one + # of each is the minimum heterogeneous setup that still exercises the + # per-articulation shape gate without that pre-existing quirk. + num_per_type = 1 + + franka_cfg = FRANKA_PANDA_CFG.replace(prim_path="/World/Env_franka_.*/Robot") + anymal_cfg = ANYMAL_C_CFG.replace(prim_path="/World/Env_anymal_.*/Robot") + + for i in range(num_per_type): + sim_utils.create_prim(f"/World/Env_franka_{i}", "Xform", translation=(2.5 * i, 0.0, 0.0)) + sim_utils.create_prim(f"/World/Env_anymal_{i}", "Xform", translation=(2.5 * i, 5.0, 0.0)) + + franka = Articulation(franka_cfg) + anymal = Articulation(anymal_cfg) + sim.reset() + assert franka.is_initialized and anymal.is_initialized + assert franka.is_fixed_base and not anymal.is_fixed_base + + # Sanity: the model-wide maxima are larger than at least one view's + # per-asset count, so a regression to model-wide sizing would manifest + # as wrong shapes here. Assert that precondition explicitly so the test + # fails clearly if the fixture stops being heterogeneous. + model = SimulationManager.get_model() + assert model.max_dofs_per_articulation > min(franka.num_joints, anymal.num_joints), ( + "scene is no longer heterogeneous; this test relies on model.max_dofs > one view's num_joints" + ) + + franka_J = franka.data.body_link_jacobian_w.torch + anymal_J = anymal.data.body_link_jacobian_w.torch + + # Each view's output uses its OWN per-asset count, not the model-wide max. + # Floating-base assets prepend ``num_base_dofs`` floating-base columns; fixed-base + # assets have ``num_base_dofs == 0``. + franka_dofs = franka.num_joints + franka.num_base_dofs + anymal_dofs = anymal.num_joints + anymal.num_base_dofs + assert franka_J.shape == torch.Size((num_per_type, franka.num_bodies - 1, 6, franka_dofs)), ( + f"Franka jacobian leaked model-wide shape: got {tuple(franka_J.shape)}" + ) + assert anymal_J.shape == torch.Size((num_per_type, anymal.num_bodies, 6, anymal_dofs)), ( + f"Anymal jacobian leaked model-wide shape: got {tuple(anymal_J.shape)}" + ) + + sim.step() + franka.update(sim.cfg.dt) + anymal.update(sim.cfg.dt) + + franka_M = franka.data.mass_matrix.torch + anymal_M = anymal.data.mass_matrix.torch + + assert franka_M.shape == torch.Size((num_per_type, franka_dofs, franka_dofs)) + assert anymal_M.shape == torch.Size((num_per_type, anymal_dofs, anymal_dofs)) + + # Each view's mass matrix must have positive diagonals — padded zero + # rows/cols (the round-2 bug) would surface as zero diagonals on the + # smaller-DoF view. Using a per-diagonal check here instead of det() + # because det of a real Franka mass matrix is naturally ~1e-13. + assert (franka_M.diagonal(dim1=-2, dim2=-1) > 1e-6).all(), ( + "Franka mass matrix has non-positive diagonal under heterogeneous scene" + ) + assert (anymal_M.diagonal(dim1=-2, dim2=-1) > 1e-6).all(), ( + "Anymal mass matrix has non-positive diagonal under heterogeneous scene" + ) + + +@pytest.mark.parametrize("num_articulations", [4]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_get_jacobians_link_origin_contract(sim, num_articulations, device, articulation_type, gravity_enabled): + """``J · q_dot`` must encode the link-origin twist (after the COM->origin shift). + + The IsaacLab task-space controllers (IK / OSC / RMPFlow) silently + rely on :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` + returning a Jacobian whose linear rows reference each link's origin + (the body's USD prim transform), not its COM. Newton's ``eval_jacobian`` + natively produces COM-referenced rows; the wrapper applies a per-column + shift ``v_origin = v_com - omega x (R · body_com_pos_b)`` to honor the + contract. This test asserts the identity by computing both sides + independently: + + * Predicted by ``J · q_dot``: takes the (already-shifted) Jacobian + and the same ``q_dot`` Newton has post-step. Linear rows should + equal v_origin. + * Ground truth from ``state.body_qd``: read Newton's per-body spatial + twist directly via ``ArticulationView.get_link_velocities`` (which + returns ``(v_com_world, omega_world)``), then apply the same shift + in python and compare. + + Reading the velocity from the ArticulationView state rather than + ``data.body_com_lin_vel_w`` bypasses the IsaacLab lazy-buffer chain, + which is irrelevant to the contract being tested. + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + + # Reproducible non-trivial q_dot — large enough to drive omega well above + # the floor where COM offset effects would round into noise. + torch.manual_seed(0) + qdot = torch.randn(num_articulations, articulation.num_joints, device=device) * 0.5 + articulation.write_joint_velocity_to_sim_index(velocity=qdot) + sim.step() + articulation.update(sim.cfg.dt) + + # body_link_jacobian_w prepends ``num_base_dofs`` floating-base columns; slice past + # them so the joint axis aligns with joint_vel (actuated-only). + J = articulation.data.body_link_jacobian_w.torch[..., articulation.num_base_dofs :] + qdot_view = articulation.data.joint_vel.torch + v_pred = torch.einsum("nbij,nj->nbi", J, qdot_view) # (N, B_jac, 6) + v_pred_lin = v_pred[..., 0:3] + v_pred_ang = v_pred[..., 3:6] + + # Ground truth from Newton state. ``get_link_velocities`` returns shape + # (num_instances, 1, num_bodies, 6) — per-articulation grouping with + # one articulation per instance — so we squeeze the inner dim. + state = SimulationManager.get_state_0() + body_qd_view = wp.to_torch(articulation.root_view.get_link_velocities(state)).squeeze(1) + body_v_com = body_qd_view[..., :3] + body_omega = body_qd_view[..., 3:] + + # World-frame COM-to-origin offset, derived from already-computed + # data layer outputs (avoids quaternion-convention pitfalls). + body_com_pos_w = articulation.data.body_com_pos_w.torch # (N, num_bodies, 3) + body_link_pos_w = articulation.data.body_link_pos_w.torch # (N, num_bodies, 3) + c_world = body_com_pos_w - body_link_pos_w + + if articulation.is_fixed_base: + body_v_com = body_v_com[:, 1:] + body_omega = body_omega[:, 1:] + c_world = c_world[:, 1:] + + # Expected v_origin = v_com - omega x c_world. + v_origin_expected = body_v_com - torch.cross(body_omega, c_world, dim=-1) + + # Tolerance: 5 mm absolute. The COM-offset bug produces a ~3 cm bias + # on the panda hand under the 0.5-rad/s injected qdot, well above + # this floor; numerical noise from kernel ordering stays under 1 mm. + torch.testing.assert_close(v_pred_ang, body_omega, atol=5e-3, rtol=1e-2) + torch.testing.assert_close(v_pred_lin, v_origin_expected, atol=5e-3, rtol=1e-2) + + +@pytest.mark.parametrize("num_articulations", [4]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_get_mass_matrix_symmetry_pd(sim, num_articulations, device, articulation_type, gravity_enabled): + """The joint-space mass matrix ``M(q)`` must be square, symmetric, and positive-definite. + + This pins three structural properties of + :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix`: + + * **Square**: shape ``(N, num_joints + num_base_dofs, num_joints + num_base_dofs)``. + A transposed gather or a non-square scratch buffer would be caught + here before downstream OSC inversion silently propagates garbage. + * **Symmetric**: ``M == M.T`` to numerical precision. The joint- + space inertia tensor is symmetric by construction; an asymmetric + result indicates a wrong-axis gather, half-populated buffer, or + Cholesky-input bug. + * **Positive-definite**: ``torch.linalg.cholesky(M)`` succeeds. OSC + computes ``M_b = (J · M^-1 · J^T)^-1`` which requires PD on every + step. A non-PD M would fail downstream as ``LinAlgError``; this + test catches it earlier and pinpoints the source. + + Parameterized on both fixed-base (panda) and floating-base (anymal). + Both backends include the floating-base DoF rows/cols on the front of + the DoF axis for floating-base assets. + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + + sim.step() + articulation.update(sim.cfg.dt) + + M = articulation.data.mass_matrix.torch # (N, J, J) + assert M.dim() == 3, f"expected 3-D mass matrix, got shape {tuple(M.shape)}" + assert M.shape[0] == num_articulations + assert M.shape[1] == M.shape[2], f"mass matrix is not square: {tuple(M.shape)}" + + # Symmetric to numerical precision. + asym = (M - M.transpose(-1, -2)).abs().max().item() + assert asym < 1e-4, f"|M - M^T|_max = {asym:.3e} — mass matrix is not symmetric" + + # Positive-definite via Cholesky. Adds a tiny diagonal jitter to + # tolerate the floor of float32 PD eigenvalues without masking real + # non-PD bugs (the jitter is well below realistic inertia scales). + eye = torch.eye(M.shape[-1], device=M.device, dtype=M.dtype).expand_as(M) + torch.linalg.cholesky(M + 1e-6 * eye) + + +@pytest.mark.parametrize("num_articulations", [1]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_jacobian_refreshes_after_manual_joint_write( + sim, num_articulations, device, articulation_type, gravity_enabled +): + """After ``write_joint_position_to_sim_index`` (no sim step), the Jacobian read + must reflect the new joint state — not the previous one. + + Catches: + - Missing FK trigger in :attr:`body_com_jacobian_w` (eval_jacobian uses stale + ``state.body_q``). + - Missing FK trigger in :attr:`body_link_jacobian_w` shift kernel. + + The contract: ``J`` read directly after a manual write must equal ``J`` read + after ``sim.step + update`` — the latter is the ground-truth fresh-FK reference. + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + sim.step() + articulation.update(sim.cfg.dt) + + # Read J / M at the baseline joint state. + q_baseline = articulation.data.joint_pos.torch.clone() + J_link_0 = articulation.data.body_link_jacobian_w.torch.clone() + J_com_0 = articulation.data.body_com_jacobian_w.torch.clone() + + # Manually write a different joint state — large delta to make Jacobian change visible. + # No sim.step / update — FK becomes stale (write_joint_position_to_sim sets _fk_timestamp = -1). + q_target = q_baseline + 0.5 + env_ids = wp.array([0], dtype=wp.int32, device=device) + articulation.write_joint_position_to_sim_index(position=q_target, env_ids=env_ids) + + # If the FK trigger works: forward() runs, body_q is refreshed to match q_target, + # eval_jacobian / shift kernel see fresh body poses, J reflects q_target → differs from J at baseline. + # If the trigger is missing: body_q stays at baseline, J unchanged from J_link_0 / J_com_0. + J_link_1 = articulation.data.body_link_jacobian_w.torch.clone() + J_com_1 = articulation.data.body_com_jacobian_w.torch.clone() + + assert not torch.allclose(J_link_0, J_link_1, atol=1e-3), ( + "body_link_jacobian_w did not change after manual joint write — " + "FK trigger likely missing (eval_jacobian / shift kernel reading stale state.body_q)." + ) + assert not torch.allclose(J_com_0, J_com_1, atol=1e-3), ( + "body_com_jacobian_w did not change after manual joint write — FK trigger likely missing before eval_jacobian." + ) + + +@pytest.mark.parametrize("num_articulations", [1]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_mass_matrix_refreshes_after_manual_joint_write( + sim, num_articulations, device, articulation_type, gravity_enabled +): + """After ``write_joint_position_to_sim_index`` (no sim step), the mass matrix read + must reflect the new joint state. + + The mass matrix depends on ``q`` (joint positions) through the body-spatial-inertia + transformation in eval_mass_matrix's ``compute_body_spatial_inertia`` step, which + reads ``state.body_q``. Same FK-staleness pattern as the Jacobian. + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + sim.step() + articulation.update(sim.cfg.dt) + + M_0 = articulation.data.mass_matrix.torch.clone() + q_target = articulation.data.joint_pos.torch.clone() + 0.5 + env_ids = wp.array([0], dtype=wp.int32, device=device) + articulation.write_joint_position_to_sim_index(position=q_target, env_ids=env_ids) + M_1 = articulation.data.mass_matrix.torch.clone() + + assert not torch.allclose(M_0, M_1, atol=1e-3), ( + "mass_matrix did not change after manual joint write — " + "FK trigger likely missing before eval_mass_matrix (compute_body_spatial_inertia " + "reads stale state.body_q)." + ) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_franka_ik_tracking_accuracy(sim, device, articulation_type, gravity_enabled): + """Newton-side IK convergence sentinel. + + Runs a full IK tracking loop end-to-end through the new + ``robot.data.body_link_jacobian_w`` accessor and records the steady-state EE + pose error. With the robot teleported to its configured init_state + home pose and scene gravity off, Newton's IK converges to + machine-precision tracking (sub-mm). A bridge regression + (wrong-reference-frame Jacobian, missing COM->origin shift, DoF + mis-ordering) would push the steady-state error well above the + threshold below. + + The pose teleport is deliberate: the standalone test path does not + invoke a manager-based env reset (which is what normally pushes + :attr:`~isaaclab.assets.ArticulationData.default_joint_pos` to sim). + Without it, the robot starts at the URDF-neutral pose where the + Franka wrist axes nearly align (rank-deficient Jacobian) and DLS + plateaus at multi-cm error -- a kinematic-singularity artifact, not + a bridge or Newton issue. + + See ``test_get_jacobians_link_origin_contract`` (above) for the + sharper unit-level pin on the Jacobian's reference-point contract. + """ + robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids = _setup_franka_at_home_pose(sim) + + sim.step() + robot.update(sim.cfg.dt) + target_pose_b = _build_relative_pose_target(robot, ee_frame_idx, (0.05, 0.0, 0.0), device) + + ik = DifferentialIKController( + DifferentialIKControllerCfg(command_type="pose", use_relative_mode=False, ik_method="dls"), + num_envs=1, + device=device, + ) + ik.set_command(target_pose_b) + + pos_history: list[float] = [] + rot_history: list[float] = [] + for _ in range(800): + jacobian = _compute_jacobian_root_frame(robot, ee_jacobi_idx, arm_joint_ids) + ee_pos_b, ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) + joint_pos = robot.data.joint_pos.torch[:, arm_joint_ids] + + joint_pos_des = ik.compute(ee_pos_b, ee_quat_b, jacobian, joint_pos) + + robot.set_joint_position_target(joint_pos_des, joint_ids=arm_joint_ids) + robot.write_data_to_sim() + sim.step() + robot.update(sim.cfg.dt) + + pos_error, rot_error = compute_pose_error(ee_pos_b, ee_quat_b, target_pose_b[:, 0:3], target_pose_b[:, 3:7]) + pos_history.append(pos_error.norm(dim=-1).max().item()) + rot_history.append(rot_error.norm(dim=-1).max().item()) + + pos_min, pos_mean = _summarize_history(pos_history) + rot_min, rot_mean = _summarize_history(rot_history) + + # Print metrics every run for stress-test capture. + print(f"IK_METRIC pos_min={pos_min:.5f} pos_mean={pos_mean:.5f} rot_min={rot_min:.5f} rot_mean={rot_mean:.5f}") + + # Regression sentinel: assert on tail mean rather than min. Tail + # min is the bottom of any oscillation envelope and can be tiny + # while the actual tracking error is much larger. With the + # configured home pose and scene gravity off, Newton converges to + # machine precision (sub-mm). The 5 mm bound absorbs any CUDA- + # kernel-ordering noise while remaining well below the "totally + # broken" regime: a bridge regression (wrong-frame Jacobian, + # missing COM->origin shift, DoF mis-ordering) would push the + # steady-state error well past this bound. + assert pos_mean < 5e-3, f"IK pos_mean {pos_mean:.5f} > 5 mm — bridge regression?" + assert rot_mean < 5e-2, f"IK rot_mean {rot_mean:.5f} > 0.05 rad — bridge regression?" + + +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_franka_osc_tracking_accuracy(sim, device, articulation_type, gravity_enabled): + """Newton-side OSC pose tracking sentinel. + + Mirror of the existing PhysX-side OSC tests in + :mod:`isaaclab.test.controllers.test_operational_space`, scoped to + Franka pose-abs tracking on Newton. Like the IK sentinel above, this + test exercises the full controller-bridge pipeline + (:attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` + + :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix`) end-to-end + and asserts a loose regression bound rather than a tight correctness + oracle. + + Newton lacks a gravity-comp primitive (see ``xfail`` test below; + upstream Newton issues #2497, #2529, #2625), so OSC runs with + ``gravity_compensation=False`` and the test isolates from gravity by + disabling scene gravity. ``inertial_dynamics_decoupling=True`` + exercises ``mass_matrix`` and the Newton COM-referenced J → + M_b → J product. The actuator PD is zeroed at cfg time so OSC's + joint-effort output is not opposed by ``kp·(target − q)``. + """ + robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids = _setup_franka_at_home_pose(sim, zero_actuator_pd=True) + + osc = OperationalSpaceController( + OperationalSpaceControllerCfg( + target_types=["pose_abs"], + impedance_mode="fixed", + inertial_dynamics_decoupling=True, + partial_inertial_dynamics_decoupling=False, + gravity_compensation=False, + motion_stiffness_task=500.0, + motion_damping_ratio_task=1.0, + ), + num_envs=1, + device=device, + ) + + sim.step() + robot.update(sim.cfg.dt) + target_pose_b = _build_relative_pose_target(robot, ee_frame_idx, (0.05, 0.0, 0.0), device) + + pos_history: list[float] = [] + rot_history: list[float] = [] + for _ in range(800): + jacobian_b = _compute_jacobian_root_frame(robot, ee_jacobi_idx, arm_joint_ids) + mass_matrix = robot.data.mass_matrix.torch[:, arm_joint_ids, :][:, :, arm_joint_ids] + ee_pos_b, ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) + ee_pose_b = torch.cat([ee_pos_b, ee_quat_b], dim=-1) + joint_vel = robot.data.joint_vel.torch[:, arm_joint_ids] + ee_vel_b = _compute_ee_vel_root(jacobian_b, joint_vel) + + osc.set_command(target_pose_b, current_ee_pose_b=ee_pose_b) + joint_efforts = osc.compute( + jacobian_b=jacobian_b, + current_ee_pose_b=ee_pose_b, + current_ee_vel_b=ee_vel_b, + mass_matrix=mass_matrix, + gravity=None, + ) + + robot.set_joint_effort_target(joint_efforts, joint_ids=arm_joint_ids) + robot.write_data_to_sim() + sim.step() + robot.update(sim.cfg.dt) + + pos_error, rot_error = compute_pose_error(ee_pos_b, ee_quat_b, target_pose_b[:, 0:3], target_pose_b[:, 3:7]) + pos_history.append(pos_error.norm(dim=-1).max().item()) + rot_history.append(rot_error.norm(dim=-1).max().item()) + + pos_min, pos_mean = _summarize_history(pos_history) + rot_min, rot_mean = _summarize_history(rot_history) + + print(f"OSC_METRIC pos_min={pos_min:.5f} pos_mean={pos_mean:.5f} rot_min={rot_min:.5f} rot_mean={rot_mean:.5f}") + + # Regression sentinel: assert on tail mean rather than min. With + # ``current_ee_vel_b = J · q_dot`` providing OSC's damping term and + # the actuator PD zeroed, the impedance settles to machine + # precision -- same ballpark as the IK test. The 5 mm bound is a + # bridge regression sentinel: a wrong J, wrong mass matrix, or + # DoF mis-ordering pushes the steady-state error well past it + # because OSC consumes both ``body_link_jacobian_w`` and + # ``mass_matrix`` per step. + assert pos_mean < 5e-3, f"OSC pos_mean {pos_mean:.5f} > 5 mm — bridge regression?" + assert rot_mean < 5e-2, f"OSC rot_mean {rot_mean:.5f} > 0.05 rad — bridge regression?" + + if __name__ == "__main__": pytest.main([__file__, "-v", "--maxfail=1"]) diff --git a/source/isaaclab_ovphysx/changelog.d/jichuanh-ik-newton-compat-mvp.rst b/source/isaaclab_ovphysx/changelog.d/jichuanh-ik-newton-compat-mvp.rst new file mode 100644 index 000000000000..f2cc47afe8a2 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/jichuanh-ik-newton-compat-mvp.rst @@ -0,0 +1,12 @@ +Changed +^^^^^^^ + +* Inherits the base + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w`, + :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w`, + :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix`, and + :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` + :class:`NotImplementedError` defaults — ovphysx's OmniGraph-based view + does not expose articulation Jacobians, mass matrices, or gravity + compensation. Use the PhysX or Newton backends for task-space + controllers. diff --git a/source/isaaclab_physx/changelog.d/jichuanh-ik-newton-compat-mvp.rst b/source/isaaclab_physx/changelog.d/jichuanh-ik-newton-compat-mvp.rst new file mode 100644 index 000000000000..8ffa5ad63b15 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/jichuanh-ik-newton-compat-mvp.rst @@ -0,0 +1,31 @@ +Added +^^^^^ + +* Added PhysX implementations of + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w`, + :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w`, + :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix`, and + :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` + on :class:`~isaaclab_physx.assets.ArticulationData`. The COM + variant is a passthrough to ``physx.ArticulationView.get_jacobians``; + the link-origin variant applies a new + :func:`~isaaclab_physx.assets.articulation.kernels.shift_jacobian_com_to_origin` + Warp kernel to convert the COM-referenced linear-velocity rows to + link-origin references using each body's pose and COM offset. All + four properties preserve the full DoF axis, including the 6 leading + floating-base columns/rows PhysX's raw tensor view prepends on + floating-base assets — matching the cross-library industry convention + (Pinocchio, Drake, MuJoCo, RBDL, OCS2, iDynTree) and Newton's + ``ArticulationView`` layout. + +Fixed +^^^^^ + +* Fixed a latent correctness bug in IK / OSC controllers on the PhysX + backend, where the previously-exposed Jacobian was COM-referenced but + the controllers used :attr:`~isaaclab_physx.assets.ArticulationData.body_link_pose_w` + as the EE pose setpoint. The frame mismatch caused tracking error on + bodies whose COM offset is non-trivial. The new + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` + applies the COM→origin shift so the Jacobian and pose share a + reference point. diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index 6258b5c5b8e4..ea2c85e4c3bd 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -464,6 +464,10 @@ def write_root_link_pose_to_sim_index( self.data._body_state_w.timestamp = -1.0 self.data._body_link_state_w.timestamp = -1.0 self.data._body_com_state_w.timestamp = -1.0 + # Task-space accessors: body-frame Jacobian + gravity comp depend on root orientation; + # mass_matrix does not (configuration-space). + self.data._body_com_jacobian_w.timestamp = -1.0 + self.data._gravity_compensation_forces.timestamp = -1.0 # set into simulation self.root_view.set_root_transforms(self._get_root_link_pose_w_f32(), indices=env_ids) @@ -553,6 +557,10 @@ def write_root_com_pose_to_sim_index( self.data._body_state_w.timestamp = -1.0 self.data._body_link_state_w.timestamp = -1.0 self.data._body_com_state_w.timestamp = -1.0 + # Task-space accessors: body-frame Jacobian + gravity comp depend on root orientation; + # mass_matrix does not (configuration-space). + self.data._body_com_jacobian_w.timestamp = -1.0 + self.data._gravity_compensation_forces.timestamp = -1.0 # set into simulation self.root_view.set_root_transforms(self._get_root_link_pose_w_f32(), indices=env_ids) @@ -874,6 +882,10 @@ def write_joint_state_to_sim_index( self.data._body_state_w.timestamp = -1.0 self.data._body_link_state_w.timestamp = -1.0 self.data._body_com_state_w.timestamp = -1.0 + # Task-space accessors all depend on joint state. + self.data._body_com_jacobian_w.timestamp = -1.0 + self.data._mass_matrix.timestamp = -1.0 + self.data._gravity_compensation_forces.timestamp = -1.0 # set into simulation self.root_view.set_dof_positions(self.data._joint_pos.data, indices=env_ids) self.root_view.set_dof_velocities(self.data._joint_vel.data, indices=env_ids) @@ -963,6 +975,10 @@ def write_joint_position_to_sim_index( self.data._body_state_w.timestamp = -1.0 self.data._body_link_state_w.timestamp = -1.0 self.data._body_com_state_w.timestamp = -1.0 + # Task-space accessors all depend on joint state. + self.data._body_com_jacobian_w.timestamp = -1.0 + self.data._mass_matrix.timestamp = -1.0 + self.data._gravity_compensation_forces.timestamp = -1.0 # set into simulation self.root_view.set_dof_positions(self.data._joint_pos.data, indices=env_ids) diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py index 3d7e6e9cb483..6f1ab28547c9 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py @@ -857,6 +857,77 @@ def body_com_pose_b(self) -> ProxyArray: self._body_com_pose_b_ta = ProxyArray(self._body_com_pose_b.data) return self._body_com_pose_b_ta + """ + Dynamics quantities (task-space controllers). + """ + + @property + def body_com_jacobian_w(self) -> ProxyArray: + """See :attr:`isaaclab.assets.BaseArticulationData.body_com_jacobian_w`. + + PhysX implementation: passthrough of ``_root_view.get_jacobians()``, which is + natively Center-Of-Mass-referenced. Refresh is gated by ``_sim_timestamp`` and + invalidated by ``write_*_to_sim_index``; the ``ProxyArray`` wrapper is lazy-init + once and reused thereafter. + """ + if self._body_com_jacobian_w.timestamp < self._sim_timestamp: + self._body_com_jacobian_w.data = self._root_view.get_jacobians() + self._body_com_jacobian_w.timestamp = self._sim_timestamp + if self._body_com_jacobian_w_ta is None: + self._body_com_jacobian_w_ta = ProxyArray(self._body_com_jacobian_w.data) + return self._body_com_jacobian_w_ta + + @property + def body_link_jacobian_w(self) -> ProxyArray: + """See :attr:`isaaclab.assets.BaseArticulationData.body_link_jacobian_w`. + + PhysX implementation: applies the COM→origin shift kernel to + :attr:`body_com_jacobian_w` (PhysX's engine output is COM-referenced). + """ + wp.launch( + articulation_kernels.shift_jacobian_com_to_origin, + dim=self._body_link_jacobian_w_buf.shape[:2] + (self._body_link_jacobian_w_buf.shape[3],), + inputs=[ + self.body_link_pose_w.warp, + self.body_com_pos_b.warp, + self._jacobian_link_offset, + self.body_com_jacobian_w.warp, + ], + outputs=[self._body_link_jacobian_w_buf], + device=self.device, + ) + return self._body_link_jacobian_w_ta + + @property + def mass_matrix(self) -> ProxyArray: + """See :attr:`isaaclab.assets.BaseArticulationData.mass_matrix`. + + PhysX implementation: passthrough of ``_root_view.get_generalized_mass_matrices()``. + Refresh is gated by ``_sim_timestamp`` and invalidated by ``write_*_to_sim_index``; + the ``ProxyArray`` wrapper is lazy-init once and reused thereafter. + """ + if self._mass_matrix.timestamp < self._sim_timestamp: + self._mass_matrix.data = self._root_view.get_generalized_mass_matrices() + self._mass_matrix.timestamp = self._sim_timestamp + if self._mass_matrix_ta is None: + self._mass_matrix_ta = ProxyArray(self._mass_matrix.data) + return self._mass_matrix_ta + + @property + def gravity_compensation_forces(self) -> ProxyArray: + """See :attr:`isaaclab.assets.BaseArticulationData.gravity_compensation_forces`. + + PhysX implementation: passthrough of ``_root_view.get_gravity_compensation_forces()``. + Refresh is gated by ``_sim_timestamp`` and invalidated by ``write_*_to_sim_index``; + the ``ProxyArray`` wrapper is lazy-init once and reused thereafter. + """ + if self._gravity_compensation_forces.timestamp < self._sim_timestamp: + self._gravity_compensation_forces.data = self._root_view.get_gravity_compensation_forces() + self._gravity_compensation_forces.timestamp = self._sim_timestamp + if self._gravity_compensation_forces_ta is None: + self._gravity_compensation_forces_ta = ProxyArray(self._gravity_compensation_forces.data) + return self._gravity_compensation_forces_ta + """ Joint state properties. """ @@ -1370,6 +1441,46 @@ def _create_buffers(self) -> None: self._root_com_lin_vel_b = TimestampedBuffer((self._num_instances), self.device, wp.vec3f) self._root_com_ang_vel_b = TimestampedBuffer((self._num_instances), self.device, wp.vec3f) + # -- dynamics quantities for task-space controllers + # PhysX's Jacobian rows include the root body for floating-base and exclude only the + # fixed root for fixed-base (``_jacobian_link_offset`` handles the body axis). PhysX's + # raw Jacobian / mass matrix / gravity-comp prepend 6 base-DoF columns on floating- + # base (the engine's natural form), matching the industry-standard convention used by + # Pinocchio, Drake, MuJoCo, RBDL, OCS2, and iDynTree. We pass through the full DoF + # axis: shape ``(N, num_jacobi_bodies, 6, num_joints + num_base_dofs)``. Newton wraps + # ``eval_jacobian`` to match the same column layout. ``body_com_jacobian_w`` / + # ``mass_matrix`` / ``gravity_compensation_forces`` pass through the engine buffer on + # every read; we only own a buffer for the link-origin Jacobian (output of the shift + # kernel). + is_fixed_base = self._root_view.shared_metatype.fixed_base + self._jacobian_link_offset = 1 if is_fixed_base else 0 + num_jacobi_bodies = self._num_bodies - self._jacobian_link_offset + num_base_dofs = 0 if is_fixed_base else 6 + self._body_link_jacobian_w_buf = wp.zeros( + (self._num_instances, num_jacobi_bodies, 6, self._num_joints + num_base_dofs), + dtype=wp.float32, + device=self.device, + ) + # ``TimestampedBuffer``s for the three engine-passthrough properties. The placeholder + # ``wp.zeros`` allocation is replaced on first read by the engine view returned from + # ``_root_view.get_*()``; timestamps are advanced on each refresh and invalidated by + # write-paths. + self._body_com_jacobian_w = TimestampedBuffer( + (self._num_instances, num_jacobi_bodies, 6, self._num_joints + num_base_dofs), + self.device, + wp.float32, + ) + self._mass_matrix = TimestampedBuffer( + (self._num_instances, self._num_joints + num_base_dofs, self._num_joints + num_base_dofs), + self.device, + wp.float32, + ) + self._gravity_compensation_forces = TimestampedBuffer( + (self._num_instances, self._num_joints + num_base_dofs), + self.device, + wp.float32, + ) + # Default root pose and velocity self._default_root_pose = wp.zeros((self._num_instances), dtype=wp.transformf, device=self.device) self._default_root_vel = wp.zeros((self._num_instances), dtype=wp.spatial_vectorf, device=self.device) @@ -1532,6 +1643,16 @@ def _pin_proxy_arrays(self) -> None: self._body_com_vel_w_ta: ProxyArray | None = None self._body_com_acc_w_ta: ProxyArray | None = None self._body_com_pose_b_ta: ProxyArray | None = None + # Dynamics quantities (task-space controllers). ``_body_link_jacobian_w`` wraps our + # own pre-allocated buffer (pointer-stable, eager wrap). The three engine-passthrough + # wrappers are lazy-init inside their property bodies on first read, matching the + # ``TimestampedBuffer`` + ``ProxyArray`` cache pattern used by ``body_link_pose_w``, + # ``joint_pos``, and the rest of this file. Refresh is gated by ``_sim_timestamp`` and + # invalidated by ``write_*_to_sim_index`` setting ``timestamp = -1.0``. + self._body_link_jacobian_w_ta = ProxyArray(self._body_link_jacobian_w_buf) + self._body_com_jacobian_w_ta: ProxyArray | None = None + self._mass_matrix_ta: ProxyArray | None = None + self._gravity_compensation_forces_ta: ProxyArray | None = None # Body properties self._body_mass_ta: ProxyArray | None = None self._body_inertia_ta: ProxyArray | None = None diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/kernels.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/kernels.py index 5686c864dd94..0c2e385af173 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/kernels.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/kernels.py @@ -486,3 +486,65 @@ def extract_friction_properties( out_friction[i, j] = friction_props[i, j, 0] out_dynamic_friction[i, j] = friction_props[i, j, 1] out_viscous_friction[i, j] = friction_props[i, j, 2] + + +@wp.kernel +def shift_jacobian_com_to_origin( + body_link_pose: wp.array2d(dtype=wp.transformf), + body_com_pos_b: wp.array2d(dtype=wp.vec3f), + link_offset: wp.int32, + src: wp.array4d(dtype=wp.float32), + dst: wp.array4d(dtype=wp.float32), +): + """Shift the linear-velocity rows of the Jacobian from COM to link origin. + + PhysX's ``ArticulationView.get_jacobians()`` returns ``J · q_dot = [v_com_world, omega_world]`` + per link — the linear rows are the velocity at the link's center of mass, expressed in + world frame. The :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` contract + requires the linear rows to be the velocity at the link **origin** (USD prim transform) so + that ``J · q_dot[body_idx]`` matches + :attr:`~isaaclab.assets.BaseArticulationData.body_link_lin_vel_w` / + :attr:`~isaaclab.assets.BaseArticulationData.body_link_ang_vel_w`. + + The shift identity is the same one applied per-body by + :func:`~isaaclab_physx.assets.kernels.get_link_vel_from_root_com_vel_func`, but layered onto + every Jacobian column: each column represents the spatial velocity contribution of one DoF, + and shifting a spatial velocity from COM to link origin uses ``v_origin = v_com - omega x + (R · body_com_pos_b)``. + + Notes on layout: + * Jacobian rows ``[0:3]`` are linear velocity, ``[3:6]`` are angular. + * ``body_link_pose`` and ``body_com_pos_b`` are indexed by the articulation's full body + count. PhysX's Jacobian rows are also indexed by the full body count for floating-base + and exclude only the root for fixed-base, so ``link_offset = 1`` for fixed-base and + ``link_offset = 0`` for floating-base, matching Newton's convention. + + Args: + body_link_pose: Per-body link pose in world frame. Shape is (num_instances, num_bodies). + body_com_pos_b: Per-body center-of-mass offset expressed in the body's link frame. Shape + is (num_instances, num_bodies). + link_offset: Offset added to the jacobian-row body index to reach the full body index. + ``1`` for fixed-base, ``0`` for floating-base. + src: COM-referenced Jacobian (read-only). Shape is (num_instances, num_jacobi_bodies, 6, + num_joints + num_base_dofs). + dst: Output buffer for the link-origin Jacobian. Same shape as ``src``. Linear rows + ``[0:3]`` are written with the shifted velocity; angular rows ``[3:6]`` are copied + unchanged (angular velocity is reference-point invariant). + """ + n, b, dof = wp.tid() + full_body_idx = b + link_offset + + R = wp.transform_get_rotation(body_link_pose[n, full_body_idx]) + c_world = wp.quat_rotate(R, body_com_pos_b[n, full_body_idx]) + + v_com = wp.vec3(src[n, b, 0, dof], src[n, b, 1, dof], src[n, b, 2, dof]) + omega = wp.vec3(src[n, b, 3, dof], src[n, b, 4, dof], src[n, b, 5, dof]) + + v_origin = v_com - wp.cross(omega, c_world) + + dst[n, b, 0, dof] = v_origin[0] + dst[n, b, 1, dof] = v_origin[1] + dst[n, b, 2, dof] = v_origin[2] + dst[n, b, 3, dof] = omega[0] + dst[n, b, 4, dof] = omega[1] + dst[n, b, 5, dof] = omega[2] diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index 227c091a1652..af36a365cb66 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -29,16 +29,23 @@ import isaaclab.utils.string as string_utils from isaaclab.actuators import ActuatorBase, IdealPDActuatorCfg, ImplicitActuatorCfg from isaaclab.assets import ArticulationCfg +from isaaclab.controllers import ( + DifferentialIKController, + DifferentialIKControllerCfg, + OperationalSpaceController, + OperationalSpaceControllerCfg, +) from isaaclab.envs.mdp.terminations import joint_effort_out_of_limit from isaaclab.managers import SceneEntityCfg from isaaclab.sim import build_simulation_context from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.math import compute_pose_error, matrix_from_quat, quat_inv, subtract_frame_transforms from isaaclab.utils.version import get_isaac_sim_version, has_kit ## # Pre-defined configs ## -from isaaclab_assets import ANYMAL_C_CFG, FRANKA_PANDA_CFG, SHADOW_HAND_CFG # isort:skip +from isaaclab_assets import ANYMAL_C_CFG, FRANKA_PANDA_CFG, FRANKA_PANDA_HIGH_PD_CFG, SHADOW_HAND_CFG # isort:skip def generate_articulation_cfg( @@ -182,6 +189,107 @@ def generate_articulation( return articulation, translations +# --------------------------------------------------------------------------- +# Franka task-space tracking helpers (shared between IK and OSC tests). +# Mirrors the helpers in ``isaaclab_newton/test/assets/test_articulation.py``. +# --------------------------------------------------------------------------- + + +def _setup_franka_at_home_pose(sim, *, zero_actuator_pd: bool = False, enable_rigid_body_gravity: bool = False): + """Build a Franka articulation at its configured home pose. + + See the Newton-side mirror for full docs. Standalone tests skip the + env reset path that normally pushes ``default_joint_pos`` to sim, + so we teleport explicitly to avoid the URDF-neutral + near-singular pose where the Franka wrist axes nearly align. + + Args: + sim: The simulation context to use. + zero_actuator_pd: If True, sets the panda_shoulder/panda_forearm + actuator stiffness and damping to zero. + enable_rigid_body_gravity: If True, override + ``FRANKA_PANDA_HIGH_PD_CFG.spawn.rigid_props.disable_gravity`` + (which defaults to True) so gravity actually loads the arm. Required + for any test that wants to exercise gravity-related dynamics + (e.g. gravity-compensation accuracy tests). + + Returns: + Tuple of ``(robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids)``. + """ + cfg = FRANKA_PANDA_HIGH_PD_CFG.copy().replace(prim_path="/World/Env_.*/Robot") + if zero_actuator_pd: + cfg.actuators["panda_shoulder"].stiffness = 0.0 + cfg.actuators["panda_shoulder"].damping = 0.0 + cfg.actuators["panda_forearm"].stiffness = 0.0 + cfg.actuators["panda_forearm"].damping = 0.0 + if enable_rigid_body_gravity: + cfg = cfg.replace( + spawn=cfg.spawn.replace( + rigid_props=cfg.spawn.rigid_props.replace(disable_gravity=False), + ), + ) + sim_utils.create_prim("/World/Env_0", "Xform", translation=(0.0, 0.0, 0.0)) + robot = Articulation(cfg) + sim.reset() + assert robot.is_initialized + + ee_frame_idx = robot.find_bodies("panda_hand")[0][0] + ee_jacobi_idx = ee_frame_idx - 1 + arm_joint_ids = robot.find_joints(["panda_joint.*"])[0] + + robot.write_joint_state_to_sim( + position=robot.data.default_joint_pos.torch[:, :].clone(), + velocity=robot.data.default_joint_vel.torch[:, :].clone(), + ) + return robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids + + +def _compute_ee_pose_root(robot, ee_frame_idx): + """Return ``(ee_pos_b, ee_quat_b, root_pose_w)`` in the root frame.""" + ee_pose_w = robot.data.body_pose_w.torch[:, ee_frame_idx] + root_pose_w = robot.data.root_pose_w.torch + ee_pos_b, ee_quat_b = subtract_frame_transforms( + root_pose_w[:, 0:3], root_pose_w[:, 3:7], ee_pose_w[:, 0:3], ee_pose_w[:, 3:7] + ) + return ee_pos_b, ee_quat_b, root_pose_w + + +def _compute_jacobian_root_frame(robot, ee_jacobi_idx, arm_joint_ids): + """Return the EE Jacobian sliced to ``arm_joint_ids`` and rotated to the root frame.""" + jacobian = robot.data.body_link_jacobian_w.torch[:, ee_jacobi_idx, :, :][:, :, arm_joint_ids] + base_rot_matrix = matrix_from_quat(quat_inv(robot.data.root_pose_w.torch[:, 3:7])) + jacobian[:, :3, :] = torch.bmm(base_rot_matrix, jacobian[:, :3, :]) + jacobian[:, 3:, :] = torch.bmm(base_rot_matrix, jacobian[:, 3:, :]) + return jacobian + + +def _compute_ee_vel_root(jacobian_b, joint_vel): + """Return the EE 6D velocity in the root frame as ``J · q_dot``. + + Required to make OSC's ``kd * ee_vel_b`` damping term meaningful. + Passing zero EE velocity (the convenient hack) leaves the impedance + undamped and the EE oscillates around the target. ``J · q_dot`` + avoids relying on ``data.body_vel_w`` (Newton's lazy velocity + buffers can return stale/zero values until forced materialization), + keeping the helper backend-symmetric. ``J`` correctness is pinned + independently by ``test_get_jacobians_link_origin_contract``. + """ + return torch.bmm(jacobian_b, joint_vel.unsqueeze(-1)).squeeze(-1) + + +def _build_relative_pose_target(robot, ee_frame_idx, delta_xyz, device): + """Build a target pose = (current EE pose) + ``delta_xyz``, preserving orientation.""" + initial_ee_pos_b, initial_ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) + target_pos_b = initial_ee_pos_b + torch.tensor([list(delta_xyz)], device=device, dtype=initial_ee_pos_b.dtype) + return torch.cat([target_pos_b, initial_ee_quat_b], dim=-1) + + +def _summarize_history(history, tail: int = 200): + """Return ``(min, mean)`` over the last ``tail`` samples.""" + tail_slice = history[-tail:] + return min(tail_slice), sum(tail_slice) / len(tail_slice) + + @pytest.fixture def sim(request): """Create simulation context with the specified device.""" @@ -572,7 +680,7 @@ def test_initialization_fixed_base_made_floating_base(sim, num_articulations, de sim: The simulation fixture num_articulations: Number of articulations to test """ - articulation_cfg = generate_articulation_cfg(articulation_type="panda") + articulation_cfg = generate_articulation_cfg(articulation_type="panda").copy() # Unfix root link by making it non-kinematic articulation_cfg.spawn.articulation_props.fix_root_link = False articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) @@ -2127,5 +2235,635 @@ def test_set_material_properties(sim, num_articulations, device, add_ground_plan torch.testing.assert_close(materials_check, materials) +## +# Shape-contract regression tests for the new BaseArticulation accessors. +# Mirror the Newton-side tests so both backends can be diffed against the +# same documented contract. These are PhysX's reference shapes — when the +# Newton-side tests pass with the same expected_shape formulas, the +# cross-backend contract holds. +## + + +@pytest.mark.parametrize("num_articulations", [1, 4]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.isaacsim_ci +def test_get_jacobians_shape_fixed_base(sim, num_articulations, device, articulation_type): + """PhysX reference: fixed-base ``body_link_jacobian_w`` is ``(N, num_bodies-1, 6, num_joints)``.""" + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + assert articulation.is_fixed_base + + J = articulation.data.body_link_jacobian_w.torch + expected = (num_articulations, articulation.num_bodies - 1, 6, articulation.num_joints) + assert J.shape == torch.Size(expected), f"expected {expected}, got {tuple(J.shape)}" + + +@pytest.mark.parametrize("num_articulations", [1, 4]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.isaacsim_ci +def test_get_mass_matrix_shape_and_nonsingular_fixed_base(sim, num_articulations, device, articulation_type): + """PhysX reference: fixed-base ``mass_matrix`` shape + non-singular.""" + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + + sim.step() + articulation.update(sim.cfg.dt) + + M = articulation.data.mass_matrix.torch + expected = (num_articulations, articulation.num_joints, articulation.num_joints) + assert M.shape == torch.Size(expected), f"expected {expected}, got {tuple(M.shape)}" + + # Each diagonal entry is the joint's effective inertia and must be positive + # for any physical articulation. Padded zero rows/cols (the bug) would show + # up here as zero diagonal entries — much more sensitive than checking the + # determinant, which can be small for a well-conditioned 9x9 just from + # numerical cancellation. + diag = M.diagonal(dim1=-2, dim2=-1) + assert (diag > 1e-6).all(), f"mass matrix has non-positive diagonal entries: min={diag.min()}" + + +@pytest.mark.parametrize("num_articulations", [1, 4]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.parametrize("articulation_type", ["anymal"]) +@pytest.mark.isaacsim_ci +def test_get_jacobians_shape_floating_base(sim, num_articulations, device, add_ground_plane, articulation_type): + """PhysX reference: floating-base ``body_link_jacobian_w``. + + Floating-base articulations include the 6 floating-base spatial-velocity columns + at the front of the DoF axis, so the shape is + ``(N, num_bodies, 6, num_joints + num_base_dofs)`` — matching Newton and the + cross-library industry convention (Pinocchio, Drake, MuJoCo, RBDL, OCS2, + iDynTree). + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + assert not articulation.is_fixed_base + + J = articulation.data.body_link_jacobian_w.torch + expected = (num_articulations, articulation.num_bodies, 6, articulation.num_joints + articulation.num_base_dofs) + assert J.shape == torch.Size(expected), f"expected {expected}, got {tuple(J.shape)}" + + +@pytest.mark.parametrize("num_articulations", [4]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_get_jacobians_link_origin_contract(sim, num_articulations, device, articulation_type, gravity_enabled): + """PhysX reference: ``J · q_dot`` matches ``[body_link_lin_vel_w; body_link_ang_vel_w]``. + + The cross-backend contract on + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` says + the Jacobian's linear rows reference each body's link origin. PhysX's + raw ``_root_view.get_jacobians()`` returns COM-referenced linear rows; + the IsaacLab wrapper applies the COM→origin shift kernel so the contract + holds. This test pins the identity from the PhysX side and parametrizes + on Anymal so the (non-trivial) shift surfaces if it ever regresses. + + Scene gravity is disabled (``gravity_enabled=False``) so the only source + of a J · q_dot ↔ body_*_w mismatch is the reference-point contract (or a + regression). The tolerance ``5e-2`` is loose enough to absorb the small + PhysX state-propagation lag between the Jacobian and the velocity + buffers (~2% on max angular speed) but well below the + COM-vs-link-origin bug magnitude (panda hand COM offset ≈ 3 cm × ω at + typical motion ≈ several rad/s gives a 0.1+ m/s linear-row residual, + 2× the tolerance). + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + + torch.manual_seed(0) + qdot = torch.randn(num_articulations, articulation.num_joints, device=device) * 0.5 + articulation.write_joint_velocity_to_sim(velocity=qdot) + sim.step() + articulation.update(sim.cfg.dt) + + # body_link_jacobian_w prepends ``num_base_dofs`` floating-base columns; slice past + # them so the joint axis aligns with joint_vel (actuated-only). + J = articulation.data.body_link_jacobian_w.torch[..., articulation.num_base_dofs :] + qdot_view = articulation.data.joint_vel.torch + v_pred = torch.einsum("nbij,nj->nbi", J, qdot_view) + + body_lin_w = articulation.data.body_link_lin_vel_w.torch + body_ang_w = articulation.data.body_link_ang_vel_w.torch + if articulation.is_fixed_base: + body_lin_w = body_lin_w[:, 1:] + body_ang_w = body_ang_w[:, 1:] + + torch.testing.assert_close(v_pred[..., 3:6], body_ang_w, atol=1.5e-1, rtol=5e-2) + torch.testing.assert_close(v_pred[..., 0:3], body_lin_w, atol=1.5e-1, rtol=5e-2) + + +@pytest.mark.parametrize("num_articulations", [4]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_get_mass_matrix_symmetry_pd(sim, num_articulations, device, articulation_type, gravity_enabled): + """The joint-space mass matrix ``M(q)`` must be square, symmetric, and positive-definite. + + Mirrors the Newton-side test in + ``source/isaaclab_newton/test/assets/test_articulation.py``. Pins + three structural properties of :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix` + that every backend must satisfy. Both backends include the 6 floating-base + rows/cols on floating-base assets (matching the cross-library industry + convention); this test cares about square + symmetric + PD across both + fixed- and floating-base, not the absolute column count. + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + + sim.step() + articulation.update(sim.cfg.dt) + + M = articulation.data.mass_matrix.torch # (N, J, J) + assert M.dim() == 3, f"expected 3-D mass matrix, got shape {tuple(M.shape)}" + assert M.shape[0] == num_articulations + assert M.shape[1] == M.shape[2], f"mass matrix is not square: {tuple(M.shape)}" + + asym = (M - M.transpose(-1, -2)).abs().max().item() + assert asym < 1e-4, f"|M - M^T|_max = {asym:.3e} — mass matrix is not symmetric" + + eye = torch.eye(M.shape[-1], device=M.device, dtype=M.dtype).expand_as(M) + torch.linalg.cholesky(M + 1e-6 * eye) + + +@pytest.mark.parametrize("num_articulations", [1]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_jacobian_refreshes_after_manual_joint_write( + sim, num_articulations, device, articulation_type, gravity_enabled +): + """After ``write_joint_position_to_sim_index`` (no sim step), the Jacobian read + must reflect the new joint state — not the previous one. + + PhysX-side counterpart to the Newton test of the same name. PhysX's + :attr:`body_link_jacobian_w` triggers FK indirectly through + :attr:`body_link_pose_w` (used by the shift kernel); :attr:`body_com_jacobian_w` is + a passthrough to ``_root_view.get_jacobians()``. This test confirms that PhysX's + tensor view returns up-to-date Jacobians after a manual joint write — i.e., that + PhysX internally refreshes FK on ``get_jacobians`` (or that our property does). + Failure means we need to add ``update_articulations_kinematic()`` before the + passthrough. + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + sim.step() + articulation.update(sim.cfg.dt) + + # Read J at the baseline joint state. + J_link_0 = articulation.data.body_link_jacobian_w.torch.clone() + J_com_0 = articulation.data.body_com_jacobian_w.torch.clone() + + # Manually write a different joint state — large delta to make the change visible. + # No sim.step / update — FK becomes stale. + q_target = articulation.data.joint_pos.torch.clone() + 0.5 + env_ids = wp.array([0], dtype=wp.int32, device=device) + articulation.write_joint_position_to_sim_index(position=q_target, env_ids=env_ids) + + # Read J again. With the FK trigger, J reflects q_target and differs from J at baseline. + # Without the trigger, body_q stays at baseline, J unchanged. + J_link_1 = articulation.data.body_link_jacobian_w.torch.clone() + J_com_1 = articulation.data.body_com_jacobian_w.torch.clone() + + assert not torch.allclose(J_link_0, J_link_1, atol=1e-3), ( + "body_link_jacobian_w did not change after manual joint write — " + "FK trigger likely missing (eval_jacobian / shift kernel reading stale state.body_q)." + ) + assert not torch.allclose(J_com_0, J_com_1, atol=1e-3), ( + "body_com_jacobian_w did not change after manual joint write — " + "PhysX get_jacobians may not auto-refresh FK; consider adding update_articulations_kinematic()." + ) + + +@pytest.mark.parametrize("num_articulations", [1]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_mass_matrix_refreshes_after_manual_joint_write( + sim, num_articulations, device, articulation_type, gravity_enabled +): + """After ``write_joint_position_to_sim_index`` (no sim step), the mass matrix read + must reflect the new joint state. + + PhysX-side counterpart. :attr:`mass_matrix` is a passthrough to + ``_root_view.get_generalized_mass_matrices()``. Failure means PhysX's tensor view + does not auto-refresh FK on this getter, and we need to add + ``update_articulations_kinematic()`` before the passthrough. + """ + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + sim.reset() + sim.step() + articulation.update(sim.cfg.dt) + + M_0 = articulation.data.mass_matrix.torch.clone() + q_target = articulation.data.joint_pos.torch.clone() + 0.5 + env_ids = wp.array([0], dtype=wp.int32, device=device) + articulation.write_joint_position_to_sim_index(position=q_target, env_ids=env_ids) + M_1 = articulation.data.mass_matrix.torch.clone() + + assert not torch.allclose(M_0, M_1, atol=1e-3), ( + "mass_matrix did not change after manual joint write — " + "PhysX get_generalized_mass_matrices may not auto-refresh FK." + ) + + +@pytest.mark.parametrize("num_articulations", [1]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.isaacsim_ci +def test_get_gravity_compensation_forces_static_equilibrium(sim, num_articulations, device, articulation_type): + """PhysX accuracy: ``τ_gc`` must hold the manipulator in static equilibrium. + + The contract is the EOM identity ``M(q) q̈ + C(q,q̇) q̇ + g(q) = τ_input``. + Setting ``τ_input = g(q)`` at ``q̇ = 0`` gives ``q̈ = 0`` — the arm should + not move. This pins + :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` + in isolation: sign errors, frame errors, and DoF-ordering errors all + surface as joint drift, while a controller-level test would have those + bugs averaged out by PD damping. + + Newton-side equivalent is deliberately omitted in this PR (see the + ``xfail`` test pinning the upstream gap). Newton's inverse-dynamics + primitive is in flight at upstream issues #2497 / #2529 and has a known + floating-base bug (#2625) that we'd have to test around. Ship a Newton + accuracy variant of this test alongside the Newton implementation when + upstream lands. + """ + base_cfg = generate_articulation_cfg(articulation_type=articulation_type) + # Replace default Franka actuators with a passthrough implicit actuator + # (stiffness = 0, damping = 0). With both gains zero the effort target + # we set IS the joint torque applied — no PD spring-damper masks the + # gravity-comp signal. Default Franka cfg has stiffness=80 / damping=4 + # which would absorb gravity through PD bias and hide accessor bugs. + cfg = base_cfg.replace( + actuators={ + "all": ImplicitActuatorCfg( + joint_names_expr=[".*"], + stiffness=0.0, + damping=0.0, + ), + }, + ) + # FRANKA_PANDA_CFG has rigid_props.disable_gravity=False already, but be + # defensive — gravity must be ON for τ_gc to have anything to cancel. + cfg = cfg.replace( + spawn=cfg.spawn.replace( + rigid_props=cfg.spawn.rigid_props.replace(disable_gravity=False), + ), + ) + + articulation, _ = generate_articulation(cfg, num_articulations, device=device) + sim.reset() + assert articulation.is_initialized + + # Force a clean static state: default joint positions, zero velocities. + # ``sim.reset`` may leave residual ``q_dot`` from solver settling under + # gravity, so we pin it explicitly here. + default_q = articulation.data.default_joint_pos.torch.clone() + default_qd = torch.zeros_like(default_q) + articulation.write_joint_state_to_sim(default_q, default_qd) + articulation.update(sim.cfg.dt) + + # Default joint pose from FRANKA_PANDA_CFG bends the elbow + # (joint2=-0.569, joint4=-2.81, joint6=3.04) so several links carry a + # gravity load — τ_gc is non-trivial in this configuration. A natural- + # hang pose (all zeros) would produce near-zero τ_gc and make this + # test uninformative. + init_q = articulation.data.joint_pos.torch.clone() + + # Step 100 times applying only τ_gc as joint efforts. + for _ in range(100): + # ``gravity_compensation_forces`` shape is ``(N, num_joints + num_base_dofs)`` + # — leading ``num_base_dofs`` floating-base entries (0 on fixed-base) followed + # by the actuated-joint entries. Slice past the floating-base entries so the + # remaining tensor aligns with ``set_joint_effort_target`` (actuated only). + tau_gc = articulation.data.gravity_compensation_forces.torch[:, articulation.num_base_dofs :] + articulation.set_joint_effort_target(tau_gc) + articulation.write_data_to_sim() + sim.step() + articulation.update(sim.cfg.dt) + + final_q = articulation.data.joint_pos.torch + drift = (final_q - init_q).abs().max() + # Tight bound: 5e-3 rad ≈ 0.3°. Numerical integration over 100 steps will + # accumulate some floor (sub-millirad on Franka), but a sign or frame bug + # in τ_gc produces drift of at least a degree per step on bent-elbow + # poses. This bound separates "correct" from "broken" cleanly. + assert drift < 5e-3, ( + f"max joint drift {drift:.5f} rad after 100 gravity-comp-only steps —" + " τ_gc did not hold static equilibrium. Check sign, DoF ordering, and" + " whether gravity_compensation_forces returns g(q) (positive) or" + " its negation." + ) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_franka_ik_tracking_accuracy(sim, device, articulation_type, gravity_enabled): + """PhysX-side IK convergence sentinel — backend parity with the Newton test. + + Mirrors :func:`isaaclab_newton.test.assets.test_articulation.test_franka_ik_tracking_accuracy` + so both backends are pinned by the same IK trajectory. With the + robot teleported to its configured init_state home pose and scene + gravity off, PhysX's IK converges to ~mm precision on this 5 cm + Cartesian step. A bridge regression (wrong J shape, wrong DoF + ordering) would push the steady-state error well past the + threshold. + """ + robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids = _setup_franka_at_home_pose(sim) + + sim.step() + robot.update(sim.cfg.dt) + target_pose_b = _build_relative_pose_target(robot, ee_frame_idx, (0.05, 0.0, 0.0), device) + + ik = DifferentialIKController( + DifferentialIKControllerCfg(command_type="pose", use_relative_mode=False, ik_method="dls"), + num_envs=1, + device=device, + ) + ik.set_command(target_pose_b) + + pos_history: list[float] = [] + rot_history: list[float] = [] + for _ in range(800): + jacobian = _compute_jacobian_root_frame(robot, ee_jacobi_idx, arm_joint_ids) + ee_pos_b, ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) + joint_pos = robot.data.joint_pos.torch[:, arm_joint_ids] + + joint_pos_des = ik.compute(ee_pos_b, ee_quat_b, jacobian, joint_pos) + + robot.set_joint_position_target(joint_pos_des, joint_ids=arm_joint_ids) + robot.write_data_to_sim() + sim.step() + robot.update(sim.cfg.dt) + + pos_error, rot_error = compute_pose_error(ee_pos_b, ee_quat_b, target_pose_b[:, 0:3], target_pose_b[:, 3:7]) + pos_history.append(pos_error.norm(dim=-1).max().item()) + rot_history.append(rot_error.norm(dim=-1).max().item()) + + pos_min, pos_mean = _summarize_history(pos_history) + rot_min, rot_mean = _summarize_history(rot_history) + + print(f"IK_METRIC pos_min={pos_min:.5f} pos_mean={pos_mean:.5f} rot_min={rot_min:.5f} rot_mean={rot_mean:.5f}") + + # Assert on tail mean (not min) so an oscillating envelope can't + # squeeze through. Threshold matched to the Newton-side test + # (5 mm / 0.05 rad). + assert pos_mean < 5e-3, f"IK pos_mean {pos_mean:.5f} > 5 mm — bridge regression?" + assert rot_mean < 5e-2, f"IK rot_mean {rot_mean:.5f} > 0.05 rad — bridge regression?" + + +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_franka_osc_tracking_accuracy(sim, device, articulation_type, gravity_enabled): + """PhysX-side OSC pose tracking sentinel — backend parity with Newton. + + Mirrors :func:`isaaclab_newton.test.assets.test_articulation.test_franka_osc_tracking_accuracy`. + Zero out the actuator's PD gains so OSC's joint-effort output is + not opposed by the implicit-PD term, matching the Newton test setup. + """ + robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids = _setup_franka_at_home_pose(sim, zero_actuator_pd=True) + + osc = OperationalSpaceController( + OperationalSpaceControllerCfg( + target_types=["pose_abs"], + impedance_mode="fixed", + inertial_dynamics_decoupling=True, + partial_inertial_dynamics_decoupling=False, + gravity_compensation=False, + motion_stiffness_task=500.0, + motion_damping_ratio_task=1.0, + ), + num_envs=1, + device=device, + ) + + sim.step() + robot.update(sim.cfg.dt) + target_pose_b = _build_relative_pose_target(robot, ee_frame_idx, (0.05, 0.0, 0.0), device) + + pos_history: list[float] = [] + rot_history: list[float] = [] + for _ in range(800): + jacobian_b = _compute_jacobian_root_frame(robot, ee_jacobi_idx, arm_joint_ids) + mass_matrix = robot.data.mass_matrix.torch[:, arm_joint_ids, :][:, :, arm_joint_ids] + ee_pos_b, ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) + ee_pose_b = torch.cat([ee_pos_b, ee_quat_b], dim=-1) + joint_vel = robot.data.joint_vel.torch[:, arm_joint_ids] + ee_vel_b = _compute_ee_vel_root(jacobian_b, joint_vel) + + osc.set_command(target_pose_b, current_ee_pose_b=ee_pose_b) + joint_efforts = osc.compute( + jacobian_b=jacobian_b, + current_ee_pose_b=ee_pose_b, + current_ee_vel_b=ee_vel_b, + mass_matrix=mass_matrix, + gravity=None, + ) + + robot.set_joint_effort_target(joint_efforts, joint_ids=arm_joint_ids) + robot.write_data_to_sim() + sim.step() + robot.update(sim.cfg.dt) + + pos_error, rot_error = compute_pose_error(ee_pos_b, ee_quat_b, target_pose_b[:, 0:3], target_pose_b[:, 3:7]) + pos_history.append(pos_error.norm(dim=-1).max().item()) + rot_history.append(rot_error.norm(dim=-1).max().item()) + + pos_min, pos_mean = _summarize_history(pos_history) + rot_min, rot_mean = _summarize_history(rot_history) + + print(f"OSC_METRIC pos_min={pos_min:.5f} pos_mean={pos_mean:.5f} rot_min={rot_min:.5f} rot_mean={rot_mean:.5f}") + + # Assert on tail mean. Threshold matched to the Newton-side test + # (5 mm / 0.05 rad). Both backends converge to machine precision + # with proper ee-velocity feedback (``J · q_dot``). + assert pos_mean < 5e-3, f"OSC pos_mean {pos_mean:.5f} > 5 mm — bridge regression?" + assert rot_mean < 5e-2, f"OSC rot_mean {rot_mean:.5f} > 0.05 rad — bridge regression?" + + +def _run_osc_stay_still_under_gravity( + sim, + device: str, + *, + gravity_compensation_enabled: bool, + num_steps: int = 100, +): + """Run OSC with a stay-still target on Franka under gravity, return EE drift summary. + + Shared helper for the gravity-comp tests. Setup mirrors + :func:`test_franka_osc_tracking_accuracy` (zero actuator PD so OSC's joint-effort + output is not opposed by an implicit-PD spring), but with scene gravity ON and the + target = the EE pose captured after the first sim step (which already includes a + fraction-of-a-mm of gravity-induced motion; that's the baseline drift starts from). + + Args: + gravity_compensation_enabled: If ``True``, the OSC controller cfg has + ``gravity_compensation=True`` and ``osc.compute(gravity=g(q))`` receives + the data-layer ``gravity_compensation_forces`` slice. If ``False``, + ``gravity_compensation=False`` and ``gravity=None``. + + Returns: + Tuple ``((pos_min, pos_mean), (rot_min, rot_mean))`` over the last 20% of + steps (per :func:`_summarize_history`), where ``pos`` is in meters and + ``rot`` in radians. + """ + # Enable rigid-body gravity so the arm actually feels weight. + # ``FRANKA_PANDA_HIGH_PD_CFG`` defaults ``disable_gravity=True`` for IK/OSC tests. + robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids = _setup_franka_at_home_pose( + sim, zero_actuator_pd=True, enable_rigid_body_gravity=True + ) + + osc = OperationalSpaceController( + OperationalSpaceControllerCfg( + target_types=["pose_abs"], + impedance_mode="fixed", + inertial_dynamics_decoupling=True, + partial_inertial_dynamics_decoupling=False, + gravity_compensation=gravity_compensation_enabled, + motion_stiffness_task=500.0, + motion_damping_ratio_task=1.0, + ), + num_envs=1, + device=device, + ) + + sim.step() + robot.update(sim.cfg.dt) + + # Stay-still target = current EE pose in root frame, captured right after the + # first step. The OSC loop must hold this pose under gravity. + initial_ee_pos_b, initial_ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) + target_pose_b = torch.cat([initial_ee_pos_b, initial_ee_quat_b], dim=-1) + + pos_history: list[float] = [] + rot_history: list[float] = [] + for _ in range(num_steps): + jacobian_b = _compute_jacobian_root_frame(robot, ee_jacobi_idx, arm_joint_ids) + mass_matrix = robot.data.mass_matrix.torch[:, arm_joint_ids, :][:, :, arm_joint_ids] + ee_pos_b, ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) + ee_pose_b = torch.cat([ee_pos_b, ee_quat_b], dim=-1) + joint_vel = robot.data.joint_vel.torch[:, arm_joint_ids] + ee_vel_b = _compute_ee_vel_root(jacobian_b, joint_vel) + + # ``gravity_compensation_forces`` shape is ``(N, num_joints + num_base_dofs)``; + # slice past the leading floating-base columns (0 for fixed-base Franka, so a + # no-op here, but the pattern matches the action-term convention). + gravity = ( + robot.data.gravity_compensation_forces.torch[:, [j + robot.num_base_dofs for j in arm_joint_ids]] + if gravity_compensation_enabled + else None + ) + + osc.set_command(target_pose_b, current_ee_pose_b=ee_pose_b) + joint_efforts = osc.compute( + jacobian_b=jacobian_b, + current_ee_pose_b=ee_pose_b, + current_ee_vel_b=ee_vel_b, + mass_matrix=mass_matrix, + gravity=gravity, + ) + robot.set_joint_effort_target(joint_efforts, joint_ids=arm_joint_ids) + robot.write_data_to_sim() + sim.step() + robot.update(sim.cfg.dt) + + pos_error, rot_error = compute_pose_error(ee_pos_b, ee_quat_b, target_pose_b[:, 0:3], target_pose_b[:, 3:7]) + pos_history.append(pos_error.norm(dim=-1).max().item()) + rot_history.append(rot_error.norm(dim=-1).max().item()) + + return _summarize_history(pos_history), _summarize_history(rot_history) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.parametrize("gravity_enabled", [True]) +@pytest.mark.isaacsim_ci +def test_franka_osc_gravity_compensation_holds_under_gravity(sim, device, articulation_type, gravity_enabled): + """OSC with ``gravity_compensation=True`` must hold the EE pose under gravity. + + With scene gravity ON and zero actuator PD (so OSC torques are not opposed by an + implicit-PD spring), passing + :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` through + ``osc.compute(gravity=...)`` should keep the arm at the initial pose. + + Pins three things that the existing direct-primitive + :func:`test_get_gravity_compensation_forces_static_equilibrium` does not: + 1. OSC's ``_jacobi_joint_idx`` indexing — the ``+ num_base_dofs`` shift. + 2. OSC's :meth:`OperationalSpaceController.compute` correctly adds ``g(q)`` to + its torque output. + 3. The data-property ``gravity_compensation_forces`` is reachable from the OSC + pipeline (catches gating regressions in + :meth:`OperationalSpaceControllerAction._compute_dynamic_quantities`). + + Companion test :func:`test_franka_osc_no_gravity_compensation_sags_under_gravity` + runs the same setup with ``gravity_compensation=False`` and reports the + uncompensated drift magnitude — a sanity check that gravity is loading the arm. + """ + (pos_min, pos_mean), (rot_min, rot_mean) = _run_osc_stay_still_under_gravity( + sim, device, gravity_compensation_enabled=True + ) + print(f"OSC_GC_ON pos_min={pos_min:.5f} pos_mean={pos_mean:.5f} rot_min={rot_min:.5f} rot_mean={rot_mean:.5f}") + + assert pos_mean < 5e-3, f"OSC + gravity_compensation pos_mean {pos_mean:.5f} > 5 mm — regression?" + assert rot_mean < 5e-2, f"OSC + gravity_compensation rot_mean {rot_mean:.5f} > 0.05 rad — regression?" + + +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.parametrize("gravity_enabled", [True]) +@pytest.mark.isaacsim_ci +def test_franka_osc_no_gravity_compensation_sags_under_gravity(sim, device, articulation_type, gravity_enabled): + """OSC without ``gravity_compensation`` under gravity: sanity check that the arm sags. + + Companion to :func:`test_franka_osc_gravity_compensation_holds_under_gravity`. + Same setup, but ``gravity_compensation=False`` and ``osc.compute(gravity=None)``. + With zero actuator PD, OSC's task-space impedance is the only restoring force — + the steady-state solution is whatever pose error the impedance produces enough + joint torque to balance ``g(q)``. + + Asserts the drift is **non-trivially larger** than the with-comp threshold (5 mm). + Without this check, a regression that broke ``gravity_compensation_forces`` by + returning zeros (or a no-op `g(q)`) would pass the with-comp test silently. The + bound here proves gravity is actually loading the arm and the with-comp pass is + meaningful. + """ + (pos_min, pos_mean), (rot_min, rot_mean) = _run_osc_stay_still_under_gravity( + sim, device, gravity_compensation_enabled=False + ) + print(f"OSC_GC_OFF pos_min={pos_min:.5f} pos_mean={pos_mean:.5f} rot_min={rot_min:.5f} rot_mean={rot_mean:.5f}") + + # Sanity: with gravity on and no comp, OSC's task-space spring vs gravity-load + # equilibrium produces a non-zero pose error. If this asserts fails, the test + # setup itself is broken (e.g., gravity is not on, or the home pose has no + # gravity load), which would invalidate the with-comp test as well. + assert pos_mean > 5e-3, ( + f"OSC + no gravity_compensation pos_mean {pos_mean:.5f} ≤ 5 mm — gravity not loading the arm?" + ) + + if __name__ == "__main__": pytest.main([__file__, "-v", "--maxfail=1"]) diff --git a/source/isaaclab_tasks/changelog.d/jichuanh-ik-newton-compat-mvp.rst b/source/isaaclab_tasks/changelog.d/jichuanh-ik-newton-compat-mvp.rst new file mode 100644 index 000000000000..361dbe9e52b1 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/jichuanh-ik-newton-compat-mvp.rst @@ -0,0 +1,13 @@ +Changed +^^^^^^^ + +* Removed the ``self.sim.physics = PhysxCfg(...)`` overrides from + ``Isaac-Reach-Franka-{IK-Abs,IK-Rel,OSC}-v0`` env configs so they + inherit the parent ``ReachPhysicsCfg`` preset. Selecting + ``presets=newton`` now picks ``NewtonCfg``; the previous + ``bounce_threshold_velocity=0.2`` PhysX behavior is preserved as + the default in ``ReachPhysicsCfg``. Direct-workflow callers in + ``automate``, ``factory``, and the deploy MDP events module were + migrated to the new + :class:`~isaaclab.assets.BaseArticulationData` properties + (:attr:`body_link_jacobian_w`, :attr:`mass_matrix`). diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_env.py b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_env.py index 438f0f80603b..e3037d67712d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_env.py @@ -288,12 +288,12 @@ def _compute_intermediate_values(self, dt): self.fingertip_midpoint_linvel = self._robot.data.body_lin_vel_w.torch[:, self.fingertip_body_idx] self.fingertip_midpoint_angvel = self._robot.data.body_ang_vel_w.torch[:, self.fingertip_body_idx] - jacobians = wp.to_torch(self._robot.root_view.get_jacobians()) + jacobians = self._robot.data.body_link_jacobian_w.torch self.left_finger_jacobian = jacobians[:, self.left_finger_body_idx - 1, 0:6, 0:7] self.right_finger_jacobian = jacobians[:, self.right_finger_body_idx - 1, 0:6, 0:7] self.fingertip_midpoint_jacobian = (self.left_finger_jacobian + self.right_finger_jacobian) * 0.5 - self.arm_mass_matrix = wp.to_torch(self._robot.root_view.get_generalized_mass_matrices())[:, 0:7, 0:7] + self.arm_mass_matrix = self._robot.data.mass_matrix.torch[:, 0:7, 0:7] self.joint_pos = self._robot.data.joint_pos.torch.clone() self.joint_vel = self._robot.data.joint_vel.torch.clone() diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_env.py b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_env.py index c79441f223ae..1982786f65a7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_env.py @@ -215,12 +215,12 @@ def _compute_intermediate_values(self, dt): self.fingertip_midpoint_linvel = self._robot.data.body_lin_vel_w.torch[:, self.fingertip_body_idx] self.fingertip_midpoint_angvel = self._robot.data.body_ang_vel_w.torch[:, self.fingertip_body_idx] - jacobians = wp.to_torch(self._robot.root_view.get_jacobians()) + jacobians = self._robot.data.body_link_jacobian_w.torch self.left_finger_jacobian = jacobians[:, self.left_finger_body_idx - 1, 0:6, 0:7] self.right_finger_jacobian = jacobians[:, self.right_finger_body_idx - 1, 0:6, 0:7] self.fingertip_midpoint_jacobian = (self.left_finger_jacobian + self.right_finger_jacobian) * 0.5 - self.arm_mass_matrix = wp.to_torch(self._robot.root_view.get_generalized_mass_matrices())[:, 0:7, 0:7] + self.arm_mass_matrix = self._robot.data.mass_matrix.torch[:, 0:7, 0:7] self.joint_pos = self._robot.data.joint_pos.torch.clone() self.joint_vel = self._robot.data.joint_vel.torch.clone() diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_env.py b/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_env.py index ecc1ef33a038..c38fe071b161 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_env.py @@ -5,7 +5,6 @@ import numpy as np import torch -import warp as wp import carb @@ -131,12 +130,12 @@ def _compute_intermediate_values(self, dt): self.fingertip_midpoint_linvel = self._robot.data.body_lin_vel_w.torch[:, self.fingertip_body_idx] self.fingertip_midpoint_angvel = self._robot.data.body_ang_vel_w.torch[:, self.fingertip_body_idx] - jacobians = wp.to_torch(self._robot.root_view.get_jacobians()) + jacobians = self._robot.data.body_link_jacobian_w.torch self.left_finger_jacobian = jacobians[:, self.left_finger_body_idx - 1, 0:6, 0:7] self.right_finger_jacobian = jacobians[:, self.right_finger_body_idx - 1, 0:6, 0:7] self.fingertip_midpoint_jacobian = (self.left_finger_jacobian + self.right_finger_jacobian) * 0.5 - self.arm_mass_matrix = wp.to_torch(self._robot.root_view.get_generalized_mass_matrices())[:, 0:7, 0:7] + self.arm_mass_matrix = self._robot.data.mass_matrix.torch[:, 0:7, 0:7] self.joint_pos = self._robot.data.joint_pos.torch.clone() self.joint_vel = self._robot.data.joint_vel.torch.clone() diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/events.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/events.py index b651a002966e..0156d486d1f0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/events.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/events.py @@ -11,7 +11,6 @@ from typing import TYPE_CHECKING import torch -import warp as wp import isaaclab.utils.math as math_utils from isaaclab.managers import EventTermCfg, ManagerTermBase, SceneEntityCfg @@ -327,9 +326,11 @@ def __call__( if torch.all(pos_error_norm < pos_threshold) and torch.all(rot_error_norm < rot_threshold): break - # Solve IK using jacobian - jacobians = wp.to_torch(self.robot_asset.root_view.get_jacobians()).clone() - jacobian = jacobians[env_ids, self.jacobi_body_idx, :, :] + # Solve IK using jacobian. ``body_link_jacobian_w`` prepends ``num_base_dofs`` + # floating-base columns on the DoF axis (0 for fixed-base, 6 for floating-base); + # slice past them so the column axis aligns with the actuated-joint state. + jacobians = self.robot_asset.data.body_link_jacobian_w.torch.clone() + jacobian = jacobians[env_ids, self.jacobi_body_idx, :, self.robot_asset.num_base_dofs :] delta_dof_pos = fc._get_delta_dof_pos( delta_pose=delta_hand_pose, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_abs_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_abs_env_cfg.py index e8e955718559..b090e568965e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_abs_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_abs_env_cfg.py @@ -3,8 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_physx.physics import PhysxCfg - from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg from isaaclab.utils import configclass @@ -36,9 +34,6 @@ def __post_init__(self): body_offset=DifferentialInverseKinematicsActionCfg.OffsetCfg(pos=[0.0, 0.0, 0.107]), ) - # IK control is not supported with Newton physics; use PhysX only. - self.sim.physics = PhysxCfg(bounce_threshold_velocity=0.2) - @configclass class FrankaReachEnvCfg_PLAY(FrankaReachEnvCfg): diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_rel_env_cfg.py index 488e92493289..024a42270d85 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_rel_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_rel_env_cfg.py @@ -3,8 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_physx.physics import PhysxCfg - from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg from isaaclab.utils import configclass @@ -37,9 +35,6 @@ def __post_init__(self): body_offset=DifferentialInverseKinematicsActionCfg.OffsetCfg(pos=[0.0, 0.0, 0.107]), ) - # IK control is not supported with Newton physics; use PhysX only. - self.sim.physics = PhysxCfg(bounce_threshold_velocity=0.2) - @configclass class FrankaReachEnvCfg_PLAY(FrankaReachEnvCfg): diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/osc_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/osc_env_cfg.py index cca92aa019bb..e612439fda70 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/osc_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/osc_env_cfg.py @@ -3,8 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_physx.physics import PhysxCfg - from isaaclab.controllers.operational_space_cfg import OperationalSpaceControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import OperationalSpaceControllerActionCfg from isaaclab.utils import configclass @@ -62,9 +60,6 @@ def __post_init__(self): self.observations.policy.joint_pos = None self.observations.policy.joint_vel = None - # OSC control is not supported with Newton physics; use PhysX only. - self.sim.physics = PhysxCfg(bounce_threshold_velocity=0.2) - @configclass class FrankaReachEnvCfg_PLAY(FrankaReachEnvCfg): From a44eefc8c15d7e5aed8d518e747ba2bbc0bcbf7b Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Wed, 13 May 2026 11:40:27 -0700 Subject: [PATCH 050/133] Disables test timeout retry (#5602) # Description The timeout retry logic is doing more harm than good as it's causing tests to run extremely long and does not actually resolve any of the timeout issues. Reverting the change to default to 0 retries on timeouts. ## 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 - [ ] 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 --- tools/conftest.py | 2 +- tools/test_settings.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/conftest.py b/tools/conftest.py index 55b00ce44afa..5b844442c0a8 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -48,7 +48,7 @@ def pytest_ignore_collect(collection_path, config): STARTUP_HANG_RETRIES = 2 """Number of times to retry a test that hangs during startup before giving up.""" -TIMEOUT_RETRIES = 2 +TIMEOUT_RETRIES = 0 """Number of times to retry a test that reaches its hard timeout before giving up.""" SHUTDOWN_GRACE_PERIOD = 30 diff --git a/tools/test_settings.py b/tools/test_settings.py index 7fdde2fef9a1..d06c282497c9 100644 --- a/tools/test_settings.py +++ b/tools/test_settings.py @@ -67,6 +67,7 @@ "test_rendering_cartpole_kitless.py": 2000, "test_rendering_dexsuite_kuka_kitless.py": 2000, "test_rendering_shadow_hand_kitless.py": 2000, + "test_contact_sensor.py": 2000, } """A dictionary of tests and their timeouts in seconds. From 4c997650c9dcc33b0123d4719b728deb6491800e Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Wed, 13 May 2026 14:16:52 -0700 Subject: [PATCH 051/133] Fixes doc build errors in mimic datagen (#5606) # Description Fix errors in documentation build job, where imports were causing compilation issues ## Type of change - Bug fix (non-breaking change which fixes an issue) - 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 --- .../isaaclab_mimic/changelog.d/fix-mimic-datagen-import.rst | 5 +++++ source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py | 4 ++-- source/isaaclab_mimic/setup.py | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 source/isaaclab_mimic/changelog.d/fix-mimic-datagen-import.rst diff --git a/source/isaaclab_mimic/changelog.d/fix-mimic-datagen-import.rst b/source/isaaclab_mimic/changelog.d/fix-mimic-datagen-import.rst new file mode 100644 index 000000000000..2c7a0ef6d4f7 --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/fix-mimic-datagen-import.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed :mod:`isaaclab_mimic.datagen` imports in packaged installs and avoided + importing task configuration modules until data generation config setup. diff --git a/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py b/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py index 1761cf9beaa7..72dc9c8d4c13 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py +++ b/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py @@ -19,8 +19,6 @@ from isaaclab_mimic.datagen.data_generator import DataGenerator from isaaclab_mimic.datagen.datagen_info_pool import DataGenInfoPool -from isaaclab_tasks.utils.parse_cfg import parse_env_cfg - # global variable to keep track of the data generation statistics num_success = 0 num_failures = 0 @@ -180,6 +178,8 @@ def setup_env_config( Raises: NotImplementedError: If no success termination term found """ + from isaaclab_tasks.utils.parse_cfg import parse_env_cfg + env_cfg = parse_env_cfg(env_name, device=device, num_envs=num_envs) if generation_num_trials is not None: diff --git a/source/isaaclab_mimic/setup.py b/source/isaaclab_mimic/setup.py index e3d9e2dc6ac3..279a7a0a248d 100644 --- a/source/isaaclab_mimic/setup.py +++ b/source/isaaclab_mimic/setup.py @@ -10,7 +10,7 @@ import platform import toml -from setuptools import setup +from setuptools import find_namespace_packages, setup # Obtain the extension data from the extension.toml file EXTENSION_PATH = os.path.dirname(os.path.realpath(__file__)) @@ -45,7 +45,7 @@ # Installation operation setup( name="isaaclab_mimic", - packages=["isaaclab_mimic"], + packages=find_namespace_packages(include=["isaaclab_mimic", "isaaclab_mimic.*"]), author=EXTENSION_TOML_DATA["package"]["author"], maintainer=EXTENSION_TOML_DATA["package"]["maintainer"], url=EXTENSION_TOML_DATA["package"]["repository"], From b2582a45c4eb9bbd70b2cad92a73563a9ca0d9d3 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Wed, 13 May 2026 14:29:40 -0700 Subject: [PATCH 052/133] Update configs to new conventions from rsl_rl >= 5.0 (#5551) # Description Formatted articulation root predicate expressions for readability. No behavior change and no new dependencies. ## Type of change Code cleanup and migration to rsl_rl >= 5.0 ## 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: Kelly Guo --- .../04_reach/reach_policy.rst | 16 ++++--- .../reinforcement_learning/rsl_rl/train.py | 2 +- .../changelog.d/rsl-rl-model-configs.rst | 5 ++ .../allegro_hand/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../direct/ant/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../direct/anymal_c/agents/rsl_rl_ppo_cfg.py | 32 ++++++++----- .../direct/cartpole/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../franka_cabinet/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../direct/humanoid/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../quadcopter/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../shadow_hand/agents/rsl_rl_ppo_cfg.py | 47 +++++++++++-------- .../classic/ant/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../classic/cartpole/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../classic/humanoid/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../arl_robot_1/agents/rsl_rl_ppo_cfg.py | 16 ++++--- .../config/digit/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../config/a1/agents/rsl_rl_ppo_cfg.py | 21 +++++---- .../config/anymal_b/agents/rsl_rl_ppo_cfg.py | 21 +++++---- .../config/anymal_c/agents/rsl_rl_ppo_cfg.py | 21 +++++---- .../config/cassie/agents/rsl_rl_ppo_cfg.py | 21 +++++---- .../config/digit/agents/rsl_rl_ppo_cfg.py | 21 +++++---- .../config/g1/agents/rsl_rl_ppo_cfg.py | 21 +++++---- .../config/go1/agents/rsl_rl_ppo_cfg.py | 21 +++++---- .../config/go2/agents/rsl_rl_ppo_cfg.py | 21 +++++---- .../config/h1/agents/rsl_rl_ppo_cfg.py | 21 +++++---- .../config/spot/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../config/franka/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../config/openarm/agents/rsl_rl_ppo_cfg.py | 16 ++++--- .../config/rizon_4s/agents/rsl_rl_ppo_cfg.py | 24 ++++++---- .../config/ur_10e/agents/rsl_rl_ppo_cfg.py | 24 ++++++---- .../config/rizon_4s/agents/rsl_rl_ppo_cfg.py | 18 ++++--- .../config/ur_10e/agents/rsl_rl_ppo_cfg.py | 18 ++++--- .../allegro_hand/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../config/franka/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../config/openarm/agents/rsl_rl_ppo_cfg.py | 16 ++++--- .../config/franka/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../openarm/bimanual/agents/rsl_rl_ppo_cfg.py | 16 ++++--- .../unimanual/agents/rsl_rl_ppo_cfg.py | 16 ++++--- .../config/ur_10/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../config/anymal_c/agents/rsl_rl_ppo_cfg.py | 17 ++++--- .../template/templates/agents/rsl_rl_ppo_cfg | 17 ++++--- 41 files changed, 451 insertions(+), 310 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/rsl-rl-model-configs.rst diff --git a/docs/source/policy_deployment/04_reach/reach_policy.rst b/docs/source/policy_deployment/04_reach/reach_policy.rst index 9317ed6f6c04..4c7aadb0e530 100644 --- a/docs/source/policy_deployment/04_reach/reach_policy.rst +++ b/docs/source/policy_deployment/04_reach/reach_policy.rst @@ -498,14 +498,18 @@ The training hyperparameters are the same for both robots: num_steps_per_env = 512 max_iterations = 1500 save_interval = 50 - empirical_normalization = True - obs_groups = {"policy": ["policy"], "critic": ["policy"]} + obs_groups = {"actor": ["policy"], "critic": ["policy"]} - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_hidden_dims=[256, 128, 64], - critic_hidden_dims=[256, 128, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], + activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index eefc13a8aa2c..b5f4fcaf0db5 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -40,7 +40,7 @@ with contextlib.suppress(ImportError): import isaaclab_tasks_experimental # noqa: F401 -RSL_RL_VERSION = "3.0.1" +RSL_RL_VERSION = "5.0.1" torch.backends.cuda.matmul.allow_tf32 = True torch.backends.cudnn.allow_tf32 = True diff --git a/source/isaaclab_tasks/changelog.d/rsl-rl-model-configs.rst b/source/isaaclab_tasks/changelog.d/rsl-rl-model-configs.rst new file mode 100644 index 000000000000..3ef1b32d5d07 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/rsl-rl-model-configs.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed RSL-RL task agent configs to use ``actor`` and ``critic`` model + configs with distribution configs instead of deprecated ``policy`` configs. diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py index 871250fd0b17..a80cf1ee33a2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class AllegroHandPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 10000 save_interval = 250 experiment_name = "allegro_hand" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=True, - critic_obs_normalization=True, - actor_hidden_dims=[1024, 512, 256, 128], - critic_hidden_dims=[1024, 512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[1024, 512, 256, 128], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[1024, 512, 256, 128], + activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py index 00eefc843e20..c2e7f15852ff 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class AntPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1000 save_interval = 50 experiment_name = "ant_direct" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[400, 200, 100], - critic_hidden_dims=[400, 200, 100], + actor = RslRlMLPModelCfg( + hidden_dims=[400, 200, 100], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[400, 200, 100], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py index 117ad6e75bed..55b34655b639 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class AnymalCFlatPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 500 save_interval = 50 experiment_name = "anymal_c_flat_direct" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[128, 128, 128], - critic_hidden_dims=[128, 128, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[128, 128, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[128, 128, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -44,13 +47,16 @@ class AnymalCRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "anymal_c_rough_direct" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py index 097b7b43a672..7d308b9f5c45 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class CartpolePPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 150 save_interval = 50 experiment_name = "cartpole_direct" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[32, 32], - critic_hidden_dims=[32, 32], + actor = RslRlMLPModelCfg( + hidden_dims=[32, 32], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[32, 32], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py index a2304fb2c4b7..c467360e61a5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class FrankaCabinetPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "franka_cabinet_direct" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=True, - critic_obs_normalization=True, - actor_hidden_dims=[256, 128, 64], - critic_hidden_dims=[256, 128, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], + activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py index 778d73f09119..07ea4e8590f1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class HumanoidPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1000 save_interval = 50 experiment_name = "humanoid_direct" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=True, - critic_obs_normalization=True, - actor_hidden_dims=[400, 200, 100], - critic_hidden_dims=[400, 200, 100], + actor = RslRlMLPModelCfg( + hidden_dims=[400, 200, 100], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[400, 200, 100], + activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py index 607d9f0fb0ea..254072606f13 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class QuadcopterPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 200 save_interval = 50 experiment_name = "quadcopter_direct" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[64, 64], - critic_hidden_dims=[64, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[64, 64], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[64, 64], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py index 6ab4c9e56f5a..fa4a96cb32e7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class ShadowHandPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 10000 save_interval = 250 experiment_name = "shadow_hand" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=True, - critic_obs_normalization=True, - actor_hidden_dims=[512, 512, 256, 128], - critic_hidden_dims=[512, 512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 512, 256, 128], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 512, 256, 128], + activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -44,13 +47,16 @@ class ShadowHandAsymFFPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 10000 save_interval = 250 experiment_name = "shadow_hand_openai_ff" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=True, - critic_obs_normalization=True, - actor_hidden_dims=[400, 400, 200, 100], - critic_hidden_dims=[512, 512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[400, 400, 200, 100], + activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 512, 256, 128], activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -74,13 +80,16 @@ class ShadowHandVisionFFPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 50000 save_interval = 250 experiment_name = "shadow_hand_vision" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=True, - critic_obs_normalization=True, - actor_hidden_dims=[1024, 512, 512, 256, 128], - critic_hidden_dims=[1024, 512, 512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[1024, 512, 512, 256, 128], + activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[1024, 512, 512, 256, 128], activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py index 986461733663..56cb2c4fd3a0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class AntPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1000 save_interval = 50 experiment_name = "ant" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[400, 200, 100], - critic_hidden_dims=[400, 200, 100], + actor = RslRlMLPModelCfg( + hidden_dims=[400, 200, 100], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[400, 200, 100], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py index 2a266a098df2..c53312ee7dd4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg, RslRlSymmetryCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg, RslRlSymmetryCfg import isaaclab_tasks.manager_based.classic.cartpole.mdp.symmetry as symmetry @@ -16,13 +16,16 @@ class CartpolePPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 150 save_interval = 50 experiment_name = "cartpole" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[32, 32], - critic_hidden_dims=[32, 32], + actor = RslRlMLPModelCfg( + hidden_dims=[32, 32], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[32, 32], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py index f2c7f48e4558..076ad94480eb 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py @@ -15,7 +15,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -24,13 +24,16 @@ class HumanoidPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1000 save_interval = 100 experiment_name = "humanoid" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=True, - critic_obs_normalization=True, - actor_hidden_dims=[400, 200, 100], - critic_hidden_dims=[400, 200, 100], + actor = RslRlMLPModelCfg( + hidden_dims=[400, 200, 100], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[400, 200, 100], + activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=2.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py index b53c53dbdd0c..9a9b0de5bb38 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,12 +14,16 @@ class TrackPositionNoObstaclesEnvPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "arl_robot_1_track_position_state_based" - empirical_normalization = False - policy = RslRlPpoActorCriticCfg( - init_noise_std=0.5, - actor_hidden_dims=[256, 128, 64], - critic_hidden_dims=[256, 128, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=0.5), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py index c98c2030a2ca..2118f550cc12 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class DigitLocoManipPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 2000 save_interval = 50 experiment_name = "digit_loco_manip" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[256, 128, 128], - critic_hidden_dims=[256, 128, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[256, 128, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[256, 128, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py index 972ebf937367..334aa1768ed6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class UnitreeA1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "unitree_a1_rough" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -45,5 +48,5 @@ def __post_init__(self): self.max_iterations = 300 self.experiment_name = "unitree_a1_flat" - self.policy.actor_hidden_dims = [128, 128, 128] - self.policy.critic_hidden_dims = [128, 128, 128] + self.actor.hidden_dims = [128, 128, 128] + self.critic.hidden_dims = [128, 128, 128] diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py index f6d0c585dd15..49c227aecdde 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg, RslRlSymmetryCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg, RslRlSymmetryCfg from isaaclab_tasks.manager_based.locomotion.velocity.mdp.symmetry import anymal @@ -16,13 +16,16 @@ class AnymalBRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "anymal_b_rough" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -47,8 +50,8 @@ def __post_init__(self): self.max_iterations = 300 self.experiment_name = "anymal_b_flat" - self.policy.actor_hidden_dims = [128, 128, 128] - self.policy.critic_hidden_dims = [128, 128, 128] + self.actor.hidden_dims = [128, 128, 128] + self.critic.hidden_dims = [128, 128, 128] @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py index 45f434fe7f0d..06072c7297fe 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg, RslRlSymmetryCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg, RslRlSymmetryCfg from isaaclab_tasks.manager_based.locomotion.velocity.mdp.symmetry import anymal @@ -16,13 +16,16 @@ class AnymalCRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "anymal_c_rough" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -47,8 +50,8 @@ def __post_init__(self): self.max_iterations = 300 self.experiment_name = "anymal_c_flat" - self.policy.actor_hidden_dims = [128, 128, 128] - self.policy.critic_hidden_dims = [128, 128, 128] + self.actor.hidden_dims = [128, 128, 128] + self.critic.hidden_dims = [128, 128, 128] @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py index 93cce1bb9294..f7cabdc37474 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class CassieRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "cassie_rough" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -45,5 +48,5 @@ def __post_init__(self): self.max_iterations = 1000 self.experiment_name = "cassie_flat" - self.policy.actor_hidden_dims = [128, 128, 128] - self.policy.critic_hidden_dims = [128, 128, 128] + self.actor.hidden_dims = [128, 128, 128] + self.critic.hidden_dims = [128, 128, 128] diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py index 72eb4a2aa3ff..217c7482f19a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class DigitRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 3000 save_interval = 50 experiment_name = "digit_rough" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -46,5 +49,5 @@ def __post_init__(self): self.max_iterations = 2000 self.experiment_name = "digit_flat" - self.policy.actor_hidden_dims = [128, 128, 128] - self.policy.critic_hidden_dims = [128, 128, 128] + self.actor.hidden_dims = [128, 128, 128] + self.critic.hidden_dims = [128, 128, 128] diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py index 7b61c184d353..d4b55dadc60f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg from isaaclab_tasks.utils import preset @@ -22,13 +22,16 @@ class G1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = preset(default=3000, newton=5000) save_interval = 50 experiment_name = "g1_rough" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -53,5 +56,5 @@ def __post_init__(self): self.max_iterations = 1500 self.experiment_name = "g1_flat" - self.policy.actor_hidden_dims = [256, 128, 128] - self.policy.critic_hidden_dims = [256, 128, 128] + self.actor.hidden_dims = [256, 128, 128] + self.critic.hidden_dims = [256, 128, 128] diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py index 9baa2b371ea3..de8ca9189ebe 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class UnitreeGo1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "unitree_go1_rough" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -45,5 +48,5 @@ def __post_init__(self): self.max_iterations = 300 self.experiment_name = "unitree_go1_flat" - self.policy.actor_hidden_dims = [128, 128, 128] - self.policy.critic_hidden_dims = [128, 128, 128] + self.actor.hidden_dims = [128, 128, 128] + self.critic.hidden_dims = [128, 128, 128] diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py index 9777785f7e30..e7b0e67b5c2b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class UnitreeGo2RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "unitree_go2_rough" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -45,5 +48,5 @@ def __post_init__(self): self.max_iterations = 300 self.experiment_name = "unitree_go2_flat" - self.policy.actor_hidden_dims = [128, 128, 128] - self.policy.critic_hidden_dims = [128, 128, 128] + self.actor.hidden_dims = [128, 128, 128] + self.critic.hidden_dims = [128, 128, 128] diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py index 102359770864..fe21c8d1da17 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class H1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 3000 save_interval = 50 experiment_name = "h1_rough" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, @@ -45,5 +48,5 @@ def __post_init__(self): self.max_iterations = 1000 self.experiment_name = "h1_flat" - self.policy.actor_hidden_dims = [128, 128, 128] - self.policy.critic_hidden_dims = [128, 128, 128] + self.actor.hidden_dims = [128, 128, 128] + self.critic.hidden_dims = [128, 128, 128] diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py index 3985f6b3b491..0c7e6f30dac6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -15,13 +15,16 @@ class SpotFlatPPORunnerCfg(RslRlOnPolicyRunnerCfg): save_interval = 50 experiment_name = "spot_flat" store_code_state = False - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=0.5, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py index 0ccb4787cdd6..aad72a2d8a69 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class CabinetPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 400 save_interval = 50 experiment_name = "franka_open_drawer" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[256, 128, 64], - critic_hidden_dims=[256, 128, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/agents/rsl_rl_ppo_cfg.py index 67f3498c361d..8dfd24624513 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,12 +14,16 @@ class OpenArmCabinetPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 600 save_interval = 50 experiment_name = "openarm_open_drawer" - empirical_normalization = False - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_hidden_dims=[256, 128, 64], - critic_hidden_dims=[256, 128, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py index 06f64e731afc..20e68ba87fdf 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticRecurrentCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg, RslRlRNNModelCfg @configclass @@ -17,18 +17,22 @@ class Rizon4sGearAssemblyRNNPPORunnerCfg(RslRlOnPolicyRunnerCfg): clip_actions = 1.0 resume = False obs_groups = { - "policy": ["policy"], + "actor": ["policy"], "critic": ["critic"], } - policy = RslRlPpoActorCriticRecurrentCfg( - state_dependent_std=True, - init_noise_std=1.0, - actor_obs_normalization=True, - critic_obs_normalization=True, - actor_hidden_dims=[256, 128, 64], - critic_hidden_dims=[256, 128, 64], - noise_std_type="log", + actor = RslRlRNNModelCfg( + hidden_dims=[256, 128, 64], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.HeteroscedasticGaussianDistributionCfg(init_std=1.0, std_type="log"), + rnn_type="lstm", + rnn_hidden_dim=256, + rnn_num_layers=2, + ) + critic = RslRlRNNModelCfg( + hidden_dims=[256, 128, 64], + activation="elu", + obs_normalization=True, rnn_type="lstm", rnn_hidden_dim=256, rnn_num_layers=2, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/agents/rsl_rl_ppo_cfg.py index ac1ecba8463d..d49d02f5990f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticRecurrentCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg, RslRlRNNModelCfg @configclass @@ -17,18 +17,22 @@ class UR10GearAssemblyRNNPPORunnerCfg(RslRlOnPolicyRunnerCfg): clip_actions = 1.0 resume = False obs_groups = { - "policy": ["policy"], + "actor": ["policy"], "critic": ["critic"], } - policy = RslRlPpoActorCriticRecurrentCfg( - state_dependent_std=True, - init_noise_std=1.0, - actor_obs_normalization=True, - critic_obs_normalization=True, - actor_hidden_dims=[256, 128, 64], - critic_hidden_dims=[256, 128, 64], - noise_std_type="log", + actor = RslRlRNNModelCfg( + hidden_dims=[256, 128, 64], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.HeteroscedasticGaussianDistributionCfg(init_std=1.0, std_type="log"), + rnn_type="lstm", + rnn_hidden_dim=256, + rnn_num_layers=2, + ) + critic = RslRlRNNModelCfg( + hidden_dims=[256, 128, 64], + activation="elu", + obs_normalization=True, rnn_type="lstm", rnn_hidden_dim=256, rnn_num_layers=2, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/agents/rsl_rl_ppo_cfg.py index cb37cb700181..85f2b38e3336 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,17 @@ class Rizon4sReachPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "reach_rizon4s" - empirical_normalization = True - obs_groups = {"policy": ["policy"], "critic": ["policy"]} - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_hidden_dims=[256, 128, 64], - critic_hidden_dims=[256, 128, 64], + obs_groups = {"actor": ["policy"], "critic": ["policy"]} + actor = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], + activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py index 02af65eec9a0..2e5a4e8dfca9 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,17 @@ class URReachPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "reach_ur10e" - empirical_normalization = True - obs_groups = {"policy": ["policy"], "critic": ["policy"]} - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_hidden_dims=[256, 128, 64], - critic_hidden_dims=[256, 128, 64], + obs_groups = {"actor": ["policy"], "critic": ["policy"]} + actor = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], + activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py index b1d3d4be1756..ac773b10af3a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class AllegroCubePPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 5000 save_interval = 50 experiment_name = "allegro_cube" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=True, - critic_obs_normalization=True, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py index 7a94614d8c97..c5010a9577df 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class LiftCubePPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "franka_lift" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[256, 128, 64], - critic_hidden_dims=[256, 128, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/agents/rsl_rl_ppo_cfg.py index f079552f1f4a..fb962b9cd98f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/agents/rsl_rl_ppo_cfg.py @@ -6,7 +6,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -15,12 +15,16 @@ class OpenArmLiftCubePPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 2000 save_interval = 50 experiment_name = "openarm_lift" - empirical_normalization = False - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_hidden_dims=[256, 128, 64], - critic_hidden_dims=[256, 128, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[256, 128, 64], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py index ede70559fd56..f523c5cc2207 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -15,13 +15,16 @@ class FrankaReachPPORunnerCfg(RslRlOnPolicyRunnerCfg): save_interval = 50 experiment_name = "franka_reach" run_name = "" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[64, 64], - critic_hidden_dims=[64, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[64, 64], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[64, 64], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/agents/rsl_rl_ppo_cfg.py index d1dd736a2ed7..0afc5496d059 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -16,12 +16,16 @@ class OpenArmReachPPORunnerCfg(RslRlOnPolicyRunnerCfg): experiment_name = "openarm_bi_reach" run_name = "" resume = False - empirical_normalization = False - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_hidden_dims=[64, 64], - critic_hidden_dims=[64, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[64, 64], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[64, 64], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/agents/rsl_rl_ppo_cfg.py index 4d43c3574195..9bfdf6530499 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/agents/rsl_rl_ppo_cfg.py @@ -6,7 +6,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -17,12 +17,16 @@ class OpenArmReachPPORunnerCfg(RslRlOnPolicyRunnerCfg): experiment_name = "openarm_reach" run_name = "" resume = False - empirical_normalization = True - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_hidden_dims=[64, 64], - critic_hidden_dims=[64, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[64, 64], activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[64, 64], + activation="elu", + obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py index c445786c44c7..ef6ae64a45f5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -15,13 +15,16 @@ class UR10ReachPPORunnerCfg(RslRlOnPolicyRunnerCfg): save_interval = 50 experiment_name = "reach_ur10" run_name = "" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[64, 64], - critic_hidden_dims=[64, 64], + actor = RslRlMLPModelCfg( + hidden_dims=[64, 64], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[64, 64], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py index 93ec98732f8c..c02c9b0eb0d0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class NavigationEnvPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "anymal_c_navigation" - policy = RslRlPpoActorCriticCfg( - init_noise_std=0.5, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[128, 128], - critic_hidden_dims=[128, 128], + actor = RslRlMLPModelCfg( + hidden_dims=[128, 128], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=0.5), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[128, 128], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, diff --git a/tools/template/templates/agents/rsl_rl_ppo_cfg b/tools/template/templates/agents/rsl_rl_ppo_cfg index 85970dfc2ce4..a29f0ae96833 100644 --- a/tools/template/templates/agents/rsl_rl_ppo_cfg +++ b/tools/template/templates/agents/rsl_rl_ppo_cfg @@ -5,7 +5,7 @@ from isaaclab.utils import configclass -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg @configclass @@ -14,13 +14,16 @@ class PPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 150 save_interval = 50 experiment_name = "cartpole_direct" - policy = RslRlPpoActorCriticCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[32, 32], - critic_hidden_dims=[32, 32], + actor = RslRlMLPModelCfg( + hidden_dims=[32, 32], activation="elu", + obs_normalization=False, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[32, 32], + activation="elu", + obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( value_loss_coef=1.0, From ae453e8b87772debb88234bb23bc308f6cdb5007 Mon Sep 17 00:00:00 2001 From: Daniela Hase <116915287+daniela-hase@users.noreply.github.com> Date: Wed, 13 May 2026 19:18:39 -0700 Subject: [PATCH 053/133] New Scene Data Provider (#5128) --- docs/source/api/index.rst | 4 - .../isaaclab_newton.scene_data_providers.rst | 4 - .../isaaclab_physx.scene_data_providers.rst | 4 - docs/source/features/visualization.rst | 4 +- .../multi_backend_architecture.rst | 46 +- .../core-concepts/scene_data_providers.rst | 139 ++-- .../dev-scene-data-provider-api.minor.rst | 34 + source/isaaclab/isaaclab/physics/__init__.pyi | 7 +- .../physics/base_scene_data_provider.py | 60 -- .../isaaclab/physics/physics_manager.py | 7 + .../isaaclab/physics/scene_data_backend.py | 61 ++ .../isaaclab/physics/scene_data_provider.py | 44 -- .../isaaclab/scene/scene_data_provider.py | 521 +++++++++++++ .../isaaclab/sim/simulation_context.py | 40 +- .../isaaclab/visualizers/base_visualizer.py | 6 +- ...test_newton_manager_visualization_state.py | 162 ++++ ...scene_data_provider_visualizer_contract.py | 199 ----- .../test_simulation_context_visualizers.py | 154 +++- .../test/visualizers/test_visualizer.py | 4 + .../dev-scene-data-provider-api.minor.rst | 31 + .../isaaclab_newton/physics/newton_manager.py | 337 +++++++- .../renderers/newton_warp_renderer.py | 29 +- .../scene_data_providers/__init__.py | 10 - .../scene_data_providers/__init__.pyi | 10 - .../newton_scene_data_provider.py | 306 -------- .../newton_gl_perspective_video.py | 12 +- source/isaaclab_newton/setup.py | 1 - .../dev-scene-data-provider-api.rst | 9 + .../isaaclab_ov/renderers/ovrtx_renderer.py | 10 +- .../dev-scene-data-provider-api.minor.rst | 19 + .../isaaclab_physx/physics/physx_manager.py | 78 +- .../scene_data_providers/__init__.py | 10 - .../scene_data_providers/__init__.pyi | 10 - .../physx_scene_data_provider.py | 728 ------------------ source/isaaclab_physx/setup.py | 1 - .../dev-scene-data-provider-api.rst | 13 + .../scenes/obstacle_scenes/obstacle_scene.py | 2 +- .../assemble_trocar/config/robot_config.py | 18 +- .../kit/kit_visualizer.py | 28 +- .../newton/newton_visualizer.py | 40 +- .../rerun/rerun_visualizer.py | 19 +- .../viser/viser_visualizer.py | 27 +- 42 files changed, 1609 insertions(+), 1639 deletions(-) delete mode 100644 docs/source/api/lab_newton/isaaclab_newton.scene_data_providers.rst delete mode 100644 docs/source/api/lab_physx/isaaclab_physx.scene_data_providers.rst create mode 100644 source/isaaclab/changelog.d/dev-scene-data-provider-api.minor.rst delete mode 100644 source/isaaclab/isaaclab/physics/base_scene_data_provider.py create mode 100644 source/isaaclab/isaaclab/physics/scene_data_backend.py delete mode 100644 source/isaaclab/isaaclab/physics/scene_data_provider.py create mode 100644 source/isaaclab/isaaclab/scene/scene_data_provider.py create mode 100644 source/isaaclab/test/sim/test_newton_manager_visualization_state.py delete mode 100644 source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py create mode 100644 source/isaaclab_newton/changelog.d/dev-scene-data-provider-api.minor.rst delete mode 100644 source/isaaclab_newton/isaaclab_newton/scene_data_providers/__init__.py delete mode 100644 source/isaaclab_newton/isaaclab_newton/scene_data_providers/__init__.pyi delete mode 100644 source/isaaclab_newton/isaaclab_newton/scene_data_providers/newton_scene_data_provider.py create mode 100644 source/isaaclab_ov/changelog.d/dev-scene-data-provider-api.rst create mode 100644 source/isaaclab_physx/changelog.d/dev-scene-data-provider-api.minor.rst delete mode 100644 source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.py delete mode 100644 source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.pyi delete mode 100644 source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py create mode 100644 source/isaaclab_tasks/changelog.d/dev-scene-data-provider-api.rst diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index bab5f025a78e..a378a2333e0f 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -128,7 +128,6 @@ The following modules are available in the ``isaaclab_physx`` extension: cloner physics renderers - scene_data_providers sensors sim.schemas sim.spawners @@ -140,7 +139,6 @@ The following modules are available in the ``isaaclab_physx`` extension: lab_physx/isaaclab_physx.cloner lab_physx/isaaclab_physx.physics lab_physx/isaaclab_physx.renderers - lab_physx/isaaclab_physx.scene_data_providers lab_physx/isaaclab_physx.sensors lab_physx/isaaclab_physx.sim.schemas lab_physx/isaaclab_physx.sim.spawners @@ -159,7 +157,6 @@ The following modules are available in the ``isaaclab_newton`` extension: cloner physics renderers - scene_data_providers sensors sim.schemas @@ -170,7 +167,6 @@ The following modules are available in the ``isaaclab_newton`` extension: lab_newton/isaaclab_newton.cloner lab_newton/isaaclab_newton.physics lab_newton/isaaclab_newton.renderers - lab_newton/isaaclab_newton.scene_data_providers lab_newton/isaaclab_newton.sensors lab_newton/isaaclab_newton.sim.schemas diff --git a/docs/source/api/lab_newton/isaaclab_newton.scene_data_providers.rst b/docs/source/api/lab_newton/isaaclab_newton.scene_data_providers.rst deleted file mode 100644 index 746a0e62ff6b..000000000000 --- a/docs/source/api/lab_newton/isaaclab_newton.scene_data_providers.rst +++ /dev/null @@ -1,4 +0,0 @@ -isaaclab\_newton.scene\_data\_providers -======================================= - -.. automodule:: isaaclab_newton.scene_data_providers diff --git a/docs/source/api/lab_physx/isaaclab_physx.scene_data_providers.rst b/docs/source/api/lab_physx/isaaclab_physx.scene_data_providers.rst deleted file mode 100644 index 937ef16b530b..000000000000 --- a/docs/source/api/lab_physx/isaaclab_physx.scene_data_providers.rst +++ /dev/null @@ -1,4 +0,0 @@ -isaaclab\_physx.scene\_data\_providers -====================================== - -.. automodule:: isaaclab_physx.scene_data_providers diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index c68816c0da00..26e9bec98414 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -507,8 +507,8 @@ Currently, live plots are only available in the Kit Visualizer. **Viser Visualizer Renderer Requirement** The Viser visualizer requires a Newton model, which is provided automatically by -:class:`~isaaclab.physics.SceneDataProvider` regardless of the active physics backend or -renderer. It is compatible with all rendering backends (RTX, Newton Warp, OVRTX). +:class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` regardless of the active physics +backend or renderer. It is compatible with all rendering backends (RTX, Newton Warp, OVRTX). **Newton Visualizer CUDA/OpenGL Interoperability Warnings** diff --git a/docs/source/overview/core-concepts/multi_backend_architecture.rst b/docs/source/overview/core-concepts/multi_backend_architecture.rst index 7a0edce5516a..e56be6fbd133 100644 --- a/docs/source/overview/core-concepts/multi_backend_architecture.rst +++ b/docs/source/overview/core-concepts/multi_backend_architecture.rst @@ -51,10 +51,10 @@ This pattern applies to all simulation components: - :class:`~isaaclab.renderers.Renderer` - :class:`~isaaclab_physx.renderers.IsaacRtxRenderer` - :class:`~isaaclab_newton.renderers.NewtonWarpRenderer` - * - Scene Data Provider - - :class:`~isaaclab.physics.SceneDataProvider` - - :class:`~isaaclab_physx.scene_data_providers.PhysxSceneDataProvider` - - :class:`~isaaclab_newton.scene_data_providers.NewtonSceneDataProvider` + * - Scene Data Backend + - :class:`~isaaclab.physics.SceneDataBackend` + - ``PhysxSceneDataBackend`` (in :mod:`isaaclab_physx.physics`) + - ``NewtonSceneDataBackend`` (in :mod:`isaaclab_newton.physics`) * - Cloner - :func:`~isaaclab.cloner.usd_replicate` - :func:`~isaaclab_physx.cloner.physx_replicate` @@ -261,24 +261,54 @@ the established conventions: │ └── ... ├── renderers/ │ └── ... - ├── cloner/ - │ └── ... - └── scene_data_providers/ + └── cloner/ └── ... **2. Implement the physics manager:** +The manager must expose a :class:`~isaaclab.physics.SceneDataBackend` so that +:class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` can read your backend's body +transforms in a Warp-native format that renderers and visualizers consume directly. + .. code-block:: python # isaaclab_mybackend/physics/mybackend_manager.py - from isaaclab.physics import PhysicsManager + from isaaclab.physics import PhysicsManager, SceneDataBackend, SceneDataFormat + + + class MyBackendSceneDataBackend(SceneDataBackend): + def __init__(self): + self._scene_data = SceneDataFormat.Transform() + + @property + def transforms(self) -> SceneDataFormat.Transform: + # Return current world-space body transforms as a Warp ``transformf`` array. + self._scene_data.transforms = ... # backend-native tensor view + return self._scene_data + + @property + def transform_count(self) -> int: + ... + + @property + def transform_paths(self) -> list[str]: + # Prim path per row of ``transforms``; used by ``SceneDataProvider.create_mapping``. + ... + class MyBackendManager(PhysicsManager): + _scene_data_backend: ClassVar[MyBackendSceneDataBackend | None] = None + @classmethod def initialize(cls, sim_context): super().initialize(sim_context) + cls._scene_data_backend = MyBackendSceneDataBackend() # Initialize your physics engine + @classmethod + def get_scene_data_backend(cls) -> SceneDataBackend: + return cls._scene_data_backend + @classmethod def step(cls): # Advance simulation by one timestep diff --git a/docs/source/overview/core-concepts/scene_data_providers.rst b/docs/source/overview/core-concepts/scene_data_providers.rst index 527c59a03c12..a279735efb31 100644 --- a/docs/source/overview/core-concepts/scene_data_providers.rst +++ b/docs/source/overview/core-concepts/scene_data_providers.rst @@ -1,85 +1,104 @@ -Scene Data Providers -==================== +Scene Data Provider +=================== -Scene Data Providers bridge physics simulation backends and visualization/rendering systems in -Isaac Lab. They provide a unified interface for accessing scene data (transforms, velocities, -Newton model/state) regardless of which physics backend is active. +The :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` bridges physics simulation +backends and the visualizers/renderers that consume scene data. It exposes a single Warp-native +read path for body transforms regardless of which physics backend (PhysX or Newton) is active, +so renderers and visualizers can stay backend-agnostic. Overview -------- Isaac Lab supports multiple physics backends (PhysX and Newton) and multiple visualizers -(Omniverse Kit, Newton, Rerun, Viser). Each combination requires scene data to flow from the -physics engine to the renderer. Scene Data Providers handle this translation automatically -through a factory pattern. +(Omniverse Kit, Newton, Rerun, Viser). Each combination needs scene data to flow from the +physics engine into the renderer or visualizer. The :class:`SceneDataProvider` owns this flow: +the physics manager provides a :class:`~isaaclab.physics.SceneDataBackend` that wraps its +native tensor views, and the provider handles format conversion and re-mapping on top of it. .. code-block:: python - from isaaclab.physics import SceneDataProvider + from isaaclab.sim import SimulationContext - # Factory auto-selects the correct implementation based on active physics backend - provider = SceneDataProvider(stage, simulation_context) + # The SimulationContext owns the active provider; consumers fetch it instead of + # constructing one directly. + provider = SimulationContext.instance().get_scene_data_provider() Architecture ------------ The system has three layers: -1. **BaseSceneDataProvider** — abstract interface defining the contract: - - - ``update()`` — refresh cached scene data (full Newton model/state sync when applicable) - - ``get_newton_model()`` — return Newton model 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 - - ``get_contacts()`` — return contact data - - ``get_camera_transforms()`` — return per-camera, per-env transforms - - ``get_metadata()`` — return backend metadata (num_envs, gravity, etc.) - -2. **SceneDataProvider** — factory that auto-selects the backend-specific implementation - based on the active :class:`~isaaclab.physics.PhysicsManager`. - -3. **Backend implementations:** - - - :class:`~isaaclab_physx.scene_data_providers.PhysxSceneDataProvider` - - :class:`~isaaclab_newton.scene_data_providers.NewtonSceneDataProvider` - -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. - -The sync pipeline: - -1. Reads transforms from PhysX ``RigidBodyView`` (fast tensor API) -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 --------------------------- - -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. - -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 +1. :class:`~isaaclab.physics.SceneDataBackend` — small interface implemented by each physics + manager. It exposes the backend's transform array directly as one of the + :class:`~isaaclab.physics.SceneDataFormat` Warp structs, plus the per-transform prim paths + and total count. There is no per-frame "update" call — the property accessors return live + views into the underlying tensor each time they're read. + + - :attr:`SceneDataBackend.transforms` — current transforms as a Warp struct (one of + :class:`SceneDataFormat.Vec3_Quat`, :class:`SceneDataFormat.Transform`, + :class:`SceneDataFormat.Matrix44`, :class:`SceneDataFormat.Vec3_Matrix33`). + - :attr:`SceneDataBackend.transform_count` — number of transforms. + - :attr:`SceneDataBackend.transform_paths` — list of USD prim paths, one per transform. + +2. :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` — wraps a backend and offers + format conversion plus index re-mapping: + + - :meth:`SceneDataProvider.get_transforms` — write the backend's transforms into a + consumer-provided :class:`SceneDataFormat` struct, optionally converting format + (e.g. ``Vec3_Quat`` → ``Transform``) and applying an index mapping. When the backend + format matches the output format and no mapping is provided, the result is a zero-copy + passthrough. + - :meth:`SceneDataProvider.create_mapping` — build a remap array from the backend's prim + paths to a consumer's desired ordering. Used when a renderer or visualizer wants + transforms indexed by its own body list rather than by the physics view order. + - :meth:`SceneDataProvider.get_camera_transforms` — discover per-camera, per-env + world transforms from the USD stage. + - :attr:`SceneDataProvider.usd_stage` — USD stage handle for stage-walking consumers. + - :attr:`SceneDataProvider.num_envs` — environment count inferred from + ``/World/envs/env_`` prims. + +3. Backend implementations: + + - ``PhysxSceneDataBackend`` (internal to :mod:`isaaclab_physx.physics`) wraps PhysX's + ``RigidBodyView`` and exposes its transforms as :class:`SceneDataFormat.Transform`. + - ``NewtonSceneDataBackend`` (internal to :mod:`isaaclab_newton.physics`) wraps the + Newton model's ``body_q`` and exposes it as :class:`SceneDataFormat.Transform`. + +PhysX backend +------------- + +When PhysX is the active physics backend, the provider reads transforms directly from PhysX's +``RigidBodyView`` (a wildcard-expanded tensor view covering every rigid body across all envs). +The transforms are returned as :class:`SceneDataFormat.Transform` (Warp ``transformf`` array), +so consumers that want this format get them zero-copy. + +Newton-native consumers (Newton visualizer, Rerun, Viser, Newton Warp renderer, OVRTX renderer) +additionally need a Newton ``Model``/``State`` to render against. To satisfy that requirement, +:class:`~isaaclab_newton.physics.NewtonManager` builds a **shadow Newton model** from the USD +stage on first access and updates its ``body_q`` from the PhysX backend each render frame. +This is hidden behind :meth:`NewtonManager.get_model` / :meth:`NewtonManager.get_state`, so +renderers don't need to know which physics backend is active. + +Newton backend +-------------- + +When Newton is the active physics backend, the backend wraps the Newton model's ``body_q`` +directly. No shadow model or per-frame sync is needed — Newton already owns the authoritative +model and state, and the provider exposes that state as +:class:`SceneDataFormat.Transform`. + +Data requirements ----------------- -Visualizers and renderers declare their data needs, and the provider is configured accordingly: +Visualizers and renderers declare what they need from the scene data path. This is resolved at +simulation-context construction time and is what triggers the shadow-model build for PhysX: .. list-table:: :header-rows: 1 * - Component - - Requires Newton Model - - Requires USD Stage + - Requires Newton model + - Requires USD stage * - Kit visualizer - No - Yes @@ -92,7 +111,7 @@ Visualizers and renderers declare their data needs, and the provider is configur * - Viser visualizer - Yes - No - * - RTX renderer + * - Isaac RTX renderer - No - Yes * - Newton Warp renderer diff --git a/source/isaaclab/changelog.d/dev-scene-data-provider-api.minor.rst b/source/isaaclab/changelog.d/dev-scene-data-provider-api.minor.rst new file mode 100644 index 000000000000..cf94f454adbd --- /dev/null +++ b/source/isaaclab/changelog.d/dev-scene-data-provider-api.minor.rst @@ -0,0 +1,34 @@ +Added +^^^^^ + +* Added :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.usd_stage`, + :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.num_envs`, and + :meth:`~isaaclab.scene.scene_data_provider.SceneDataProvider.get_camera_transforms` + so visualizers and renderers can pull stage-derived data through the same + Warp-native provider that already exposes transforms. + +Changed +^^^^^^^ + +* **Breaking:** :class:`~isaaclab.visualizers.base_visualizer.BaseVisualizer` + subclasses now receive a + :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` in + :meth:`~isaaclab.visualizers.base_visualizer.BaseVisualizer.initialize` + instead of the removed ``BaseSceneDataProvider``. Read environment count + from :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.num_envs` + and call + :meth:`~isaaclab.scene.scene_data_provider.SceneDataProvider.get_camera_transforms` + on the new provider; both replace the previous ``get_metadata()`` / + ``get_camera_transforms()`` calls on the legacy interface. + +Removed +^^^^^^^ + +* **Breaking:** Removed ``isaaclab.physics.BaseSceneDataProvider``, + ``isaaclab.physics.SceneDataProvider`` (the legacy factory), + ``SimulationContext.initialize_scene_data_provider()``, and + ``SimulationContext.update_scene_data_provider()``. Use + :meth:`~isaaclab.sim.simulation_context.SimulationContext.get_scene_data_provider` + to obtain the new provider; consumers that previously called + ``get_newton_model()`` / ``get_newton_state()`` should call + ``NewtonManager.get_model()`` / ``NewtonManager.get_state()`` instead. diff --git a/source/isaaclab/isaaclab/physics/__init__.pyi b/source/isaaclab/isaaclab/physics/__init__.pyi index 14d0d4d61739..d2ceced82eb2 100644 --- a/source/isaaclab/isaaclab/physics/__init__.pyi +++ b/source/isaaclab/isaaclab/physics/__init__.pyi @@ -8,11 +8,10 @@ __all__ = [ "PhysicsEvent", "PhysicsManager", "PhysicsCfg", - "BaseSceneDataProvider", - "SceneDataProvider", + "SceneDataBackend", + "SceneDataFormat", ] -from .base_scene_data_provider import BaseSceneDataProvider from .physics_manager import CallbackHandle, PhysicsEvent, PhysicsManager from .physics_manager_cfg import PhysicsCfg -from .scene_data_provider import SceneDataProvider +from .scene_data_backend import SceneDataBackend, SceneDataFormat diff --git a/source/isaaclab/isaaclab/physics/base_scene_data_provider.py b/source/isaaclab/isaaclab/physics/base_scene_data_provider.py deleted file mode 100644 index 9760a71d25ba..000000000000 --- a/source/isaaclab/isaaclab/physics/base_scene_data_provider.py +++ /dev/null @@ -1,60 +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 - -"""Scene data provider interface for visualizers and renderers.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any - - -class BaseSceneDataProvider(ABC): - """Backend-agnostic scene data provider interface.""" - - @abstractmethod - def update(self) -> None: - """Refresh any cached scene data (full model/state).""" - raise NotImplementedError - - @abstractmethod - def get_newton_model(self) -> Any | None: - """Return Newton model handle when available.""" - raise NotImplementedError - - @abstractmethod - def get_newton_state(self) -> Any | None: - """Return Newton state handle when available (full state).""" - raise NotImplementedError - - @abstractmethod - def get_usd_stage(self) -> Any | None: - """Return USD stage handle when available.""" - raise NotImplementedError - - @abstractmethod - def get_metadata(self) -> dict[str, Any]: - """Return backend metadata (num_envs, gravity, etc.).""" - raise NotImplementedError - - @abstractmethod - def get_transforms(self) -> dict[str, Any] | None: - """Return body transforms, if supported.""" - raise NotImplementedError - - @abstractmethod - def get_velocities(self) -> dict[str, Any] | None: - """Return body velocities, if supported.""" - raise NotImplementedError - - @abstractmethod - def get_contacts(self) -> dict[str, Any] | None: - """Return contacts, if supported.""" - raise NotImplementedError - - @abstractmethod - def get_camera_transforms(self) -> dict[str, Any] | None: - """Return per-camera, per-env transforms, if supported.""" - raise NotImplementedError diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 7a4cdfe84403..6727d87174b6 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, ClassVar if TYPE_CHECKING: + from isaaclab.physics.scene_data_backend import SceneDataBackend from isaaclab.sim.simulation_context import SimulationContext logger = logging.getLogger(__name__) @@ -259,6 +260,12 @@ def forward(cls) -> None: """Update kinematics without stepping physics (for rendering).""" pass + @classmethod + @abstractmethod + def get_scene_data_backend(cls) -> SceneDataBackend: + """Return the SceneDataBackend for the SceneDataProvider.""" + pass + @classmethod @abstractmethod def step(cls) -> None: diff --git a/source/isaaclab/isaaclab/physics/scene_data_backend.py b/source/isaaclab/isaaclab/physics/scene_data_backend.py new file mode 100644 index 000000000000..d94bea9e76c9 --- /dev/null +++ b/source/isaaclab/isaaclab/physics/scene_data_backend.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 + +"""Backend interface and data formats for the scene data provider. + +These types live in :mod:`isaaclab.physics` rather than +:mod:`isaaclab.scene.scene_data_provider` so that physics backends +(``isaaclab_physx``, ``isaaclab_newton``) can subclass +:class:`SceneDataBackend` without pulling :mod:`isaaclab.scene` into the +``AppLauncher`` pre-launch import chain. ``AppLauncher._create_app`` pops +``*lab*`` modules from ``sys.modules`` during Kit init and any submodule +imported during that window ends up orphaned from its parent's +``__dict__`` after restoration. +""" + +from __future__ import annotations + +import warp as wp + + +class SceneDataFormat: + @wp.struct + class Vec3_Quat: + positions: wp.array(dtype=wp.vec3f) = None + orientations: wp.array(dtype=wp.quatf) = None + + @wp.struct + class Vec3_Matrix33: + positions: wp.array(dtype=wp.vec3f) = None + orientations: wp.array(dtype=wp.mat33f) = None + + @wp.struct + class Transform: + transforms: wp.array(dtype=wp.transformf) = None + + @wp.struct + class Matrix44: + matrices: wp.array(dtype=wp.mat44f) = None + + +class SceneDataBackend: + @property + def transforms( + self, + ) -> ( + SceneDataFormat.Vec3_Quat | SceneDataFormat.Transform | SceneDataFormat.Matrix44 | SceneDataFormat.Vec3_Matrix33 + ): + """Return the sim backends transforms as one of the SceneDataFormat structs.""" + raise NotImplementedError + + @property + def transform_count(self) -> int: + """Return the number of transforms in the sim backend.""" + raise NotImplementedError + + @property + def transform_paths(self) -> list[str]: + """Return the paths for each transform.""" + raise NotImplementedError diff --git a/source/isaaclab/isaaclab/physics/scene_data_provider.py b/source/isaaclab/isaaclab/physics/scene_data_provider.py deleted file mode 100644 index 0095a413158d..000000000000 --- a/source/isaaclab/isaaclab/physics/scene_data_provider.py +++ /dev/null @@ -1,44 +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 - -"""Factory for creating scene data provider instances.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from isaaclab.utils.backend_utils import FactoryBase - -from .base_scene_data_provider import BaseSceneDataProvider - -if TYPE_CHECKING: - from isaaclab.sim import SimulationContext - - -class SceneDataProvider(FactoryBase, BaseSceneDataProvider): - """Factory for creating scene data provider instances.""" - - _backend_class_names = {"physx": "PhysxSceneDataProvider", "newton": "NewtonSceneDataProvider"} - - @classmethod - def _get_backend(cls, stage, simulation_context: SimulationContext, *args, **kwargs) -> str: - manager_name = simulation_context.physics_manager.__name__.lower() - if "newton" in manager_name: - return "newton" - if "physx" in manager_name: - return "physx" - raise ValueError(f"Unknown physics manager: {manager_name}") - - @classmethod - def _get_module_name(cls, backend: str) -> str: - return f"isaaclab_{backend}.scene_data_providers" - - def __new__(cls, stage, simulation_context: SimulationContext, *args, **kwargs) -> BaseSceneDataProvider: - """Create a new scene data provider based on the active physics backend.""" - result = super().__new__(cls, stage, simulation_context, *args, **kwargs) - if not isinstance(result, BaseSceneDataProvider): - name = type(result).__name__ - raise TypeError(f"Backend scene data provider {name!r} must inherit from BaseSceneDataProvider.") - return result diff --git a/source/isaaclab/isaaclab/scene/scene_data_provider.py b/source/isaaclab/isaaclab/scene/scene_data_provider.py new file mode 100644 index 000000000000..a44700dd575c --- /dev/null +++ b/source/isaaclab/isaaclab/scene/scene_data_provider.py @@ -0,0 +1,521 @@ +# 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 contextlib +import re +from collections import deque +from typing import TYPE_CHECKING, Any + +import numpy as np +import warp as wp + +from isaaclab.physics.scene_data_backend import SceneDataBackend, SceneDataFormat + +if TYPE_CHECKING: + from pxr import Usd + + +class SceneDataProvider: + def __init__(self, backend: SceneDataBackend): + """Initialize the scene data provider. + + Args: + backend: The simulation backend that supplies raw transform data. + """ + self.backend = backend + self._num_envs_cache: int | None = None + + @property + def transform_count(self) -> int: + """Number of transforms available from the sim backend.""" + return self.backend.transform_count + + @property + def usd_stage(self) -> Usd.Stage | None: + """Pixar :class:`Usd.Stage` for visualizers and renderers that walk USD. + + Resolves to :attr:`isaaclab.sim.SimulationContext.stage`, falling back to + ``omni.usd.get_context().get_stage()`` when the simulation context has no + cached stage. Returns ``None`` on Newton-only headless runs without a USD + stage. + """ + from isaaclab.sim import SimulationContext + + sim = SimulationContext.instance() + stage = getattr(sim, "stage", None) if sim is not None else None + if stage is not None: + return stage + try: + import omni.usd + + return omni.usd.get_context().get_stage() + except Exception: + return None + + @property + def num_envs(self) -> int: + """Number of environments discovered from ``/World/envs/env_`` prims. + + Cached on first call. Returns ``0`` when no USD stage is available or when + no ``/World/envs/env_`` prims exist. + """ + if self._num_envs_cache is not None: + return self._num_envs_cache + self._num_envs_cache = _discover_num_envs(self.usd_stage) + return self._num_envs_cache + + def get_camera_transforms(self) -> dict[str, Any] | None: + """Per-camera, per-environment world transforms discovered from USD. + + Returns: + Dictionary with keys ``order`` (list of template prim paths using + ``env_%d``), ``positions`` and ``orientations`` (per-camera, per-env + lists, with ``None`` for absent envs), and ``num_envs``. Returns + ``None`` when no USD stage is available. + """ + return _walk_camera_prims(self.usd_stage) + + def get_transforms( + self, + output: SceneDataFormat.Vec3_Quat + | SceneDataFormat.Transform + | SceneDataFormat.Matrix44 + | SceneDataFormat.Vec3_Matrix33, + mapping: wp.array(dtype=wp.int32) | None = None, + allow_passthrough: bool = True, + ) -> bool: + """Convert sim backend transforms into the requested output format. + + When the backend's native format matches ``output``, data is either passed + through by reference (``allow_passthrough=True``) or deep-copied. Otherwise a + Warp conversion kernel is launched to transform the data, applying ``mapping`` + to reorder the output if provided. + + Args: + output: A pre-allocated :class:`SceneDataFormat` struct that determines the + target format. Uninitialized (``None``) fields are allocated automatically + when a conversion kernel is needed. + mapping: Optional index remapping array produced by + :meth:`create_mapping`. When ``None``, input and output indices are + identical. + allow_passthrough: If ``True`` and the formats already match, the output + struct's fields are set to reference the input arrays directly + (zero-copy). If ``False``, the data is always copied. + + Returns: + ``True`` if the conversion succeeded, ``False`` if no suitable conversion + kernel exists for the input/output format pair. + """ + input = self.backend.transforms + + if mapping is None and type(input) is type(output): + if allow_passthrough: + for field_name in input._cls.vars: + setattr(output, field_name, getattr(input, field_name)) + else: + self.init_output(output) + for field_name in input._cls.vars: + wp.copy(getattr(output, field_name), getattr(input, field_name)) + return True + + conversion_kernel_name = f"convert_{input._cls.__name__}_to_{output._cls.__name__}" + + if conversion_kernel := getattr(ConversionKernels, conversion_kernel_name, None): + self.init_output(output) + wp.launch(kernel=conversion_kernel, dim=self.transform_count, inputs=[input, mapping], outputs=[output]) + return True + + return False + + def init_output( + self, + output: SceneDataFormat.Vec3_Quat + | SceneDataFormat.Transform + | SceneDataFormat.Matrix44 + | SceneDataFormat.Vec3_Matrix33, + ): + """Allocate any uninitialized fields in ``output`` with empty Warp arrays. + + Only fields that are currently ``None`` are allocated; already-initialized + fields are left untouched. + + Args: + output: A :class:`SceneDataFormat` struct whose ``None``-valued fields + will be replaced with empty arrays of length :attr:`transform_count`. + """ + for field_name, field_value in output._cls.vars.items(): + if getattr(output, field_name) is None: + setattr(output, field_name, wp.empty(self.transform_count, dtype=field_value.type.dtype)) + + def create_mapping(self, paths: list[str | None]) -> wp.array(dtype=wp.int32) | None: + """Create an index mapping from sim backend transforms to desired output ordering. + + For each transform in the sim backend, the resulting array stores the index into + ``paths`` where that transform should be written. Transforms whose path does not + appear in ``paths`` (or maps to ``None``) receive an index of ``-1`` and are + skipped during conversion. + + Args: + paths: Desired output ordering expressed as prim paths. Use ``None`` for + slots that should not receive any transform. + + Returns: + A Warp int32 array of length :attr:`transform_count` containing the + remapped indices, or ``None`` if the sim backend provides no transform + paths or if no mapping is needed. + """ + if input_paths := self.backend.transform_paths: + mapping = [-1] * len(input_paths) + for i, path in enumerate(input_paths): + with contextlib.suppress(ValueError): + mapping[i] = paths.index(path) + if not np.array_equal(mapping, np.arange(len(input_paths))): + return wp.array(mapping, dtype=wp.int32) + return None + + +class ConversionKernels: + @wp.func + def get_output_index(tid: wp.int32, mapping: wp.array(dtype=wp.int32)) -> wp.int32: + if not mapping.shape[0]: + return tid + if tid < mapping.shape[0]: + return mapping[tid] + return wp.int32(-1) + + @wp.kernel + def convert_Vec3_Quat_to_Vec3_Quat( + input: SceneDataFormat.Vec3_Quat, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Vec3_Quat + ): + """Pass-through Vec3/Quat""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.positions[idx] = input.positions[tid] + output.orientations[idx] = input.orientations[tid] + + @wp.kernel + def convert_Vec3_Quat_to_Vec3_Matrix33( + input: SceneDataFormat.Vec3_Quat, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Vec3_Matrix33 + ): + """Convert Vec3/Quat to Vec3/Matrix33""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.positions[idx] = input.positions[tid] + output.orientations[idx] = wp.quat_to_matrix(input.orientations[tid]) + + @wp.kernel + def convert_Vec3_Quat_to_Transform( + input: SceneDataFormat.Vec3_Quat, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Transform + ): + """Convert Vec3/Quat to Transform""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.transforms[idx] = wp.transformf(input.positions[tid], input.orientations[tid]) + + @wp.kernel + def convert_Vec3_Quat_to_Matrix44( + input: SceneDataFormat.Vec3_Quat, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Matrix44 + ): + """Convert Vec3/Quat to Matrix44""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.matrices[idx] = wp.transform_to_matrix(wp.transformf(input.positions[tid], input.orientations[tid])) + + @wp.kernel + def convert_Vec3_Matrix33_to_Vec3_Quat( + input: SceneDataFormat.Vec3_Matrix33, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Vec3_Quat + ): + """Convert Vec3/Matrix33 to Vec3/Quat""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.positions[idx] = input.positions[tid] + output.orientations[idx] = wp.quat_from_matrix(input.orientations[tid]) + + @wp.kernel + def convert_Vec3_Matrix33_to_Vec3_Matrix33( + input: SceneDataFormat.Vec3_Matrix33, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Vec3_Matrix33 + ): + """Pass-through Vec3/Matrix33""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.positions[idx] = input.positions[tid] + output.orientations[idx] = input.orientations[tid] + + @wp.kernel + def convert_Vec3_Matrix33_to_Transform( + input: SceneDataFormat.Vec3_Matrix33, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Transform + ): + """Convert Vec3/Matrix33 to Transform""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.transforms[idx] = wp.transformf(input.positions[tid], wp.quat_from_matrix(input.orientations[tid])) + + @wp.kernel + def convert_Vec3_Matrix33_to_Matrix44( + input: SceneDataFormat.Vec3_Matrix33, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Matrix44 + ): + """Convert Vec3/Matrix33 to Matrix44""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + transform = wp.transformf(input.positions[tid], wp.quat_from_matrix(input.orientations[tid])) + output.matrices[idx] = wp.transform_to_matrix(transform) + + @wp.kernel + def convert_Transform_to_Vec3_Quat( + input: SceneDataFormat.Transform, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Vec3_Quat + ): + """Convert Transform to Vec3/Quat""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.positions[idx] = wp.transform_get_translation(input.transforms[tid]) + output.orientations[idx] = wp.transform_get_rotation(input.transforms[tid]) + + @wp.kernel + def convert_Transform_to_Vec3_Matrix33( + input: SceneDataFormat.Transform, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Vec3_Matrix33 + ): + """Convert Transform to Vec3/Matrix33""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.positions[idx] = wp.transform_get_translation(input.transforms[tid]) + output.orientations[idx] = wp.quat_to_matrix(wp.transform_get_rotation(input.transforms[tid])) + + @wp.kernel + def convert_Transform_to_Transform( + input: SceneDataFormat.Transform, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Transform + ): + """Pass-through Transform""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.transforms[idx] = input.transforms[tid] + + @wp.kernel + def convert_Transform_to_Matrix44( + input: SceneDataFormat.Transform, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Matrix44 + ): + """Convert Transform to Matrix44""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.matrices[idx] = wp.transform_to_matrix(input.transforms[tid]) + + @wp.kernel + def convert_Matrix44_to_Vec3_Quat( + input: SceneDataFormat.Matrix44, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Vec3_Quat + ): + """Convert Matrix44 to Vec3/Quat""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + transform = wp.transform_from_matrix(input.matrices[tid]) + output.positions[idx] = wp.transform_get_translation(transform) + output.orientations[idx] = wp.transform_get_rotation(transform) + + @wp.kernel + def convert_Matrix44_to_Vec3_Matrix33( + input: SceneDataFormat.Matrix44, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Vec3_Matrix33 + ): + """Convert Matrix44 to Vec3/Matrix33""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + transform = wp.transform_from_matrix(input.matrices[tid]) + output.positions[idx] = wp.transform_get_translation(transform) + output.orientations[idx] = wp.quat_to_matrix(wp.transform_get_rotation(transform)) + + @wp.kernel + def convert_Matrix44_to_Transform( + input: SceneDataFormat.Matrix44, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Transform + ): + """Convert Matrix44 to Transform""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.transforms[idx] = wp.transform_from_matrix(input.matrices[tid]) + + @wp.kernel + def convert_Matrix44_to_Matrix44( + input: SceneDataFormat.Matrix44, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Matrix44 + ): + """Pass-through Matrix44""" + tid = wp.tid() + idx = ConversionKernels.get_output_index(tid, mapping) + if idx > -1: + output.matrices[idx] = input.matrices[tid] + + +_ENV_NAME_RE = re.compile(r"^env_(\d+)$") +_ENV_PATH_RE = re.compile(r"(?P/World/envs/env_)(?P\d+)(?P/.*)") + + +def _discover_num_envs(stage: Usd.Stage | None) -> int: + """Infer environment count from ``/World/envs/env_`` prim names on ``stage``. + + Args: + stage: USD stage to inspect, or ``None``. + + Returns: + Number of environments discovered, or ``0`` when ``stage`` is ``None`` or no + ``/World/envs/env_`` prims exist. + """ + if stage is None: + return 0 + max_env_id = -1 + envs_root = stage.GetPrimAtPath("/World/envs") + if envs_root.IsValid(): + for child in envs_root.GetChildren(): + if match := _ENV_NAME_RE.match(child.GetName()): + max_env_id = max(max_env_id, int(match.group(1))) + return max_env_id + 1 if max_env_id >= 0 else 0 + + +def _walk_camera_prims(stage: Usd.Stage | None) -> dict[str, Any] | None: + """Walk ``stage`` and collect per-environment camera transforms. + + Args: + stage: USD stage to traverse, or ``None``. + + Returns: + Dictionary with keys ``order`` (template prim paths using ``env_%d``), + ``positions``, ``orientations`` (per-camera, per-env, with ``None`` for + absent envs), and ``num_envs``. Returns ``None`` when ``stage`` is ``None``. + """ + if stage is None: + return None + + from pxr import UsdGeom + + import isaaclab.sim as isaaclab_sim + + shared_paths: list[str] = [] + instances: dict[str, list[tuple[int, str]]] = {} + num_envs = -1 + + stage_prims = deque([stage.GetPseudoRoot()]) + while stage_prims: + prim = stage_prims.popleft() + prim_path = prim.GetPath().pathString + + world_id = 0 + template_path = prim_path + if match := _ENV_PATH_RE.match(prim_path): + world_id = int(match.group("id")) + template_path = match.group("root") + "%d" + match.group("path") + if world_id > num_envs: + num_envs = world_id + + imageable = UsdGeom.Imageable(prim) + if imageable and imageable.ComputeVisibility() == UsdGeom.Tokens.invisible: + continue + + if prim.IsA(UsdGeom.Camera): + instances.setdefault(template_path, []).append((world_id, prim_path)) + if template_path not in shared_paths: + shared_paths.append(template_path) + + if hasattr(UsdGeom, "TraverseInstanceProxies"): + child_prims = prim.GetFilteredChildren(UsdGeom.TraverseInstanceProxies()) + else: + child_prims = prim.GetChildren() + if child_prims: + stage_prims.extend(child_prims) + + num_envs += 1 + positions: list[list[list[float] | None]] = [] + orientations: list[list[list[float] | None]] = [] + + for template_path in shared_paths: + per_world_pos: list[list[float] | None] = [None] * num_envs + per_world_ori: list[list[float] | None] = [None] * num_envs + for world_id, prim_path in instances.get(template_path, []): + if world_id < 0 or world_id >= num_envs: + continue + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + continue + pos, ori = isaaclab_sim.resolve_prim_pose(prim) + per_world_pos[world_id] = [float(pos[0]), float(pos[1]), float(pos[2])] + per_world_ori[world_id] = [float(ori[0]), float(ori[1]), float(ori[2]), float(ori[3])] + positions.append(per_world_pos) + orientations.append(per_world_ori) + + return {"order": shared_paths, "positions": positions, "orientations": orientations, "num_envs": num_envs} + + +############################ +## Example + +if __name__ == "__main__": + + class ExampleSceneDataBackend(SceneDataBackend): + def __init__(self): + self.__transforms = SceneDataFormat.Transform() + self.__transforms.transforms = wp.array(np.hstack([np.arange(10).reshape(10, 1)] * 7), dtype=wp.transformf) + + @property + def transforms(self) -> SceneDataFormat.Transform: + return self.__transforms + + @property + def transform_count(self) -> int: + return self.__transforms.transforms.shape[0] + + @property + def transform_paths(self): + return [ + "/world/shape_01", + "/world/shape_02", + "/world/shape_03", + "/world/shape_04", + "/world/shape_05", + "/world/shape_06", + "/world/shape_07", + "/world/shape_08", + "/world/shape_09", + "/world/shape_10", + ] + + sim = ExampleSceneDataBackend() + sdp = SceneDataProvider(sim) + + output_data = SceneDataFormat.Vec3_Matrix33() + output_data.positions = wp.empty(sdp.transform_count, dtype=wp.vec3f) + output_data.orientations = wp.empty(sdp.transform_count, dtype=wp.mat33f) + + print(sim.transforms.transforms) + mapping = sdp.create_mapping( + [ + "/world/shape_02", + "/world/shape_01", + "/world/shape_03", + "/world/shape_04", + "/world/shape_05", + None, + None, + "/world/shape_10", + None, + None, + ] + ) + print(mapping) + if sdp.get_transforms(output_data, mapping): + print(output_data.positions) + else: + print("Failed to get transforms!") + + wp.synchronize() diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 607221bd4874..1fd965185221 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -23,12 +23,13 @@ from isaaclab.app.settings_manager import SettingsManager from isaaclab.envs.utils.recording_hooks import run_recording_hooks_after_visualizers from isaaclab.markers.vis_marker_registry import VisMarkerRegistry -from isaaclab.physics import BaseSceneDataProvider, PhysicsEvent, PhysicsManager, SceneDataProvider +from isaaclab.physics import PhysicsEvent, PhysicsManager from isaaclab.physics.scene_data_requirements import ( SceneDataRequirement, resolve_scene_data_requirements, ) from isaaclab.renderers.render_context import RenderContext +from isaaclab.scene.scene_data_provider import SceneDataProvider 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 @@ -172,15 +173,14 @@ def __init__(self, cfg: SimulationCfg | None = None): self.physics_manager.initialize(self) self._apply_render_cfg_settings() - # Initialize visualizer state (provider/visualizers are created lazily during initialize_visualizers()). - self._scene_data_provider: BaseSceneDataProvider | None = None + # Initialize visualizer state (visualizers are created lazily during initialize_visualizers()). + self._scene_data_provider = SceneDataProvider(self.physics_manager.get_scene_data_backend()) self._visualizers: list[BaseVisualizer] = [] self._scene_data_requirements = SceneDataRequirement() # Clone plan published by InteractiveScene after cloning. Providers (e.g. the # Newton visualizer model rebuilder on a PhysX backend) consume this to derive # their own backend args. None until :meth:`InteractiveScene.clone_environments` runs. self._clone_plan: ClonePlan | None = None - self._visualizer_step_counter = 0 # Default visualization dt used before/without visualizer initialization. physics_dt = getattr(self.cfg.physics, "dt", None) self._viz_dt = (physics_dt if physics_dt is not None else self.cfg.dt) * self.cfg.render_interval @@ -593,7 +593,6 @@ def initialize_visualizers(self) -> None: ] requirements = resolve_scene_data_requirements(visualizer_types=visualizer_types) self._scene_data_requirements = requirements - self.initialize_scene_data_provider() self._visualizers = [] for cfg in visualizer_cfgs: @@ -622,15 +621,7 @@ def initialize_visualizers(self) -> None: 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): - close_provider() - self._scene_data_provider = None - - def initialize_scene_data_provider(self) -> BaseSceneDataProvider: - if self._scene_data_provider is None: - self._scene_data_provider = SceneDataProvider(self.stage, self) + def get_scene_data_provider(self) -> SceneDataProvider: return self._scene_data_provider def get_scene_data_requirements(self) -> SceneDataRequirement: @@ -759,7 +750,13 @@ def update_visualizers(self, dt: float, skip_app_pumping: bool = False) -> None: if not self._visualizers: return - self.update_scene_data_provider() + if self._should_forward_before_visualizer_update(): + self.physics_manager.forward() + + # Marker callbacks update VisualizationMarkers state; visualizer step() + # consumes that state later in this method. + if any(viz.supports_markers() for viz in self._visualizers): + self.vis_marker_registry.dispatch_callbacks() # Marker callbacks update VisualizationMarkers state; visualizer step() # consumes that state later in this method. @@ -801,14 +798,6 @@ def update_visualizers(self, dt: float, skip_app_pumping: bool = False) -> None: except Exception as exc: logger.error("Error closing visualizer: %s", exc) - def update_scene_data_provider(self, force_require_forward: bool = False): - if force_require_forward or self._should_forward_before_visualizer_update(): - self.physics_manager.forward() - self._visualizer_step_counter += 1 - if self._scene_data_provider is None: - return - self._scene_data_provider.update() - def _should_forward_before_visualizer_update(self) -> bool: """Return True if any visualizer requires pre-step forward kinematics.""" return any(viz.requires_forward_before_step() for viz in self._visualizers) @@ -864,11 +853,6 @@ def clear_instance(cls) -> None: for viz in cls._instance._visualizers: viz.close() cls._instance._visualizers.clear() - if cls._instance._scene_data_provider is not None: - close_provider = getattr(cls._instance._scene_data_provider, "close", None) - if callable(close_provider): - close_provider() - cls._instance._scene_data_provider = None # Tear down the stage. We skip clear_stage() (prim-by-prim deletion) since # close_stage() + app shutdown destroy the entire stage at once. diff --git a/source/isaaclab/isaaclab/visualizers/base_visualizer.py b/source/isaaclab/isaaclab/visualizers/base_visualizer.py index b0fc5a81088f..92f8c37ce947 100644 --- a/source/isaaclab/isaaclab/visualizers/base_visualizer.py +++ b/source/isaaclab/isaaclab/visualizers/base_visualizer.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from isaaclab.physics import SceneDataProvider + from isaaclab.scene.scene_data_provider import SceneDataProvider from .visualizer_cfg import VisualizerCfg @@ -147,9 +147,9 @@ def _compute_visualized_env_ids(self) -> list[int] | None: if self._scene_data_provider is None: return None cfg = self.cfg - num_envs = self._scene_data_provider.get_metadata().get("num_envs", 0) + num_envs = self._scene_data_provider.num_envs if num_envs <= 0: - logger.debug("[Visualizer] num_envs is 0 or missing from provider metadata; env selection disabled.") + logger.debug("[Visualizer] num_envs is 0 or missing from provider; env selection disabled.") return None # Explicit list wins; never combine with random cap-only mode. if cfg.visible_env_indices is not None: diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py new file mode 100644 index 000000000000..de0515c57e07 --- /dev/null +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -0,0 +1,162 @@ +# 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 ``NewtonManager.update_visualization_state`` and shadow-model build. + +When the active sim backend is PhysX and a Newton-native visualizer/renderer is in +use, :meth:`NewtonManager._ensure_visualization_model` must build the manager's +``_model`` / ``_state_0`` directly from the USD stage (via +:meth:`NewtonManager._build_visualization_model_from_stage`), and +:meth:`NewtonManager.update_visualization_state` must copy fresh transforms into +``_state_0.body_q`` via the new +:class:`~isaaclab.scene.scene_data_provider.SceneDataProvider`. +""" + +from __future__ import annotations + +from types import SimpleNamespace + + +def _reset_newton_manager_state(): + from isaaclab_newton.physics import NewtonManager + + NewtonManager._builder = None + NewtonManager._model = None + NewtonManager._state_0 = None + NewtonManager._num_envs = None + NewtonManager._physx_visualization_scene_data = None + NewtonManager._physx_visualization_mapping = None + + +def test_ensure_visualization_model_noop_when_backend_is_newton(monkeypatch): + """When sim backend is Newton, the manager keeps its own model/state untouched.""" + from isaaclab_newton.physics import NewtonManager + + _reset_newton_manager_state() + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: True)) + NewtonManager._ensure_visualization_model() + assert NewtonManager._model is None + assert NewtonManager._state_0 is None + + +def test_ensure_visualization_model_builds_from_stage_when_backend_is_physx(monkeypatch): + """With a PhysX sim backend, the shadow Newton model is built directly from the stage.""" + from isaaclab_newton.physics import NewtonManager + from isaaclab_newton.physics import newton_manager as nm + + _reset_newton_manager_state() + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: False)) + monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: SimpleNamespace()) + monkeypatch.setattr(nm, "replace_newton_shape_colors", lambda model, *a, **kw: 0) + + finalize_calls: list[str] = [] + + class _FakeBuilder: + body_count = 3 + + def finalize(self, device): + finalize_calls.append(device) + return SimpleNamespace(state=lambda: SimpleNamespace(body_q=None)) + + monkeypatch.setattr( + NewtonManager, + "_build_visualization_model_from_stage", + classmethod(lambda cls, stage: _FakeBuilder()), + ) + monkeypatch.setattr(nm.PhysicsManager, "_device", "cpu", raising=False) + + NewtonManager._ensure_visualization_model() + + assert finalize_calls == ["cpu"] + assert NewtonManager._model is not None + assert NewtonManager._state_0 is not None + + +def test_ensure_visualization_model_empty_builder_logs_and_skips(monkeypatch, caplog): + """When the stage walk produces no bodies, model/state stay unset and an error is logged.""" + from isaaclab_newton.physics import NewtonManager + from isaaclab_newton.physics import newton_manager as nm + + _reset_newton_manager_state() + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: False)) + monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: SimpleNamespace()) + + class _EmptyBuilder: + body_count = 0 + + monkeypatch.setattr( + NewtonManager, + "_build_visualization_model_from_stage", + classmethod(lambda cls, stage: _EmptyBuilder()), + ) + + with caplog.at_level("ERROR"): + NewtonManager._ensure_visualization_model() + + assert NewtonManager._model is None + assert NewtonManager._state_0 is None + assert any("no Newton bodies" in r.message for r in caplog.records) + + +def test_ensure_visualization_model_populates_num_envs_when_backend_is_physx(monkeypatch): + """Shadow-model build must populate ``_num_envs`` so ``get_num_envs`` is correct under PhysX.""" + from isaaclab_newton.physics import NewtonManager + from isaaclab_newton.physics import newton_manager as nm + + _reset_newton_manager_state() + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: False)) + monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: SimpleNamespace()) + monkeypatch.setattr(nm, "replace_newton_shape_colors", lambda model, *a, **kw: 0) + + class _FakeBuilder: + body_count = 3 + + def finalize(self, device): + return SimpleNamespace(state=lambda: SimpleNamespace(body_q=None)) + + def _fake_build(cls, stage): + # Mirror the real shadow-build behaviour: writes the env count discovered on the stage. + NewtonManager._num_envs = 4 + return _FakeBuilder() + + monkeypatch.setattr(NewtonManager, "_build_visualization_model_from_stage", classmethod(_fake_build)) + monkeypatch.setattr(nm.PhysicsManager, "_device", "cpu", raising=False) + + NewtonManager._ensure_visualization_model() + + assert NewtonManager.get_num_envs() == 4 + assert NewtonManager._model.num_envs == 4 + + +def test_ensure_visualization_model_missing_stage_leaves_state_unset(monkeypatch, caplog): + """When no USD stage is available, model/state stay unset and an error is logged.""" + from isaaclab_newton.physics import NewtonManager + from isaaclab_newton.physics import newton_manager as nm + + _reset_newton_manager_state() + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: False)) + monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: None) + + with caplog.at_level("ERROR"): + NewtonManager._ensure_visualization_model() + + assert NewtonManager._model is None + assert NewtonManager._state_0 is None + assert any("No USD stage available" in r.message for r in caplog.records) + + +def test_update_visualization_state_noop_when_backend_is_newton(monkeypatch): + """When sim backend is Newton, update_visualization_state is a no-op.""" + from isaaclab_newton.physics import NewtonManager + + _reset_newton_manager_state() + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: True)) + + # Pre-set sentinel values to ensure update doesn't touch them. + NewtonManager._model = "live-model" + NewtonManager._state_0 = "live-state" + NewtonManager.update_visualization_state() + assert NewtonManager._model == "live-model" + assert NewtonManager._state_0 == "live-state" 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 deleted file mode 100644 index d8e640c394f8..000000000000 --- a/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py +++ /dev/null @@ -1,199 +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 - -"""Unit tests for PhysxSceneDataProvider visualizer-facing contracts.""" - -from __future__ import annotations - -import sys -from types import SimpleNamespace - -import pytest -import torch -from isaaclab_physx.scene_data_providers import PhysxSceneDataProvider - -from isaaclab.cloner import ClonePlan - -PROVIDER_MOD = "isaaclab_physx.scene_data_providers.physx_scene_data_provider" - - -def _silent_stage() -> SimpleNamespace: - """Stage stub whose ``GetPrimAtPath`` returns an invalid prim — env xforms read as zero.""" - return SimpleNamespace(GetPrimAtPath=lambda path: SimpleNamespace(IsValid=lambda: False)) - - -@pytest.fixture -def stub_provider(): - """Bare :class:`PhysxSceneDataProvider` with all buffer attrs initialized to defaults. - - Tests assign ``_simulation_context`` and ``_stage`` themselves; everything else is the - pre-build state the build path expects. - """ - p = object.__new__(PhysxSceneDataProvider) - p._device = "cpu" - p._xform_views = {} - p._view_body_index_map = {} - p._view_order_tensors = {} - p._pose_buf_num_bodies = 0 - p._positions_buf = None - p._orientations_buf = None - p._covered_buf = None - p._xform_mask_buf = None - return p - - -@pytest.fixture -def newton_stub(monkeypatch): - """Stub the ``isaaclab_newton`` newton-prebuild module and the side-effect helpers. - - Returned :class:`SimpleNamespace` exposes: - - * ``calls`` — list of kwargs from each prebuild invocation, - * ``model`` / ``state_obj`` — what prebuild returns; tests can override before invoking. - """ - state = SimpleNamespace( - calls=[], - model=SimpleNamespace(body_label=[], articulation_label=[]), - state_obj=object(), - ) - - def _prebuild(**kwargs): - state.calls.append(dict(kwargs)) - return state.model, state.state_obj - - monkeypatch.setitem( - sys.modules, "isaaclab_newton.cloner.newton_replicate", SimpleNamespace(newton_visualizer_prebuild=_prebuild) - ) - monkeypatch.setattr(f"{PROVIDER_MOD}.UsdGeom.GetStageUpAxis", lambda stage: "Z") - monkeypatch.setattr(f"{PROVIDER_MOD}.replace_newton_shape_colors", lambda m, s: None) - return state - - -def test_get_newton_model_returns_model_when_sync_enabled(stub_provider): - """Callers receive the full Newton model from :meth:`get_newton_model`.""" - stub_provider._needs_newton_sync = True - stub_provider._newton_model = "full-model" - assert stub_provider.get_newton_model() == "full-model" - - -def test_build_from_clone_plan_populates_provider_state(stub_provider, newton_stub): - """Building from a flat clone plan sets model, state, and rigid-body paths. - - Asserts the provider consumes the single source-of-truth ``(sources, - destinations, mask)`` contract directly and reads per-env positions from stage - xforms. - """ - newton_stub.model = SimpleNamespace( - body_label=["/World/envs/env_0/Object/A"], - articulation_label=["/World/envs/env_0/Robot"], - ) - plan = ClonePlan( - sources=( - "/World/envs/env_0/Object", - "/World/envs/env_1/Object", - "/World/envs/env_0/Robot", - ), - destinations=("/World/envs/env_{}/Object", "/World/envs/env_{}/Object", "/World/envs/env_{}/Robot"), - # object 0 -> env 0, 2 ; object 1 -> env 1, 3 ; robot -> all envs - clone_mask=torch.tensor( - [[True, False, True, False], [False, True, False, True], [True, True, True, True]], dtype=torch.bool - ), - ) - stub_provider._simulation_context = SimpleNamespace(get_clone_plan=lambda: plan) - stub_provider._stage = _silent_stage() - - stub_provider._build_newton_model_from_clone_plan() - - assert stub_provider._newton_model is newton_stub.model - assert stub_provider._newton_state is newton_stub.state_obj - assert stub_provider._rigid_body_paths == newton_stub.model.body_label - assert stub_provider._rigid_body_view_paths == newton_stub.model.body_label + newton_stub.model.articulation_label - assert stub_provider._num_envs_at_last_newton_build == 4 - assert stub_provider._last_newton_model_build_source == "built" - - kw = newton_stub.calls[-1] - assert kw["sources"] == [ - "/World/envs/env_0/Object", - "/World/envs/env_1/Object", - "/World/envs/env_0/Robot", - ] - assert kw["destinations"] == ["/World/envs/env_{}/Object", "/World/envs/env_{}/Object", "/World/envs/env_{}/Robot"] - assert kw["mapping"].shape == (3, 4) - assert kw["positions"].shape == (4, 3) - - -def test_build_from_clone_plan_missing_sets_error_state(stub_provider): - """When no clone plan is published, model/state stay unset.""" - stub_provider._simulation_context = SimpleNamespace(get_clone_plan=lambda: None) - stub_provider._stage = object() - - stub_provider._build_newton_model_from_clone_plan() - - assert stub_provider._last_newton_model_build_source == "missing" - assert stub_provider._newton_model is None - assert stub_provider._newton_state is None - - -def test_build_from_clone_plan_skips_unused_source_rows(stub_provider, newton_stub): - """A source row with no assigned env (all-False mask row) is dropped, not raised on. - - When ``num_prototypes > num_envs`` under a sequential strategy (or any strategy that - leaves some prototypes unused), the provider must filter unused rows out of - sources/destinations/mask. - """ - # 3 prototypes, 2 envs, sequential: env 0 → proto 0, env 1 → proto 1, proto 2 unused. - plan = ClonePlan( - sources=("/World/envs/env_0/Object", "/World/envs/env_1/Object", "/World/envs/env_0/Object"), - destinations=("/World/envs/env_{}/Object",) * 3, - clone_mask=torch.tensor([[True, False], [False, True], [False, False]], dtype=torch.bool), - ) - stub_provider._simulation_context = SimpleNamespace(get_clone_plan=lambda: plan) - stub_provider._stage = _silent_stage() - - stub_provider._build_newton_model_from_clone_plan() - - assert stub_provider._last_newton_model_build_source == "built" - kw = newton_stub.calls[-1] - # Unused proto_2 row dropped; only the two assigned prototypes survive. - assert kw["sources"] == ["/World/envs/env_0/Object", "/World/envs/env_1/Object"] - assert kw["mapping"].shape == (2, 2) - - -def test_build_from_clone_plan_uses_destination_template_for_env_lookup(stub_provider, newton_stub): - """Env-origin lookup uses the plan's destination prefix, not a hardcoded path. - - A scene with a non-default env path (``/Stage/scenes/env_``) should still have its - xform translates read correctly. Replaces the prior hardcoded ``/World/envs/env_``. - """ - visited: list[str] = [] - - def _get_prim(path): - visited.append(path) - return SimpleNamespace(IsValid=lambda: False) - - plan = ClonePlan( - sources=("/Stage/scenes/env_0/Object",), - destinations=("/Stage/scenes/env_{}/Object",), - clone_mask=torch.ones((1, 3), dtype=torch.bool), - ) - stub_provider._simulation_context = SimpleNamespace(get_clone_plan=lambda: plan) - stub_provider._stage = SimpleNamespace(GetPrimAtPath=_get_prim) - - stub_provider._build_newton_model_from_clone_plan() - - assert {f"/Stage/scenes/env_{i}" for i in range(3)} <= set(visited) - assert not any(p.startswith("/World/envs/") for p in visited) - - -def test_clone_plan_carries_flat_replication_contract(): - """``ClonePlan`` contains only sources, destinations, and the clone mask.""" - plan = ClonePlan( - sources=("/World/envs/env_0/Object",), - destinations=("/World/envs/env_{}/Object",), - clone_mask=torch.ones((1, 4), dtype=torch.bool), - ) - assert plan.sources == ("/World/envs/env_0/Object",) - assert plan.destinations == ("/World/envs/env_{}/Object",) - assert plan.clone_mask.shape == (1, 4) diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 1c40e21cb548..cf136c37053a 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -34,11 +34,21 @@ def forward(self): class _FakeProvider: - def __init__(self): - self.update_calls = [] + """Fake new-style SceneDataProvider for tests; only provides what visualizers read.""" + + def __init__(self, num_envs: int = 0): + self._num_envs = num_envs + + @property + def num_envs(self) -> int: + return self._num_envs + + @property + def usd_stage(self): + return None - def update(self): - self.update_calls.append(True) + def get_camera_transforms(self): + return None class _FakeVisualizer: @@ -110,30 +120,30 @@ def _make_context(visualizers, provider=None): ctx._visualizers = list(visualizers) ctx._scene_data_provider = provider ctx.physics_manager = _FakePhysicsManager() - ctx._visualizer_step_counter = 0 return ctx -def test_update_scene_data_provider_forwards_and_updates_provider(): +def test_update_visualizers_runs_forward_when_a_visualizer_requires_it(): provider = _FakeProvider() viz_a = _FakeVisualizer(env_ids=[0, 2], requires_forward=True) viz_b = _FakeVisualizer(env_ids=[2, 3]) - viz_c = _FakeVisualizer(env_ids=None) - ctx = _make_context([viz_a, viz_b, viz_c], provider=provider) + ctx = _make_context([viz_a, viz_b], provider=provider) - ctx.update_scene_data_provider() + ctx.update_visualizers(0.1) assert ctx.physics_manager.forward_calls == 1 - assert provider.update_calls == [True] - assert ctx._visualizer_step_counter == 1 + assert viz_a.step_calls == [0.1] + assert viz_b.step_calls == [0.1] -def test_update_scene_data_provider_force_forward_with_no_visualizers(): +def test_update_visualizers_skips_forward_when_no_visualizer_requires_it(): provider = _FakeProvider() - 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 == [True] + viz = _FakeVisualizer(env_ids=[0]) + ctx = _make_context([viz], provider=provider) + + ctx.update_visualizers(0.1) + + assert ctx.physics_manager.forward_calls == 0 def test_update_visualizers_removes_closed_nonrunning_and_failed(caplog): @@ -199,19 +209,13 @@ def _other_callback(event): class _DummyViserSceneDataProvider: - def __init__(self): - self._metadata = {"num_envs": 4} - self.state_calls: list[list[int] | None] = [] - - def get_metadata(self) -> dict: - return self._metadata - - def get_newton_model(self): - return "dummy-model" + @property + def num_envs(self) -> int: + return 4 - def get_newton_state(self): - self.state_calls.append(None) - return {"state_call": len(self.state_calls)} + @property + def usd_stage(self): + return None def get_camera_transforms(self): return {} @@ -234,27 +238,47 @@ def is_running(self) -> bool: return True -def test_viser_visualizer_initialize_and_step_uses_provider_state(monkeypatch: pytest.MonkeyPatch): +def test_viser_visualizer_initialize_and_step_uses_newton_manager_state(monkeypatch: pytest.MonkeyPatch): provider = _DummyViserSceneDataProvider() viewer = _DummyViserViewer() def _fake_create_viewer(self, record_to_viser: str | None, metadata: dict | None = None): assert record_to_viser is None - assert metadata == provider.get_metadata() + assert metadata == {"num_envs": provider.num_envs} self._viewer = viewer monkeypatch.setattr(viser_visualizer.ViserVisualizer, "_create_viewer", _fake_create_viewer) + state_calls: list[None] = [] + + class _FakeNewtonManager: + @staticmethod + def get_model(): + return "dummy-model" + + @staticmethod + def get_state(): + state_calls.append(None) + return {"state_call": len(state_calls)} + + @staticmethod + def get_num_envs() -> int: + return 1 + + import isaaclab_newton.physics as _np_mod + + monkeypatch.setattr(_np_mod, "NewtonManager", _FakeNewtonManager) + visualizer = viser_visualizer.ViserVisualizer(ViserVisualizerCfg()) visualizer.initialize(cast(Any, provider)) visualizer.step(0.25) assert visualizer.is_initialized - assert provider.state_calls == [None, None] + assert state_calls == [None, 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) - # log_state passes through get_newton_state() as-is; no env_ids (or other) keys are merged in. + # log_state passes NewtonManager.get_state() through as-is; no env_ids merged in. assert viewer.calls[1] == ("log_state", {"state_call": 2}) assert viewer.calls[2] == ("end_frame",) @@ -276,6 +300,26 @@ def _raise_marker_render(*args, **kwargs): monkeypatch.setattr(viser_visualizer.ViserVisualizer, "_create_viewer", _fake_create_viewer) monkeypatch.setattr(viser_visualizer, "render_newton_visualization_markers", _raise_marker_render) + state_calls: list[None] = [] + + class _FakeNewtonManager: + @staticmethod + def get_model(): + return "dummy-model" + + @staticmethod + def get_state(): + state_calls.append(None) + return {"state_call": len(state_calls)} + + @staticmethod + def get_num_envs() -> int: + return 1 + + import isaaclab_newton.physics as _np_mod + + monkeypatch.setattr(_np_mod, "NewtonManager", _FakeNewtonManager) + visualizer = viser_visualizer.ViserVisualizer(ViserVisualizerCfg()) visualizer.initialize(cast(Any, provider)) @@ -586,17 +630,33 @@ def close(self) -> None: captured["closed"] = True class _DummyRerunSceneDataProvider: - def get_metadata(self) -> dict: - return {"num_envs": 4} + @property + def num_envs(self) -> int: + return 4 + + @property + def usd_stage(self): + return None + + def get_camera_transforms(self): + return {} - def get_newton_model(self): + class _FakeNewtonManager: + @staticmethod + def get_model(): return "dummy-model" - def get_newton_state(self): + @staticmethod + def get_state(): return {"ok": True} - def get_camera_transforms(self): - return {} + @staticmethod + def get_num_envs() -> int: + return 1 + + import isaaclab_newton.physics as _np_mod + + monkeypatch.setattr(_np_mod, "NewtonManager", _FakeNewtonManager) monkeypatch.setattr(rerun_visualizer, "NewtonViewerRerun", _FakeNewtonViewerRerun) monkeypatch.setattr( @@ -655,6 +715,23 @@ def _raise_marker_render(*args, **kwargs): monkeypatch.setattr(rerun_visualizer, "render_newton_visualization_markers", _raise_marker_render) + class _FakeNewtonManager: + @staticmethod + def get_model(): + return "dummy-model" + + @staticmethod + def get_state(): + return {"ok": True} + + @staticmethod + def get_num_envs() -> int: + return 4 + + import isaaclab_newton.physics as _np_mod + + monkeypatch.setattr(_np_mod, "NewtonManager", _FakeNewtonManager) + visualizer = rerun_visualizer.RerunVisualizer(RerunVisualizerCfg()) viewer = _FakeRerunViewer() visualizer._is_initialized = True @@ -794,7 +871,6 @@ def _make_context_with_settings( ctx._scene_data_provider = _FakeProvider() ctx._scene_data_requirements = None ctx._clone_plan = None - ctx._visualizer_step_counter = 0 ctx._viz_dt = 0.01 ctx.get_setting = lambda name: settings.get(name) return ctx diff --git a/source/isaaclab/test/visualizers/test_visualizer.py b/source/isaaclab/test/visualizers/test_visualizer.py index 44d3a89aea16..4b050431eb82 100644 --- a/source/isaaclab/test/visualizers/test_visualizer.py +++ b/source/isaaclab/test/visualizers/test_visualizer.py @@ -79,6 +79,10 @@ def __init__(self, num_envs: int = 0, transforms: dict | None = None): self._num_envs = num_envs self._transforms = transforms + @property + def num_envs(self) -> int: + return self._num_envs + def get_metadata(self) -> dict: return {"num_envs": self._num_envs} diff --git a/source/isaaclab_newton/changelog.d/dev-scene-data-provider-api.minor.rst b/source/isaaclab_newton/changelog.d/dev-scene-data-provider-api.minor.rst new file mode 100644 index 000000000000..ae662cf3dcaa --- /dev/null +++ b/source/isaaclab_newton/changelog.d/dev-scene-data-provider-api.minor.rst @@ -0,0 +1,31 @@ +Added +^^^^^ + +* Added :meth:`~isaaclab_newton.physics.NewtonManager.get_state` and + :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state` so + Newton-based renderers, visualizers, and video recorders can fetch a Newton + ``Model``/``State`` regardless of the active sim backend. When the sim + backend is PhysX the manager builds a shadow Newton model directly from the + USD stage (via + :meth:`~isaaclab_newton.physics.NewtonManager.instantiate_builder_from_stage`) + and refreshes ``state_0.body_q`` from rigid-body transforms supplied by the + :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` each render + frame. + +Changed +^^^^^^^ + +* **Breaking:** :class:`~isaaclab_newton.renderers.NewtonWarpRenderer`, + :class:`~isaaclab_newton.video_recording.NewtonGlPerspectiveVideo`, and the + Newton/Rerun/Viser visualizers now read Newton ``Model``/``State`` from + :class:`~isaaclab_newton.physics.NewtonManager` instead of the removed + ``BaseSceneDataProvider.get_newton_model()`` / ``get_newton_state()``. + +Removed +^^^^^^^ + +* **Breaking:** Removed the ``isaaclab_newton.scene_data_providers`` package + (``NewtonSceneDataProvider``). Replace direct uses with + :meth:`~isaaclab_newton.physics.NewtonManager.get_model` / + :meth:`~isaaclab_newton.physics.NewtonManager.get_state` and the + Warp-native :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider`. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 97535fb8a328..7f112af4bacf 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -33,7 +33,7 @@ from newton.sensors import SensorIMU as NewtonSensorIMU from newton.solvers import SolverBase, SolverNotifyFlags -from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager +from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager, SceneDataBackend, SceneDataFormat from isaaclab.sim.utils.newton_model_utils import replace_newton_shape_colors from isaaclab.sim.utils.stage import get_current_stage from isaaclab.utils import checked_apply @@ -100,6 +100,44 @@ def _scatter_reset_masks_from_ids( fk_mask[articulation_ids[world, arti]] = True +class NewtonSceneDataBackend(SceneDataBackend): + """Scene data backend that reads rigid body transforms from Newton's simulation state. + + The backend reads ``body_q`` (an array of :class:`wp.transformf`) from + Newton's current state and exposes it as :class:`SceneDataFormat.Transform`. + Body paths come from the model's ``body_label`` attribute. + """ + + def __init__(self): + self._scene_data = SceneDataFormat.Transform() + + @property + def transforms(self) -> SceneDataFormat.Transform: + """Return the current Newton rigid body transforms as :class:`SceneDataFormat.Transform`.""" + self._scene_data.transforms = self.state.body_q + return self._scene_data + + @property + def transform_count(self) -> int: + """Return the number of rigid body transforms in the Newton sim.""" + return self.model.body_count + + @property + def transform_paths(self) -> list[str]: + """Return the prim paths for each rigid body transform.""" + if self.model.body_label is not None: + return list(self.model.body_label) + return [] + + @property + def model(self) -> Model: + return NewtonManager.get_model() + + @property + def state(self) -> Model: + return NewtonManager.get_state_0() + + class NewtonManager(PhysicsManager): """Abstract Newton physics manager for Isaac Lab. @@ -178,6 +216,15 @@ class NewtonManager(PhysicsManager): # Model changes (callbacks use unified system from PhysicsManager) _model_changes: set[int] = set() + # Scene data backend + _scene_data_backend: NewtonSceneDataBackend | None = None + + # Visualization-only state used when the sim backend is PhysX. Populated + # lazily in :meth:`_ensure_visualization_model` and updated each render + # frame in :meth:`update_visualization_state`. + _visualization_scene_data: SceneDataFormat.Transform | None = None + _visualization_mapping: wp.array | None = None + # Views list for assets to register their views _views: list = [] @@ -219,7 +266,9 @@ def initialize(cls, sim_context: SimulationContext) -> None: from isaaclab.app.settings_manager import get_settings_manager cameras_enabled = bool(get_settings_manager().get("/isaaclab/cameras_enabled", False)) - NewtonManager._clone_physics_only = "kit" not in requested and not cameras_enabled + cls._clone_physics_only = "kit" not in requested and not cameras_enabled + + cls._scene_data_backend = NewtonSceneDataBackend() @classmethod def reset(cls, soft: bool = False) -> None: @@ -414,6 +463,11 @@ def close(cls) -> None: cls.clear() super().close() + @classmethod + def get_scene_data_backend(cls) -> SceneDataBackend: + """Return the SceneDataBackend for the SceneDataProvider.""" + return cls._scene_data_backend + @classmethod def register_callback( cls, @@ -477,7 +531,10 @@ def clear(cls): NewtonManager._usdrt_stage = None NewtonManager._transforms_dirty = False NewtonManager._up_axis = "Z" + NewtonManager._visualization_scene_data = None + NewtonManager._visualization_mapping = None NewtonManager._model_changes = set() + NewtonManager._scene_data_backend = None NewtonManager._cl_pending_sites = {} NewtonManager._cl_site_index_map = {} NewtonManager._pending_extended_state_attributes = set() @@ -1246,14 +1303,288 @@ def get_solver_convergence_steps(cls) -> dict[str, float | int]: # State accessors (used extensively by articulation/rigid object data) @classmethod def get_model(cls) -> Model: - """Get the Newton model.""" + """Get the Newton model. + + When the active sim backend is Newton this returns the manager's own + authoritative model. When the active sim backend is PhysX a shadow + Newton model is built lazily (from the visualizer prebuilt artifact) so + renderers/visualizers that operate on Newton ``Model`` and ``State`` can + still drive a PhysX-simulated scene. + """ + cls._ensure_visualization_model() return cls._model @classmethod def get_state_0(cls) -> State: """Get the current state.""" + cls._ensure_visualization_model() return cls._state_0 + @classmethod + def get_state(cls) -> State: + """Get the current Newton state for visualization. + + Use this method from visualizers/renderers/video recorders that need a + backend-agnostic Newton ``State``. When the sim backend is PhysX this + refreshes the shadow ``_state_0.body_q`` from the live PhysX scene via + :meth:`update_visualization_state` before returning, so callers never + observe stale transforms. Under the Newton sim backend + :meth:`update_visualization_state` is a no-op and this is equivalent to + :meth:`get_state_0`. + """ + cls.update_visualization_state() + return cls.get_state_0() + + @classmethod + def get_num_envs(cls) -> int: + return cls._num_envs + + @classmethod + def _backend_is_newton(cls) -> bool: + """Return ``True`` when the active sim backend is Newton.""" + sim = PhysicsManager._sim + if sim is None: + return False + return isinstance(sim.get_scene_data_provider().backend, NewtonSceneDataBackend) + + @classmethod + def _ensure_visualization_model(cls) -> None: + """Build a shadow Newton model from the USD stage when the sim backend is PhysX. + + No-op when the sim backend is Newton (the manager's own ``_model`` / + ``_state_0`` are authoritative) or when a shadow model has already been + built. This is the entry point that makes :meth:`get_model` / + :meth:`get_state` work uniformly across both sim backends. + + The shadow model is built by walking the USD stage via + :meth:`_build_visualization_model_from_stage` and finalizing the resulting + :class:`~newton.ModelBuilder`. Per-frame body transforms are pushed into + ``_state_0.body_q`` by :meth:`update_visualization_state` using the new + :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider`. + """ + + if cls._model is not None and cls._state_0 is not None: + return + + if cls._backend_is_newton(): + return + + stage = get_current_stage() + if stage is None: + logger.error( + "[NewtonManager] No USD stage available; cannot build a Newton " + "Model/State for visualization while the sim backend is PhysX." + ) + return + + try: + builder = cls._build_visualization_model_from_stage(stage) + except Exception: + logger.exception( + "[NewtonManager] Failed to build a Newton ModelBuilder from the USD stage " + "for visualization (sim backend is PhysX)." + ) + return + + if builder is None or builder.body_count == 0: + logger.error( + "[NewtonManager] USD stage walk produced no Newton bodies; the shadow " + "Newton model for visualization will be empty. Common causes: the cloned " + "envs are not yet on the stage, or PhysX schemas could not be parsed by " + "Newton's add_usd. Check that /World/envs/env_ prims exist when the " + "renderer is initialized." + ) + return + + device = PhysicsManager._device or "cpu" + try: + cls._model = builder.finalize(device=device) + cls._state_0 = cls._model.state() + cls._model.num_envs = cls._num_envs + replace_newton_shape_colors(cls._model) + + except Exception: + logger.exception( + "[NewtonManager] Failed to finalize the shadow Newton ModelBuilder for " + "visualization (sim backend is PhysX)." + ) + cls._model = None + cls._state_0 = None + + @classmethod + def _build_visualization_model_from_stage(cls, stage) -> ModelBuilder | None: + """Build a fresh Newton ``ModelBuilder`` from the USD stage for visualization. + + Walks IsaacLab's ``/World/envs/env_`` convention and adds each env as + its own Newton world. When the env subtree is identical across envs (the + common cloned-scene case) a single env_0 prototype is built once and + replicated via :meth:`ModelBuilder.add_builder`; otherwise each env is + ingested independently with :meth:`ModelBuilder.add_usd`. + + This routine is intentionally independent of + :meth:`instantiate_builder_from_stage` (which targets the live-sim path + and uses a different naming convention and writes into ``cls._builder`` + and ``cls._cl_site_index_map``). The visualization shadow path must not + pollute those live-sim slots. ``cls._num_envs`` is populated here too so + :meth:`get_num_envs` returns the env count when the sim backend is PhysX + (the live-sim path never runs in that configuration, so there is no slot + to collide with). + + Args: + stage: USD stage to inspect. + + Returns: + A populated :class:`~newton.ModelBuilder`, or ``None`` when no + ``/World/envs/env_`` prims exist on the stage. + """ + import re + + from pxr import UsdGeom + + up_axis_token = UsdGeom.GetStageUpAxis(stage) + up_axis = Axis.from_string(str(up_axis_token)) + schema_resolvers = [SchemaResolverNewton(), SchemaResolverPhysx()] + + env_pattern = re.compile(r"^env_(\d+)$") + env_paths: list[tuple[int, str]] = [] + envs_root = stage.GetPrimAtPath("/World/envs") + if envs_root and envs_root.IsValid(): + for child in envs_root.GetChildren(): + if match := env_pattern.match(child.GetName()): + env_paths.append((int(match.group(1)), child.GetPath().pathString)) + env_paths.sort(key=lambda x: x[0]) + + builder = ModelBuilder(up_axis=up_axis) + + if not env_paths: + # Fallback: ingest the whole stage as a single world. + builder.add_usd(stage, schema_resolvers=schema_resolvers) + NewtonManager._num_envs = 1 + return builder + + NewtonManager._num_envs = len(env_paths) + + # Ingest stage-level (non-env) geometry into the global world (``current_world == -1``) + # so visualization sees the ground plane, ceilings, fixed props, etc. The legacy + # cloner-based prebuild did this via ``add_usd(stage, ignore_paths=["/World/envs"], ...)`` + # before adding the per-env worlds; without this, renderers/visualizers driven off the + # shadow Newton model are missing every shape authored outside the env hierarchy. + builder.add_usd( + stage, + ignore_paths=[r"/World/envs($|/.*)"], + schema_resolvers=schema_resolvers, + ) + + # Build env_0 as a prototype, then replicate across envs. + proto_env_path = env_paths[0][1] + proto = ModelBuilder(up_axis=up_axis) + proto.add_usd( + stage, + root_path=proto_env_path, + schema_resolvers=schema_resolvers, + ) + + xform_cache = UsdGeom.XformCache() + + # ``add_builder`` copies the prototype's ``body_label`` (and sibling label arrays) + # verbatim into each replicated world, so all worlds end up with prim paths under + # the prototype env (e.g. ``/World/envs/env_0/...``). The visualization sync uses + # these labels to map PhysX transforms (which carry distinct per-env paths) into + # ``state.body_q``; without rewriting, ``paths.index()`` resolves every match to + # world 0 and worlds 1..N never receive fresh poses. Rewrite the newly-added + # labels after each ``add_builder`` so each world references its own env prim path. + label_attrs = ("body_label", "articulation_label", "joint_label", "shape_label") + label_starts = {attr: len(getattr(builder, attr)) for attr in label_attrs} + + # ``proto.add_usd`` ingests env_0's bodies at their absolute world positions + # (``UsdPhysics.LoadUsdPhysicsFromRange`` reports world-space transforms), so + # ``proto.body_q`` already encodes env_0's world transform. ``add_builder`` + # composes its ``xform`` onto every imported body, so passing each env's + # absolute world transform here would double the offset; the correct xform is + # the env's pose relative to the prototype (identity for env_0, env_X * env_0^-1 + # for the rest). Dynamic bodies are overwritten in ``update_visualization_state`` + # via the PhysX sync, but static bodies (e.g. the table) keep this initial pose + # and render at the wrong position when env_0 is not at the world origin. + proto_world_gf = xform_cache.GetLocalToWorldTransform(stage.GetPrimAtPath(proto_env_path)) + proto_translation = proto_world_gf.ExtractTranslation() + proto_rotation = proto_world_gf.ExtractRotationQuat() + proto_world_tf = wp.transform( + (proto_translation[0], proto_translation[1], proto_translation[2]), + ( + proto_rotation.GetImaginary()[0], + proto_rotation.GetImaginary()[1], + proto_rotation.GetImaginary()[2], + proto_rotation.GetReal(), + ), + ) + proto_world_tf_inv = wp.transform_inverse(proto_world_tf) + + for _, env_path in env_paths: + world_xform = xform_cache.GetLocalToWorldTransform(stage.GetPrimAtPath(env_path)) + translation = world_xform.ExtractTranslation() + rotation = world_xform.ExtractRotationQuat() + env_world_tf = wp.transform( + (translation[0], translation[1], translation[2]), + ( + rotation.GetImaginary()[0], + rotation.GetImaginary()[1], + rotation.GetImaginary()[2], + rotation.GetReal(), + ), + ) + relative_tf = wp.transform_multiply(env_world_tf, proto_world_tf_inv) + builder.begin_world() + builder.add_builder(proto, xform=relative_tf) + if env_path != proto_env_path: + for attr in label_attrs: + labels = getattr(builder, attr) + for i in range(label_starts[attr], len(labels)): + labels[i] = labels[i].replace(proto_env_path, env_path, 1) + for attr in label_attrs: + label_starts[attr] = len(getattr(builder, attr)) + builder.end_world() + + return builder + + @classmethod + def update_visualization_state(cls) -> None: + """Refresh visualization state for the active sim backend. + + Newton sim backend: no-op — ``_state_0`` is the live, authoritative state + already advanced by :meth:`step` / forward kinematics. + + PhysX sim backend: pull rigid-body transforms from the + :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` and write + them into the shadow ``_state_0.body_q`` so Newton-native consumers + (Newton renderer, Newton/Rerun/Viser visualizers, OVRTX renderer, Newton + GL video) see fresh poses. + + Invoked lazily from :meth:`get_state` so consumers do not need to + coordinate the sync explicitly. + """ + if cls._backend_is_newton(): + return + cls._ensure_visualization_model() + if cls._state_0 is None or cls._model is None or cls._state_0.body_q is None: + return + sim = PhysicsManager._sim + if sim is None: + return + + sdp = sim.get_scene_data_provider() + if cls._visualization_scene_data is None: + cls._visualization_scene_data = SceneDataFormat.Transform() + if cls._visualization_mapping is None: + body_paths = list(getattr(cls._model, "body_label", None) or []) + cls._visualization_mapping = sdp.create_mapping(body_paths) + + cls._visualization_scene_data.transforms = cls._state_0.body_q + sdp.get_transforms( + cls._visualization_scene_data, + mapping=cls._visualization_mapping, + allow_passthrough=False, + ) + @classmethod def get_state_1(cls) -> State: """Get the next state.""" 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 0ba3558c3504..fbccc3da4137 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py @@ -20,10 +20,10 @@ from isaaclab.sim import SimulationContext from isaaclab.utils.math import convert_camera_frame_orientation_convention +from ..physics.newton_manager import NewtonManager from .newton_warp_renderer_cfg import NewtonWarpRendererCfg if TYPE_CHECKING: - from isaaclab.physics import BaseSceneDataProvider from isaaclab.sensors.camera.camera_data import CameraData logger = logging.getLogger(__name__) @@ -157,17 +157,17 @@ def __init__(self, cfg: NewtonWarpRendererCfg): def initialize(self) -> None: """Post-physics setup: read the built Newton model and construct the sensor.""" - newton_model = self.get_scene_data_provider().get_newton_model() - if newton_model is None: + self._newton_model: newton.Model = NewtonManager.get_model() + if self._newton_model is None: raise RuntimeError( - "NewtonWarpRenderer requires a Newton model but the scene data provider returned None. " + "NewtonWarpRenderer requires a Newton model but NewtonManager.get_model() returned None. " "This usually means the Newton model failed to build from the USD stage " "(e.g., unsupported PhysX schemas such as tendons). " "Check the log for earlier Newton model build errors." ) self.newton_sensor = newton.sensors.SensorTiledCamera( - newton_model, + self._newton_model, config=newton.sensors.SensorTiledCamera.RenderConfig( enable_textures=self.cfg.enable_textures, enable_shadows=self.cfg.enable_shadows, @@ -182,8 +182,8 @@ def initialize(self) -> None: # ``RenderContext.render`` raises if ``build_bvh_shape`` was never called for the model. # Build it once per model — idempotent across multiple sensors that share ``newton_model`` # because subsequent calls overwrite the same model-level BVH attributes. - if newton_model.shape_count > 0 and newton_model.bvh_shapes is None: - newton.geometry.build_bvh_shape(newton_model, newton_model.state()) + if self._newton_model.shape_count > 0 and self._newton_model.bvh_shapes is None: + newton.geometry.build_bvh_shape(self._newton_model, self._newton_model.state()) if self.cfg.create_default_light: self.newton_sensor.utils.create_default_light(enable_shadows=self.cfg.enable_shadows) @@ -222,7 +222,9 @@ def set_outputs(self, render_data: RenderData, output_data: dict[str, torch.Tens def update_transforms(self): """Sync Newton scene state before rendering. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.update_transforms`.""" - SimulationContext.instance().update_scene_data_provider(True) + sim = SimulationContext.instance() + sim.physics_manager.forward() + NewtonManager.update_visualization_state() def update_camera( self, render_data: RenderData, positions: torch.Tensor, orientations: torch.Tensor, intrinsics: torch.Tensor @@ -233,11 +235,14 @@ def update_camera( def render(self, render_data: RenderData): """Render and write to output buffers. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.render`.""" - newton_state = self.get_scene_data_provider().get_newton_state() + + newton_state: newton.State = NewtonManager.get_state() + # Refit the shape BVH against the current state since env body poses move every frame. # ``build_bvh_shape`` ran once in ``__init__``; ``refit_bvh_shape`` reuses that topology. if self.newton_sensor.model.shape_count > 0: newton.geometry.refit_bvh_shape(self.newton_sensor.model, newton_state) + self.newton_sensor.update( newton_state, render_data.camera_transforms, @@ -266,7 +271,5 @@ def read_output(self, render_data: RenderData, camera_data: CameraData) -> None: def cleanup(self, render_data: RenderData | None): """Release resources. No-op for Newton Warp. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.cleanup`.""" - pass - - def get_scene_data_provider(self) -> BaseSceneDataProvider: - return SimulationContext.instance().initialize_scene_data_provider() + if render_data: + render_data.sensor = None diff --git a/source/isaaclab_newton/isaaclab_newton/scene_data_providers/__init__.py b/source/isaaclab_newton/isaaclab_newton/scene_data_providers/__init__.py deleted file mode 100644 index cf0f30853ead..000000000000 --- a/source/isaaclab_newton/isaaclab_newton/scene_data_providers/__init__.py +++ /dev/null @@ -1,10 +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 - -"""Newton scene data provider backends.""" - -from isaaclab.utils.module import lazy_export - -lazy_export() diff --git a/source/isaaclab_newton/isaaclab_newton/scene_data_providers/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/scene_data_providers/__init__.pyi deleted file mode 100644 index 3cb204031738..000000000000 --- a/source/isaaclab_newton/isaaclab_newton/scene_data_providers/__init__.pyi +++ /dev/null @@ -1,10 +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 - -__all__ = [ - "NewtonSceneDataProvider", -] - -from .newton_scene_data_provider import NewtonSceneDataProvider 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 deleted file mode 100644 index ba19f4e7c63a..000000000000 --- a/source/isaaclab_newton/isaaclab_newton/scene_data_providers/newton_scene_data_provider.py +++ /dev/null @@ -1,306 +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 - -"""Scene data provider for Newton physics backend.""" - -from __future__ import annotations - -import logging -import re -from collections import deque -from typing import Any - -from pxr import UsdGeom - -from isaaclab.physics.base_scene_data_provider import BaseSceneDataProvider - -logger = logging.getLogger(__name__) - -_ENV_ID_RE = re.compile(r"/World/envs/env_(\d+)") - - -class NewtonSceneDataProvider(BaseSceneDataProvider): - """Scene data provider for Newton physics backend. - - Provides access to Newton model, state, and USD stage for visualizers and renderers. - Unlike PhysxSceneDataProvider which must build its own Newton model from USD and sync - PhysX transforms into it, this provider delegates directly to NewtonManager since the - Newton backend already owns the authoritative model and state. - """ - - def __init__(self, stage, simulation_context) -> None: - """Initialize the Newton scene data provider. - - Args: - stage: USD stage handle. - simulation_context: Active simulation context. - """ - self._simulation_context = simulation_context - self._stage = stage - self._metadata = {"physics_backend": "newton"} - self._num_envs: int | None = None - self._warned_once: set[str] = set() - - # Determine if usd stage sync is required for selected renderers and visualizers - requirements = self._simulation_context.get_scene_data_requirements() - self._needs_usd_sync = bool(requirements.requires_usd_stage) - - def _warn_once(self, key: str, message: str, *args) -> None: - """Emit a warning once per unique key. - - Args: - key: Unique warning key. - message: Warning message format string. - *args: Optional formatting arguments. - """ - if key in self._warned_once: - return - self._warned_once.add(key) - logger.warning(message, *args) - - # ---- Environment discovery --------------------------------------------------------------- - - def get_num_envs(self) -> int: - """Return discovered environment count. - - Returns: - Number of environments discovered from stage prim paths. - """ - if self._num_envs is not None and self._num_envs > 0: - return self._num_envs - discovered = self._determine_num_envs_in_scene() - if discovered > 0: - self._num_envs = discovered - return discovered - return 0 - - def _determine_num_envs_in_scene(self) -> int: - """Infer environment count from ``/World/envs/env_`` prim names. - - Returns: - Number of environments inferred from the stage. - """ - if self._stage is None: - return 0 - max_env_id = -1 - env_name_re = re.compile(r"^env_(\d+)$") - envs_root = self._stage.GetPrimAtPath("/World/envs") - if envs_root.IsValid(): - for child in envs_root.GetChildren(): - match = env_name_re.match(child.GetName()) - if match: - max_env_id = max(max_env_id, int(match.group(1))) - return max_env_id + 1 if max_env_id >= 0 else 0 - - # ---- Core provider API ------------------------------------------------------------------- - - 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`, - after forward kinematics have been evaluated. Only calls - :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. - """ - if not self._needs_usd_sync: - return - try: - from isaaclab_newton.physics import NewtonManager - - NewtonManager.sync_transforms_to_usd() - except Exception: - pass - - def get_newton_model(self) -> Any | None: - """Return Newton model from ``NewtonManager``. - - Returns: - Newton model object, or ``None`` when unavailable. - """ - from isaaclab_newton.physics import NewtonManager - - return NewtonManager.get_model() - - def get_newton_state(self) -> Any | None: - """Return Newton state from NewtonManager. - - Returns: - The current Newton state (state_0) from NewtonManager. - """ - from isaaclab_newton.physics import NewtonManager - - return NewtonManager.get_state_0() - - def get_model(self) -> Any | None: - """Alias for :meth:`get_newton_model` for visualizer compatibility. - - Returns: - Newton model object, or ``None`` when unavailable. - """ - return self.get_newton_model() - - 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. - - Returns: - USD stage object, or ``None`` when unavailable. - """ - if self._stage is not None: - return self._stage - return getattr(self._simulation_context, "stage", None) - - def get_metadata(self) -> dict[str, Any]: - """Return provider metadata. - - Returns: - Metadata dictionary with backend and synchronization information. - """ - out = dict(self._metadata) - out["num_envs"] = self.get_num_envs() - out["needs_usd_sync"] = self._needs_usd_sync - return out - - def get_transforms(self) -> dict[str, Any] | None: - """Return body transforms from Newton state. - - Reads body_q from the authoritative Newton state and splits it into - positions (vec3) and orientations (quaternion xyzw). - - Returns: - Dictionary containing positions and orientations, or ``None`` when unavailable. - """ - try: - import warp as wp - - from isaaclab_newton.physics import NewtonManager - - state = NewtonManager.get_state_0() - if state is None or state.body_q is None: - return None - - body_q_t = wp.to_torch(state.body_q) - positions = body_q_t[:, :3] - orientations = body_q_t[:, 3:7] - return {"positions": positions, "orientations": orientations} - except Exception as exc: - self._warn_once( - "get-transforms-failed", - "[NewtonSceneDataProvider] get_transforms() failed: %s", - exc, - ) - return None - - def get_velocities(self) -> dict[str, Any] | None: - """Return body velocities from Newton state. - - Returns: - Dictionary containing linear and angular velocities, or ``None`` when unavailable. - """ - try: - import warp as wp - - from isaaclab_newton.physics import NewtonManager - - state = NewtonManager.get_state_0() - if state is None: - return None - - body_qd = getattr(state, "body_qd", None) - if body_qd is None: - return None - - body_qd_t = wp.to_torch(body_qd) - linear = body_qd_t[:, :3] - angular = body_qd_t[:, 3:6] - return {"linear": linear, "angular": angular, "source": "newton"} - except Exception as exc: - self._warn_once( - "get-velocities-failed", - "[NewtonSceneDataProvider] get_velocities() failed: %s", - exc, - ) - return None - - def get_contacts(self) -> dict[str, Any] | None: - """Return contact data for Newton provider. - - Returns: - ``None`` because contacts are not currently implemented in this provider. - """ - return None - - def get_camera_transforms(self) -> dict[str, Any] | None: - """Return per-camera, per-environment transforms. - - Returns: - Dictionary containing camera order, positions, orientations, and environment count, - or ``None`` when unavailable. - """ - if self._stage is None: - return None - - import isaaclab.sim as isaaclab_sim - - env_pattern = re.compile(r"(?P/World/envs/env_)(?P\d+)(?P/.*)") - shared_paths: list[str] = [] - instances: dict[str, list[tuple[int, str]]] = {} - num_envs = -1 - - stage_prims = deque([self._stage.GetPseudoRoot()]) - while stage_prims: - prim = stage_prims.popleft() - prim_path = prim.GetPath().pathString - - world_id = 0 - template_path = prim_path - if match := env_pattern.match(prim_path): - world_id = int(match.group("id")) - template_path = match.group("root") + "%d" + match.group("path") - if world_id > num_envs: - num_envs = world_id - - imageable = UsdGeom.Imageable(prim) - if imageable and imageable.ComputeVisibility() == UsdGeom.Tokens.invisible: - continue - - if prim.IsA(UsdGeom.Camera): - if template_path not in instances: - instances[template_path] = [] - instances[template_path].append((world_id, prim_path)) - if template_path not in shared_paths: - shared_paths.append(template_path) - - if hasattr(UsdGeom, "TraverseInstanceProxies"): - child_prims = prim.GetFilteredChildren(UsdGeom.TraverseInstanceProxies()) - else: - child_prims = prim.GetChildren() - if child_prims: - stage_prims.extend(child_prims) - - num_envs += 1 - positions: list[list[list[float] | None]] = [] - orientations: list[list[list[float] | None]] = [] - - for template_path in shared_paths: - per_world_pos: list[list[float] | None] = [None] * num_envs - per_world_ori: list[list[float] | None] = [None] * num_envs - for world_id, prim_path in instances.get(template_path, []): - if world_id < 0 or world_id >= num_envs: - continue - prim = self._stage.GetPrimAtPath(prim_path) - if not prim.IsValid(): - continue - pos, ori = isaaclab_sim.resolve_prim_pose(prim) - per_world_pos[world_id] = [float(pos[0]), float(pos[1]), float(pos[2])] - per_world_ori[world_id] = [float(ori[0]), float(ori[1]), float(ori[2]), float(ori[3])] - positions.append(per_world_pos) - orientations.append(per_world_ori) - - return {"order": shared_paths, "positions": positions, "orientations": orientations, "num_envs": num_envs} diff --git a/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video.py b/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video.py index 9c440a657981..1f557300529b 100644 --- a/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video.py +++ b/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video.py @@ -31,13 +31,12 @@ def _ensure_viewer(self) -> None: if self._init_attempted: return self._init_attempted = True - from isaaclab.sim import SimulationContext + from isaaclab_newton.physics import NewtonManager - sdp = SimulationContext.instance().initialize_scene_data_provider() - model = sdp.get_newton_model() + model = NewtonManager.get_model() if model is None: raise RuntimeError( - "Newton GL perspective video requires a Newton model on the scene data provider. " + "Newton GL perspective video requires a Newton model from NewtonManager. " "Do not use --video for this setup." ) @@ -105,9 +104,10 @@ def render_rgb_array(self) -> np.ndarray: self._ensure_viewer() from isaaclab.sim import SimulationContext + from isaaclab_newton.physics import NewtonManager + sim = SimulationContext.instance() - sdp = sim.initialize_scene_data_provider() - state = sdp.get_newton_state() + state = NewtonManager.get_state() dt = sim.get_physics_dt() viewer = self._viewer diff --git a/source/isaaclab_newton/setup.py b/source/isaaclab_newton/setup.py index 4a954d0e9371..ad480ef9697d 100644 --- a/source/isaaclab_newton/setup.py +++ b/source/isaaclab_newton/setup.py @@ -69,7 +69,6 @@ def run(self): "isaaclab_newton.cloner", "isaaclab_newton.physics", "isaaclab_newton.renderers", - "isaaclab_newton.scene_data_providers", "isaaclab_newton.sensors", "isaaclab_newton.sensors.contact_sensor", "isaaclab_newton.sensors.frame_transformer", diff --git a/source/isaaclab_ov/changelog.d/dev-scene-data-provider-api.rst b/source/isaaclab_ov/changelog.d/dev-scene-data-provider-api.rst new file mode 100644 index 000000000000..a34f01f0654e --- /dev/null +++ b/source/isaaclab_ov/changelog.d/dev-scene-data-provider-api.rst @@ -0,0 +1,9 @@ +Changed +^^^^^^^ + +* **Breaking:** :class:`~isaaclab_ov.renderers.OVRTXRenderer` now reads the + Newton ``Model`` and ``State`` it binds OVRTX attributes against from + :meth:`~isaaclab_newton.physics.NewtonManager.get_model` / + :meth:`~isaaclab_newton.physics.NewtonManager.get_state` instead of the + removed ``BaseSceneDataProvider.get_newton_model()`` / + ``get_newton_state()``. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 99ad0554048e..170ef1d44c66 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -314,10 +314,9 @@ def _update_scene_partitions_after_clone(self, usd_file_path: str, num_envs: int def _setup_object_bindings(self): """Setup OVRTX bindings for scene objects to sync with Newton physics.""" try: - from isaaclab.sim import SimulationContext + from isaaclab_newton.physics import NewtonManager - provider = SimulationContext.instance().initialize_scene_data_provider() - newton_model = provider.get_newton_model() + newton_model = NewtonManager.get_model() if newton_model is None: logger.info("Newton model not available, skipping object bindings") return @@ -419,10 +418,9 @@ def update_transforms(self) -> None: return try: - from isaaclab.sim import SimulationContext + from isaaclab_newton.physics import NewtonManager - provider = SimulationContext.instance().initialize_scene_data_provider() - newton_state = provider.get_newton_state() + newton_state = NewtonManager.get_state() if newton_state is None: return body_q = getattr(newton_state, "body_q", None) diff --git a/source/isaaclab_physx/changelog.d/dev-scene-data-provider-api.minor.rst b/source/isaaclab_physx/changelog.d/dev-scene-data-provider-api.minor.rst new file mode 100644 index 000000000000..77d2850749cc --- /dev/null +++ b/source/isaaclab_physx/changelog.d/dev-scene-data-provider-api.minor.rst @@ -0,0 +1,19 @@ +Added +^^^^^ + +* Added :meth:`~isaaclab_physx.physics.PhysxManager.pre_render` so the + PhysX backend can drive + :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state` + once per render frame when the active visualizer/renderer set requires a + Newton model. + +Removed +^^^^^^^ + +* **Breaking:** Removed the ``isaaclab_physx.scene_data_providers`` package + (``PhysxSceneDataProvider``). The Warp-native + :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` now exposes + PhysX rigid-body transforms via + :class:`~isaaclab_physx.physics.PhysxSceneDataBackend`, and the + PhysX→Newton state sync used by Newton visualizers/renderers moved to + :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state`. diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 4fb55064f072..bb23bc130ad5 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any, ClassVar import torch +import warp as wp import carb import omni.kit.app @@ -28,10 +29,10 @@ import omni.physx import omni.timeline import omni.usd -from pxr import Sdf, UsdUtils +from pxr import Sdf, Usd, UsdPhysics, UsdUtils import isaaclab.sim as sim_utils -from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager +from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager, SceneDataBackend, SceneDataFormat from isaaclab.utils.string import to_camel_case if TYPE_CHECKING: @@ -153,6 +154,71 @@ def _update_usda_start_time(self, file_path: str) -> None: f.write(content) +class PhysxSceneDataBackend(SceneDataBackend): + def __init__(self): + self._simulation_view: omni.physics.tensors.SimulationView | None = None + self._rigid_body_view: omni.physics.tensors.RigidBodyView | None = None + self._scene_data = SceneDataFormat.Transform() + + @property + def simulation_view(self) -> omni.physics.tensors.SimulationView | None: + return self._simulation_view + + @simulation_view.setter + def simulation_view(self, simulation_view: omni.physics.tensors.SimulationView | None): + self._simulation_view = simulation_view + self._rigid_body_view = None + + def get_rigid_body_view(self) -> omni.physics.tensors.RigidBodyView | None: + """Lazily create a rigid body view covering all rigid bodies in the scene. + + Discovers rigid body prims by traversing the USD stage and converts + per-environment paths (``/World/envs/env_N/...``) into wildcard + patterns so a single PhysX view covers every environment instance. + """ + if self._rigid_body_view is not None: + return self._rigid_body_view + + if self._simulation_view is None: + return None + + stage: Usd.Stage = omni.usd.get_context().get_stage() + if stage is None: + return None + + patterns: set[str] = set() + for prim in stage.Traverse(): + if prim.HasAPI(UsdPhysics.RigidBodyAPI): + patterns.add(re.sub(r"/World/envs/env_\d+", "/World/envs/env_*", prim.GetPath().pathString)) + + if not patterns: + return None + + self._rigid_body_view = self._simulation_view.create_rigid_body_view(list(patterns)) + return self._rigid_body_view + + @property + def transforms(self) -> SceneDataFormat.Transform: + """Return the current PhysX rigid body transforms as :class:`SceneDataFormat.Transform`.""" + if view := self.get_rigid_body_view(): + self._scene_data.transforms = view.get_transforms().view(wp.transformf) + return self._scene_data + + @property + def transform_count(self) -> int: + """Return the number of rigid body transforms in the PhysX sim.""" + if view := self.get_rigid_body_view(): + return view.count + return 0 + + @property + def transform_paths(self) -> list[str]: + """Return the prim paths for each rigid body transform.""" + if view := self.get_rigid_body_view(): + return list(view.prim_paths) + return [] + + class PhysxManager(PhysicsManager): """Manages PhysX physics simulation lifecycle. @@ -165,6 +231,7 @@ class PhysxManager(PhysicsManager): _event_bus: ClassVar[carb.eventdispatcher.IEventDispatcher] = carb.eventdispatcher.get_eventdispatcher() _physx: ClassVar[omni.physx.IPhysx] = omni.physx.get_physx_interface() _physx_sim: ClassVar[omni.physx.IPhysxSimulation] = omni.physx.get_physx_simulation_interface() + _scene_data_backend: ClassVar[PhysxSceneDataBackend | None] = None _view: ClassVar[omni.physics.tensors.SimulationView | None] = None _view_warp: ClassVar[omni.physics.tensors.SimulationView | None] = None @@ -210,6 +277,7 @@ def initialize(cls, sim_context: SimulationContext) -> None: cls._configure_physics() cls._load_fabric() cls._anim_recorder = AnimationRecorder(sim_context) + cls._scene_data_backend = PhysxSceneDataBackend() # force update cycle to apply dt sim = PhysicsManager._sim @@ -248,6 +316,11 @@ def forward(cls) -> None: cls._view.update_articulations_kinematic() cls._update_fabric(0.0, 0.0) + @classmethod + def get_scene_data_backend(cls) -> SceneDataBackend: + """Return the SceneDataBackend for the SceneDataProvider.""" + return cls._scene_data_backend + @classmethod def step(cls) -> None: """Step the physics simulation.""" @@ -689,6 +762,7 @@ def _warmup_and_create_views(cls) -> None: # Final update after view creation cls._physx.update_simulation(cls.get_physics_dt(), 0.0) cls._view_created = True + cls._scene_data_backend.simulation_view = cls._view cls._event_bus.dispatch_event(IsaacEvents.SIMULATION_VIEW_CREATED.value, payload={}) cls.dispatch_event(PhysicsEvent.PHYSICS_READY, payload={}) diff --git a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.py b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.py deleted file mode 100644 index 0a3fe4d79fb4..000000000000 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.py +++ /dev/null @@ -1,10 +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 - -"""PhysX scene data provider backends.""" - -from isaaclab.utils.module import lazy_export - -lazy_export() diff --git a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.pyi deleted file mode 100644 index 32c6f9c07335..000000000000 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.pyi +++ /dev/null @@ -1,10 +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 - -__all__ = [ - "PhysxSceneDataProvider", -] - -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 deleted file mode 100644 index ec4c64d8f8c7..000000000000 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py +++ /dev/null @@ -1,728 +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 - -"""PhysX scene data provider for Omni/PhysX backend.""" - -from __future__ import annotations - -import logging -import re -import time -from collections import deque -from typing import Any - -import torch -import warp as wp - -from pxr import UsdGeom, UsdPhysics - -from isaaclab.physics.base_scene_data_provider import BaseSceneDataProvider -from isaaclab.sim.utils.newton_model_utils import replace_newton_shape_colors - -logger = logging.getLogger(__name__) - - -@wp.kernel(enable_backward=False) -def _set_body_q_kernel( - positions: wp.array(dtype=wp.vec3), - orientations: wp.array(dtype=wp.quatf), - body_q: wp.array(dtype=wp.transformf), -): - """Write pose arrays into Newton ``body_q`` in one-to-one index order.""" - i = wp.tid() - body_q[i] = wp.transformf(positions[i], orientations[i]) - - -class PhysxSceneDataProvider(BaseSceneDataProvider): - """Scene data provider for Omni PhysX backend. - - Supports: - - body poses via PhysX tensor views, with FrameView fallback - - camera poses & intrinsics - - USD stage handles - - Newton model/state (built locally from the scene's :class:`ClonePlan` when required) - """ - - # ---- Environment discovery / metadata ------------------------------------------------- - - 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: - return self._num_envs - discovered_num_envs = self._determine_num_envs_in_scene() - if discovered_num_envs > 0: - self._num_envs = discovered_num_envs - return discovered_num_envs - return 0 - - def _determine_num_envs_in_scene(self) -> int: - """Infer env count from /World/envs/env_ prims.""" - if self._stage is None: - return 0 - - max_env_id = -1 - env_name_re = re.compile(r"^env_(\d+)$") - - envs_root = self._stage.GetPrimAtPath("/World/envs") - if envs_root.IsValid(): - for child in envs_root.GetChildren(): - match = env_name_re.match(child.GetName()) - if match: - max_env_id = max(max_env_id, int(match.group(1))) - return max_env_id + 1 if max_env_id >= 0 else 0 - - def __init__(self, stage, simulation_context) -> None: - """Initialize the PhysX scene data provider. - - Args: - stage: USD stage handle. - simulation_context: Active simulation context. - """ - from isaaclab_physx.physics import PhysxManager as SimulationManager - - self._simulation_context = simulation_context - self._stage = stage - self._physics_sim_view = SimulationManager.get_physics_sim_view() - self._rigid_body_view = None - self._xform_views: dict[str, Any] = {} - self._xform_view_failures: set[str] = set() - self._view_body_index_map: dict[str, list[int]] = {} - self._warned_once: set[str] = set() - - # Single source of truth: discovered from stage and cached once available. - self._num_envs: int | None = None - - # Determine if newton model sync is required for selected renderers and visualizers - requirements = self._simulation_context.get_scene_data_requirements() - self._needs_newton_sync = bool(requirements.requires_newton_model) - - # 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"} - if self._stage is None: - raise RuntimeError( - "[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._num_envs_at_last_newton_build: int | None = None # for _refresh_newton_model_if_needed - - self._device = getattr(self._simulation_context, "device", "cuda:0") - self._newton_model = None - self._newton_state = 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] = [] - - # Reused pose buffers (MR perf): avoid per-call allocations in _read_poses_from_best_source. - self._pose_buf_num_bodies = 0 - self._positions_buf = None - self._orientations_buf = None - self._covered_buf = 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 load outcome (tests / debug): "built" | "missing" | "error". - self._last_newton_model_build_source: str | None = None - self._last_newton_model_build_elapsed_ms: float | None = None - - if self._needs_newton_sync: - self._build_newton_model_from_clone_plan() - self._setup_rigid_body_view() - - # ---- Newton model + PhysX view setup -------------------------------------------------- - - def _wildcard_env_paths(self, paths: list[str]) -> list[str]: - """Convert /World/envs/env_0 paths to a wildcard pattern when possible.""" - wildcard_paths = [ - path.replace("/World/envs/env_0", "/World/envs/env_*") for path in paths if "/World/envs/env_0" in path - ] - return list(dict.fromkeys(wildcard_paths)) if wildcard_paths else paths - - def _refresh_newton_model_if_needed(self) -> None: - """Reload Newton model/state and PhysX views when the discovered env count changes.""" - num_envs = self.get_num_envs() - if num_envs <= 0: - return - - 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_clone_plan() - self._setup_rigid_body_view() - - def _build_newton_model_from_clone_plan(self) -> None: - """Build Newton model and state from the scene's :class:`ClonePlan`. - - Reads the plan :meth:`InteractiveScene.clone_environments` publishes on - :class:`SimulationContext`, validates the flat ``(sources, destinations, mask)`` - shape :func:`isaaclab_newton.cloner.newton_visualizer_prebuild` expects, and - caches the resulting model/state. Per-env positions are read off - ``xformOp:translate`` on the env-level prims derived from the first destination - template. Pre-condition violations raise :class:`RuntimeError` (logged as - ``"missing"``); ``isaaclab_newton`` being absent (optional dep) maps to - ``"missing"`` via the import's own exception types; unexpected failures fall - through to ``"error"``. - """ - start_t = time.perf_counter() - source = "missing" - try: - plan = self._simulation_context.get_clone_plan() - if plan is None: - raise RuntimeError("No clone plan on simulation context.") - from isaaclab_newton.cloner.newton_replicate import newton_visualizer_prebuild - - if len(plan.sources) != len(plan.destinations): - raise RuntimeError( - f"Clone plan sources and destinations disagree: {len(plan.sources)} != {len(plan.destinations)}" - ) - if plan.clone_mask.dim() != 2 or plan.clone_mask.size(0) != len(plan.sources): - raise RuntimeError( - f"Clone plan mask shape {tuple(plan.clone_mask.shape)} does not match {len(plan.sources)} sources." - ) - - # Drop all-False rows (possible when ``num_prototypes > num_envs``). - sources, destinations, mask_rows = [], [], [] - for i, (source_path, destination) in enumerate(zip(plan.sources, plan.destinations)): - if not plan.clone_mask[i].any(): - continue - sources.append(source_path) - destinations.append(destination) - mask_rows.append(plan.clone_mask[i : i + 1]) - if not sources: - raise RuntimeError("All clone-plan source rows are empty.") - mask = torch.cat(mask_rows, dim=0) - num_envs = plan.clone_mask.size(1) - - # Env-level path template = dest_template up to the first ``{}``. Per-env world - # positions: xformOp:translate read off each env prim; missing prims fall through. - env_path_template = destinations[0].split("{}")[0] + "{}" - positions = torch.zeros((num_envs, 3), dtype=torch.float32, device=self._device) - for i in range(num_envs): - prim = self._stage.GetPrimAtPath(env_path_template.format(i)) - if prim.IsValid() and (v := prim.GetAttribute("xformOp:translate").Get()) is not None: - positions[i] = torch.tensor([v[0], v[1], v[2]], device=self._device) - - model, state = newton_visualizer_prebuild( - stage=self._stage, - sources=sources, - destinations=destinations, - env_ids=torch.arange(num_envs, dtype=torch.long, device=mask.device), - mapping=mask, - positions=positions, - device=self._device, - up_axis=UsdGeom.GetStageUpAxis(self._stage), - ) - if model is None or state is None: - raise RuntimeError("newton_visualizer_prebuild returned None.") - - self._newton_model, self._newton_state = model, state - replace_newton_shape_colors(self._newton_model, self._stage) - # Newton renamed ``*_key`` → ``*_label`` mid-development; fall back so we work either way. - # ``dict.fromkeys`` preserves order while deduping — articulation roots can overlap rigid bodies. - label_or_key = lambda kind: list(getattr(model, f"{kind}_label", None) or getattr(model, f"{kind}_key", [])) # noqa: E731 - self._rigid_body_paths = label_or_key("body") - self._rigid_body_view_paths = list(dict.fromkeys(self._rigid_body_paths + label_or_key("articulation"))) - # Reset cached views/buffers; rebuilt lazily by ``_setup_rigid_body_view``. - self._xform_views.clear() - self._view_order_tensors.clear() - self._view_body_index_map = {} - self._pose_buf_num_bodies = 0 - self._positions_buf = self._orientations_buf = self._covered_buf = self._xform_mask_buf = None - self._num_envs_at_last_newton_build = num_envs - source = "built" - except (ImportError, ModuleNotFoundError) as exc: - logger.warning("[PhysxSceneDataProvider] isaaclab_newton not available: %s", exc) - self._clear_newton_model_state() - except RuntimeError as exc: - logger.error("[PhysxSceneDataProvider] %s", exc) - self._clear_newton_model_state() - except Exception as exc: - source = "error" - logger.error("[PhysxSceneDataProvider] Failed to build Newton model from clone plan: %s", exc) - self._clear_newton_model_state() - finally: - self._last_newton_model_build_elapsed_ms = (time.perf_counter() - start_t) * 1000.0 - self._last_newton_model_build_source = source - logger.debug( - "[PhysxSceneDataProvider] Newton model build source=%s elapsed_ms=%.2f", - source, - self._last_newton_model_build_elapsed_ms, - ) - - 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. - - Uses body paths extracted from Newton model to create PhysX tensor API view - for reading rigid body transforms. - """ - if self._physics_sim_view is None: - return - paths = self._rigid_body_view_paths or self._rigid_body_paths - if not paths: - return - # Defensive: only pass true rigid-body prims into PhysX RigidBodyView. - # Some prebuilt artifacts carry articulation root paths for coverage, but - # those roots are not guaranteed to be rigid-body prims and can trip native - # view creation paths on some tasks. - rigid_paths: list[str] = [] - dropped_non_rigid = 0 - for path in paths: - prim = self._stage.GetPrimAtPath(path) if self._stage is not None else None - if prim and prim.IsValid() and prim.HasAPI(UsdPhysics.RigidBodyAPI): - rigid_paths.append(path) - else: - dropped_non_rigid += 1 - if not rigid_paths: - self._warn_once( - "rigid-view-no-rigid-paths", - "[PhysxSceneDataProvider] No rigid-body prim paths available for RigidBodyView creation.", - level=logging.WARNING, - ) - return - try: - paths_to_use = self._wildcard_env_paths(rigid_paths) - self._rigid_body_view = self._physics_sim_view.create_rigid_body_view(paths_to_use) - self._cache_view_index_map(self._rigid_body_view, "rigid_body_view") - except Exception as exc: - logger.warning(f"[PhysxSceneDataProvider] Failed to create RigidBodyView: {exc}") - self._rigid_body_view = None - - # ---- Pose/velocity read pipeline ------------------------------------------------------ - - def _warn_once(self, key: str, message: str, *args, level=logging.WARNING) -> None: - """Log a warning only once for a given key.""" - if key in self._warned_once: - return - self._warned_once.add(key) - logger.log(level, message, *args) - - def _get_view_world_poses(self, view: Any): - """Read world poses from a PhysX view.""" - if view is None: - return None, None - - result = view.get_transforms() - if isinstance(result, tuple) and len(result) == 2: - return result - if hasattr(result, "shape"): - return result[:, :3], result[:, 3:7] - - import warp as wp - - result_t = wp.to_torch(result) - return result_t[:, :3], result_t[:, 3:7] - - def _cache_view_index_map(self, view, key: str) -> None: - """Map PhysX view indices to Newton body_key ordering.""" - prim_paths = getattr(view, "prim_paths", None) - if not prim_paths or not self._rigid_body_paths: - return - - # Build map: (env_id, relative_path) -> view_index to align view order. - view_map: dict[tuple[int | None, str], int] = {} - for view_idx, path in enumerate(prim_paths): - env_id, rel = self._split_env_relative_path(path) - view_map[(env_id, rel)] = view_idx - - # Build reordering: newton_body_index -> view_index so we can scatter - # PhysX view outputs into Newton body ordering. - order: list[int | None] = [None] * len(self._rigid_body_paths) - for body_idx, path in enumerate(self._rigid_body_paths): - env_id, rel = self._split_env_relative_path(path) - view_idx = view_map.get((env_id, rel)) - if view_idx is None: - view_idx = view_map.get((None, rel)) # Try without env_id - order[body_idx] = view_idx - - if all(idx is not None for idx in order): - self._view_body_index_map[key] = order # type: ignore[arg-type] - # Cache as device tensor for vectorized scatter in _apply_view_poses. - import torch - - self._view_order_tensors[key] = torch.tensor(order, dtype=torch.long, device=self._device) - - def _split_env_relative_path(self, path: str) -> tuple[int | None, str]: - """Extract (env_id, relative_path) from a prim path.""" - match = re.search(r"/World/envs/env_(\d+)(/.*)", path) - return (int(match.group(1)), match.group(2)) if match else (None, path) - - def _get_view_velocities(self, view): - """Read linear/angular velocities from a PhysX view.""" - if view is None: - return None, None - - try: - # Canonical API for PhysX tensor views. - result = view.get_velocities() - if isinstance(result, tuple) and len(result) == 2: - return result - if hasattr(result, "shape") and result.shape[-1] == 6: - return result[..., :3], result[..., 3:6] - except (AttributeError, RuntimeError, TypeError) as exc: - logger.debug("[PhysxSceneDataProvider] get_velocities() unavailable/failed for %s: %s", type(view), exc) - return None, None - - def _apply_view_poses(self, view: Any, view_key: str, positions: Any, orientations: Any, covered: Any) -> int: - """Fill poses from a PhysX view for bodies not yet covered.""" - import torch - import warp as wp - - if view is None: - return 0 - - pos, quat = self._get_view_world_poses(view) - if pos is None or quat is None: - return 0 - - order = self._view_body_index_map.get(view_key) - if not order: - return 0 - - # Normalize returned arrays to torch tensors across backends (torch/warp/other). - if not isinstance(pos, torch.Tensor): - try: - pos = wp.to_torch(pos) - except Exception: - pos = torch.as_tensor(pos) - if not isinstance(quat, torch.Tensor): - try: - quat = wp.to_torch(quat) - except Exception: - quat = torch.as_tensor(quat) - - pos = pos.to(device=self._device, dtype=torch.float32) - quat = quat.to(device=self._device, dtype=torch.float32) - - # Vectorized scatter when we have a cached order tensor (view fully covers bodies). - order_t = self._view_order_tensors.get(view_key) - if order_t is not None: - uncovered_mask = ~covered - if uncovered_mask.any(): - newton_indices = uncovered_mask.nonzero(as_tuple=True)[0] - view_indices = order_t[newton_indices] - positions[newton_indices] = pos[view_indices] - orientations[newton_indices] = quat[view_indices] - covered[newton_indices] = True - return newton_indices.numel() - return 0 - - # 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]: - positions[newton_idx] = pos[view_idx] - orientations[newton_idx] = quat[view_idx] - covered[newton_idx] = True - count += 1 - - return count - - def _apply_xform_poses(self, positions: Any, orientations: Any, covered: Any, xform_mask: Any) -> int: - """Fill remaining body poses using ``XformPrimView`` for prims not covered by the rigid-body view.""" - import torch - - from isaaclab.sim.views import FrameView - - uncovered = torch.where(~covered)[0].cpu().tolist() - if not uncovered: - return 0 - - # Query each uncovered prim path directly from USD. - count = 0 - for idx in uncovered: - path = self._rigid_body_paths[idx] - try: - if path not in self._xform_views: - self._xform_views[path] = FrameView( - path, device=self._device, stage=self._stage, validate_xform_ops=False - ) - - pos_w, quat_w = self._xform_views[path].get_world_poses() - if pos_w is not None and quat_w is not None: - positions[idx] = pos_w.torch.to(device=self._device, dtype=torch.float32).squeeze() - orientations[idx] = quat_w.torch.to(device=self._device, dtype=torch.float32).squeeze() - covered[idx] = True - xform_mask[idx] = True - count += 1 - except Exception: - self._xform_view_failures.add(path) - continue - - if len(self._xform_view_failures) > 0: - self._warn_once( - "xform-fallback-failures", - "[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, 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. - """ - return orientations - - def _read_poses_from_best_source(self) -> tuple[Any, Any, str, Any] | None: - """Merge pose data from rigid-body and xform views.""" - if self._newton_state is None or not self._rigid_body_paths: - return None - - import torch - - num_bodies = len(self._rigid_body_paths) - if num_bodies != self._newton_state.body_q.shape[0]: - self._warn_once( - "body-count-mismatch", - "[PhysxSceneDataProvider] Body count mismatch: body_key=%d, state=%d", - num_bodies, - int(self._newton_state.body_q.shape[0]), - ) - return None - - # Reuse buffers when size unchanged to avoid per-call allocations (MR perf). - if num_bodies != self._pose_buf_num_bodies or self._positions_buf is None: - self._pose_buf_num_bodies = num_bodies - self._positions_buf = torch.zeros((num_bodies, 3), dtype=torch.float32, device=self._device) - self._orientations_buf = torch.zeros((num_bodies, 4), dtype=torch.float32, device=self._device) - self._covered_buf = torch.zeros(num_bodies, dtype=torch.bool, device=self._device) - self._xform_mask_buf = torch.zeros(num_bodies, dtype=torch.bool, device=self._device) - else: - self._covered_buf.zero_() - self._xform_mask_buf.zero_() - - positions = self._positions_buf - orientations = self._orientations_buf - covered = self._covered_buf - xform_mask = self._xform_mask_buf - - 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 returned no transforms; " - "filled from XformPrimView where needed." - ), - level=logging.DEBUG, - ) - - if not covered.all(): - self._warn_once( - "pose-read-incomplete", - "[PhysxSceneDataProvider] Failed to read %d/%d body poses.", - int((~covered).sum().item()), - num_bodies, - ) - return None - - active = sum([rigid_count > 0, xform_count > 0]) - source = ( - "merged" if active > 1 else ("rigid_body_view" if rigid_count else "xform_view" if xform_count else "none") - ) - return positions, orientations, source, xform_mask - - def _get_set_body_q_kernel(self): - """Return module-level Warp kernel for writing transforms to Newton state.""" - return _set_body_q_kernel - - # ---- Newton state sync ---------------------------------------------------------------- - - 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 - - try: - # Re-check env count in case stage population completed after provider construction. - self._refresh_newton_model_if_needed() - - result = self._read_poses_from_best_source() - if result is None: - return - - positions, orientations, _, xform_mask = result - orientations_xyzw = self._convert_xform_quats(orientations.reshape(-1, 4), xform_mask) - - positions_wp = wp.from_torch(positions.reshape(-1, 3), dtype=wp.vec3) - orientations_wp = wp.from_torch(orientations_xyzw, dtype=wp.quatf) - - 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", - "[PhysxSceneDataProvider] Failed to sync transforms to Newton state: %s", - exc, - ) - - def get_newton_model(self) -> Any | None: - """Return Newton model when sync is enabled. - - Returns: - Newton model object, or ``None`` when unavailable. - """ - return self._newton_model if self._needs_newton_sync else None - - 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 - return self._newton_state - - # ---- Public provider API --------------------------------------------------------------- - - def get_usd_stage(self) -> Any: - """Return USD stage handle. - - Returns: - USD stage object. - """ - if self._stage is not None: - return self._stage - return getattr(self._simulation_context, "stage", None) - - def get_camera_transforms(self) -> dict[str, Any] | None: - """Return per-camera, per-environment transforms. - - Returns: - Dictionary containing camera order, positions, orientations, and environment count, - or ``None`` when unavailable. - """ - if self._stage is None: - return None - - import isaaclab.sim as isaaclab_sim - - env_pattern = re.compile(r"(?P/World/envs/env_)(?P\d+)(?P/.*)") - shared_paths: list[str] = [] - instances: dict[str, list[tuple[int, str]]] = {} - num_envs = -1 - - # Breadth-first walk so we discover camera prims across the full stage. - stage_prims = deque([self._stage.GetPseudoRoot()]) - while stage_prims: - prim = stage_prims.popleft() - prim_path = prim.GetPath().pathString - - world_id = 0 - template_path = prim_path - if match := env_pattern.match(prim_path): - # Normalize per-env path to a shared template key (env_%d/...) so - # visualizers can query one camera path for all env instances. - world_id = int(match.group("id")) - template_path = match.group("root") + "%d" + match.group("path") - if world_id > num_envs: - num_envs = world_id - - imageable = UsdGeom.Imageable(prim) - if imageable and imageable.ComputeVisibility() == UsdGeom.Tokens.invisible: - continue - - if prim.IsA(UsdGeom.Camera): - if template_path not in instances: - instances[template_path] = [] - instances[template_path].append((world_id, prim_path)) - if template_path not in shared_paths: - shared_paths.append(template_path) - - if hasattr(UsdGeom, "TraverseInstanceProxies"): - child_prims = prim.GetFilteredChildren(UsdGeom.TraverseInstanceProxies()) - else: - child_prims = prim.GetChildren() - if child_prims: - stage_prims.extend(child_prims) - - num_envs += 1 - positions: list[list[list[float] | None]] = [] - orientations: list[list[list[float] | None]] = [] - - for template_path in shared_paths: - per_world_pos: list[list[float] | None] = [None] * num_envs - per_world_ori: list[list[float] | None] = [None] * num_envs - for world_id, prim_path in instances.get(template_path, []): - if world_id < 0 or world_id >= num_envs: - continue - prim = self._stage.GetPrimAtPath(prim_path) - if not prim.IsValid(): - continue - pos, ori = isaaclab_sim.resolve_prim_pose(prim) - per_world_pos[world_id] = [float(pos[0]), float(pos[1]), float(pos[2])] - per_world_ori[world_id] = [float(ori[0]), float(ori[1]), float(ori[2]), float(ori[3])] - positions.append(per_world_pos) - orientations.append(per_world_ori) - - return {"order": shared_paths, "positions": positions, "orientations": orientations, "num_envs": num_envs} - - def get_metadata(self) -> dict[str, Any]: - """Return provider metadata for visualizers and renderers. - - Returns: - Metadata dictionary with backend and environment count. - """ - out = dict(self._metadata) - out["num_envs"] = self.get_num_envs() - return out - - def get_transforms(self) -> dict[str, Any] | None: - """Return merged body transforms from available PhysX views. - - Returns: - Dictionary with positions/orientations, or ``None`` when unavailable. - """ - try: - result = self._read_poses_from_best_source() - if result is None: - return None - - positions, orientations, _, xform_mask = result - orientations_xyzw = self._convert_xform_quats(orientations, xform_mask) - return {"positions": positions, "orientations": orientations_xyzw} - except Exception as exc: - self._warn_once( - "get-transforms-failed", - "[PhysxSceneDataProvider] get_transforms() failed: %s", - exc, - ) - return None - - def get_velocities(self) -> dict[str, Any] | None: - """Return linear/angular velocities from available PhysX views. - - Returns: - Dictionary with linear/angular velocities, or ``None`` when unavailable. - """ - for source, view in (("rigid_body_view", self._rigid_body_view),): - linear, angular = self._get_view_velocities(view) - if linear is not None and angular is not None: - return {"linear": linear, "angular": angular, "source": source} - return None - - def get_contacts(self) -> dict[str, Any] | None: - """Return contact data for PhysX provider. - - Returns: - ``None`` because contacts are not currently implemented in this provider. - """ - return None diff --git a/source/isaaclab_physx/setup.py b/source/isaaclab_physx/setup.py index eddfca89e1e1..77611a3ee365 100644 --- a/source/isaaclab_physx/setup.py +++ b/source/isaaclab_physx/setup.py @@ -50,7 +50,6 @@ "isaaclab_physx.cloner", "isaaclab_physx.physics", "isaaclab_physx.renderers", - "isaaclab_physx.scene_data_providers", "isaaclab_physx.sensors", "isaaclab_physx.sensors.contact_sensor", "isaaclab_physx.sensors.frame_transformer", diff --git a/source/isaaclab_tasks/changelog.d/dev-scene-data-provider-api.rst b/source/isaaclab_tasks/changelog.d/dev-scene-data-provider-api.rst new file mode 100644 index 000000000000..3beb9dba21a1 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/dev-scene-data-provider-api.rst @@ -0,0 +1,13 @@ +Fixed +^^^^^ + +* Fixed ``Isaac-Navigation-3DObstacles-ARL-Robot-1-v0`` config load + raising ``TypeError: only 0-dimensional arrays can be converted to + Python scalars`` under NumPy 2.0+. The wall-color sampling now + requests a scalar from :func:`numpy.random.randint` instead of a + shape-``(1,)`` array. +* Fixed ``make current-docs`` failing to import + :mod:`isaaclab_mimic.datagen` because the ``assemble_trocar`` robot + config evaluated ``np.pi`` at module scope, which raised + ``TypeError`` under Sphinx's mocked ``numpy``. Switched the constant + factors to :data:`math.pi`. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene.py index 711a9e990742..be3fe2e1fc53 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene.py @@ -55,7 +55,7 @@ def generate_obstacle_collection(cfg: ObstaclesSceneCfg) -> RigidObjectCollectio for wall_name, wall_cfg in cfg.wall_cfgs.items(): # Walls get their specific size and default center default_center = [0.0, 0.0, 0.0] # Will be set properly at reset - color = float(np.random.randint(0, 256, size=1, dtype=np.uint8)) / 255.0 + color = float(np.random.randint(0, 256, dtype=np.uint8)) / 255.0 rigid_objects[wall_name] = RigidObjectCfg( prim_path=f"{{ENV_REGEX_NS}}/obstacle_{wall_name}", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py index 81c60741b784..ed70792ca6a0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py @@ -13,7 +13,7 @@ `G1RobotPresets.g1_29dof_dex3_base_fix(...)`. """ -import numpy as np +import math from isaaclab.assets import ArticulationCfg from isaaclab.utils import configclass @@ -92,19 +92,19 @@ "right_wrist_pitch_joint": 1.182285, "right_wrist_yaw_joint": -0.022848, # dex3 hands (left) - "left_hand_index_0_joint": -60.0 * np.pi / 180.0, - "left_hand_middle_0_joint": -60.0 * np.pi / 180.0, + "left_hand_index_0_joint": -60.0 * math.pi / 180.0, + "left_hand_middle_0_joint": -60.0 * math.pi / 180.0, "left_hand_thumb_0_joint": 0.0, - "left_hand_index_1_joint": -40.0 * np.pi / 180.0, - "left_hand_middle_1_joint": -40.0 * np.pi / 180.0, + "left_hand_index_1_joint": -40.0 * math.pi / 180.0, + "left_hand_middle_1_joint": -40.0 * math.pi / 180.0, "left_hand_thumb_1_joint": 0.0, "left_hand_thumb_2_joint": 0.0, # dexterous hand joint - right hand - "right_hand_index_0_joint": 60.0 * np.pi / 180.0, - "right_hand_middle_0_joint": 60.0 * np.pi / 180.0, + "right_hand_index_0_joint": 60.0 * math.pi / 180.0, + "right_hand_middle_0_joint": 60.0 * math.pi / 180.0, "right_hand_thumb_0_joint": 0.0, - "right_hand_index_1_joint": 40.0 * np.pi / 180.0, - "right_hand_middle_1_joint": 40.0 * np.pi / 180.0, + "right_hand_index_1_joint": 40.0 * math.pi / 180.0, + "right_hand_middle_1_joint": 40.0 * math.pi / 180.0, "right_hand_thumb_1_joint": 0.0, "right_hand_thumb_2_joint": 0.0, } diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 701a21c79984..4766fbe7f68f 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: - from isaaclab.physics import BaseSceneDataProvider + from isaaclab.scene.scene_data_provider import SceneDataProvider _DEFAULT_VIEWPORT_NAME = "Visualizer Viewport" @@ -55,7 +55,7 @@ def __init__(self, cfg: KitVisualizerCfg): # ---- Lifecycle ------------------------------------------------------------------------ - def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: + def initialize(self, scene_data_provider: SceneDataProvider) -> None: """Initialize viewport resources and bind scene data provider. Args: @@ -68,26 +68,23 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: if scene_data_provider is None: raise RuntimeError("[KitVisualizer] Requires a scene_data_provider.") self._scene_data_provider = scene_data_provider - usd_stage = scene_data_provider.get_usd_stage() + usd_stage = scene_data_provider.usd_stage if usd_stage is None: raise RuntimeError("[KitVisualizer] USD stage not available from scene_data_provider.") - metadata = scene_data_provider.get_metadata() + num_envs = scene_data_provider.num_envs self._ensure_simulation_app() self._setup_viewport() 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.max_visible_envs, num_envs_meta - ) + self._resolved_visible_env_ids = resolve_visible_env_indices(self._env_ids, self.cfg.max_visible_envs, num_envs) if self._resolved_visible_env_ids is not None: logger.warning( "[KitVisualizer] Partial visualization in Kit uses visibility only; unselected env prims are hidden." ) - self._apply_env_visibility(usd_stage, metadata, self._resolved_visible_env_ids) + self._apply_env_visibility(usd_stage, num_envs, 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 ) self._log_initialization_table( logger=logger, @@ -369,7 +366,7 @@ def _set_active_camera_path(self, camera_path: str) -> bool: """ if self._viewport_api is None: return False - usd_stage = self._scene_data_provider.get_usd_stage() if self._scene_data_provider else None + usd_stage = self._scene_data_provider.usd_stage if self._scene_data_provider else None if usd_stage is None: return False camera_prim = usd_stage.GetPrimAtPath(camera_path) @@ -378,9 +375,8 @@ 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, visible_env_ids: list[int]) -> None: + def _apply_env_visibility(self, usd_stage, num_envs: int, 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(visible_env_ids) @@ -406,10 +402,10 @@ 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() + usd_stage = self._scene_data_provider.usd_stage if usd_stage is None: return - num_envs = int(self._scene_data_provider.get_metadata().get("num_envs", 0)) + num_envs = self._scene_data_provider.num_envs if num_envs <= 0: return self._apply_visual_point_instancer_visibility(usd_stage, num_envs, set(self._resolved_visible_env_ids)) @@ -457,7 +453,7 @@ def _point_instancer_instance_count(pi: UsdGeom.PointInstancer) -> int | None: def _restore_env_visibility(self) -> None: """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 + usd_stage = self._scene_data_provider.usd_stage if self._scene_data_provider else None if usd_stage is None: return for env_path, prev in self._hidden_env_visibilities.items(): diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index aeafc29bd264..31c17a7b16d6 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -19,13 +19,12 @@ from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds, resolve_visible_env_indices -from .newton_visualization_markers import render_newton_visualization_markers from .newton_visualizer_cfg import NewtonVisualizerCfg logger = logging.getLogger(__name__) if TYPE_CHECKING: - from isaaclab.physics import BaseSceneDataProvider + from isaaclab.scene.scene_data_provider import SceneDataProvider class NewtonViewerGL(ViewerGL): @@ -272,12 +271,14 @@ def __init__(self, cfg: NewtonVisualizerCfg): self._headless_no_viewer = False self._resolved_visible_env_ids: list[int] | None = None - def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: + def initialize(self, scene_data_provider: SceneDataProvider) -> None: """Initialize viewer resources and bind scene data provider. Args: scene_data_provider: Scene data provider used to fetch model/state data. """ + from isaaclab_newton.physics import NewtonManager + if self._is_initialized: logger.debug("[NewtonVisualizer] initialize() called while already initialized.") return @@ -285,11 +286,11 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: raise RuntimeError("Newton visualizer requires a scene_data_provider.") self._scene_data_provider = scene_data_provider - metadata = scene_data_provider.get_metadata() - num_envs = int(metadata.get("num_envs", 0)) + num_envs = scene_data_provider.num_envs + metadata = {"num_envs": num_envs} self._env_ids = self._compute_visualized_env_ids() - self._model = scene_data_provider.get_newton_model() - self._state = scene_data_provider.get_newton_state() + self._model = NewtonManager.get_model() + self._state = NewtonManager.get_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`. @@ -370,24 +371,16 @@ def step(self, dt: float) -> None: self._sim_time += dt self._step_counter += 1 + from isaaclab_newton.physics import NewtonManager + if self._viewer is None: - if self._scene_data_provider is not None: - self._state = self._scene_data_provider.get_newton_state() + self._state = NewtonManager.get_state() return if self.cfg.cam_source == "prim_path": self._update_camera_from_usd_path() - self._state = self._scene_data_provider.get_newton_state() - num_envs = int(self._scene_data_provider.get_metadata().get("num_envs", 0)) - - contacts = None - if self._viewer.show_contacts: - contacts_data = self._scene_data_provider.get_contacts() - if isinstance(contacts_data, dict): - contacts = contacts_data.get("contacts", contacts_data) - else: - contacts = contacts_data + self._state = NewtonManager.get_state() update_frequency = self._viewer._update_frequency if self._viewer else self._update_frequency if self._step_counter % update_frequency != 0: @@ -402,15 +395,6 @@ def step(self, dt: float) -> None: self._viewer.end_frame() return self._viewer.log_state(self._state) - if contacts is not None and hasattr(self._viewer, "log_contacts"): - try: - self._viewer.log_contacts(contacts, self._state) - except RuntimeError as exc: - logger.debug(f"[NewtonVisualizer] Failed to log contacts: {exc}") - if self.cfg.enable_markers: - render_newton_visualization_markers( - self._viewer, self._resolved_visible_env_ids, num_envs=num_envs - ) self._viewer.end_frame() else: self._viewer._update() diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py index 5600ad2ee9c4..7e6f9a00331a 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py @@ -26,7 +26,7 @@ from .rerun_visualizer_cfg import RerunVisualizerCfg if TYPE_CHECKING: - from isaaclab.physics import BaseSceneDataProvider + from isaaclab.scene.scene_data_provider import SceneDataProvider logger = logging.getLogger(__name__) @@ -136,23 +136,24 @@ def __init__(self, cfg: RerunVisualizerCfg): self._last_camera_pose: tuple[tuple[float, float, float], tuple[float, float, float]] | None = None self._resolved_visible_env_ids: list[int] | None = None - def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: + def initialize(self, scene_data_provider: SceneDataProvider) -> None: """Initialize rerun viewer and bind scene data provider. Args: scene_data_provider: Scene data provider used to fetch model/state data. """ + from isaaclab_newton.physics import NewtonManager + if self._is_initialized: return if scene_data_provider is None: raise RuntimeError("Rerun visualizer requires a scene_data_provider.") self._scene_data_provider = scene_data_provider - metadata = scene_data_provider.get_metadata() - num_envs = int(metadata.get("num_envs", 0)) + num_envs = scene_data_provider.num_envs self._env_ids = self._compute_visualized_env_ids() - self._model = scene_data_provider.get_newton_model() - self._state = scene_data_provider.get_newton_state() + self._model = NewtonManager.get_model() + self._state = NewtonManager.get_state() grpc_port = int(self.cfg.grpc_port) web_port = int(self.cfg.web_port) @@ -229,6 +230,8 @@ def step(self, dt: float) -> None: Args: dt: Simulation time-step in seconds. """ + from isaaclab_newton.physics import NewtonManager + if not self._is_initialized or self._is_closed or self._viewer is None: return @@ -238,8 +241,8 @@ 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() - num_envs = int(self._scene_data_provider.get_metadata().get("num_envs", 0)) + self._state = NewtonManager.get_state() + num_envs = NewtonManager.get_num_envs() 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 44a0e92c1d70..b3569b04dbf3 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py @@ -27,7 +27,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: - from isaaclab.physics import BaseSceneDataProvider + from isaaclab.scene.scene_data_provider import SceneDataProvider def _disable_viser_runtime_client_rebuild_if_bundled() -> None: @@ -133,12 +133,14 @@ def __init__(self, cfg: ViserVisualizerCfg): self._resolved_visible_env_ids: list[int] | None = None self._warned_marker_render_failure = False - def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: + def initialize(self, scene_data_provider: SceneDataProvider) -> None: """Initialize viewer resources and bind scene data provider. Args: scene_data_provider: Scene data provider used to fetch model/state data. """ + from isaaclab_newton.physics import NewtonManager + if self._is_initialized: logger.debug("[ViserVisualizer] initialize() called while already initialized.") return @@ -146,19 +148,17 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: raise RuntimeError("Viser visualizer requires a scene_data_provider.") self._scene_data_provider = scene_data_provider - metadata = scene_data_provider.get_metadata() + num_envs = scene_data_provider.num_envs + metadata = {"num_envs": num_envs} self._env_ids = self._compute_visualized_env_ids() - self._model = scene_data_provider.get_newton_model() - self._state = scene_data_provider.get_newton_state() + self._model = NewtonManager.get_model() + self._state = NewtonManager.get_state() self._active_record_path = self.cfg.record_to_viser self._create_viewer(record_to_viser=self.cfg.record_to_viser, metadata=metadata) - num_envs_meta = int(metadata.get("num_envs", 0)) - self._resolved_visible_env_ids = resolve_visible_env_indices( - self._env_ids, self.cfg.max_visible_envs, num_envs_meta - ) + self._resolved_visible_env_ids = resolve_visible_env_indices(self._env_ids, self.cfg.max_visible_envs, num_envs) 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 ) viewer_url = _viser_web_viewer_url(self.cfg.port) self._log_initialization_table( @@ -182,6 +182,8 @@ def step(self, dt: float) -> None: Args: dt: Simulation time-step in seconds. """ + from isaaclab_newton.physics import NewtonManager + if not self._is_initialized or self._viewer is None or self._scene_data_provider is None: return @@ -189,8 +191,9 @@ 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() - num_envs = int(self._scene_data_provider.get_metadata().get("num_envs", 0)) + self._state = NewtonManager.get_state() + num_envs = NewtonManager.get_num_envs() + self._sim_time += dt self._viewer.begin_frame(self._sim_time) try: From 00ec13e60f79dbf1e1b1778bcb8d63ab971d21bf Mon Sep 17 00:00:00 2001 From: Nicholas Blauch Date: Wed, 13 May 2026 19:54:29 -0700 Subject: [PATCH 054/133] Restore RT2 RenderCfg fields removed in PhysicsManager refactor (#5167) # Description PR [#4142](https://github.com/isaac-sim/IsaacLab/pull/4142) added 10 typed RT2 (`RealTimePathTracing`) fields to `RenderCfg` to expose the carb settings that actually control rendering quality under the RT2 mode that has been default since Isaac Sim 4.5. The PhysicsManager refactor in PR [#4564](https://github.com/isaac-sim/IsaacLab/pull/4564) (commit `0ba9c5cb`) accidentally removed all 10 of these fields during a large `simulation_cfg.py` rewrite, while the `.kit` preset files that reference the same carb paths were left intact. This PR restores the removed fields and their `field_to_setting` mappings, along with test coverage. **Restored fields:** | Field | Carb Path | |-------|-----------| | `max_bounces` | `/rtx/rtpt/maxBounces` | | `split_glass` | `/rtx/rtpt/splitGlass` | | `split_clearcoat` | `/rtx/rtpt/splitClearcoat` | | `split_rough_reflection` | `/rtx/rtpt/splitRoughReflection` | | `ambient_light_intensity` | `/rtx/sceneDb/ambientLightIntensity` | | `ambient_occlusion_denoiser_mode` | `/rtx/ambientOcclusion/denoiserMode` | | `subpixel_mode` | `/rtx/raytracing/subpixel/mode` | | `enable_cached_raytracing` | `/rtx/raytracing/cached/enabled` | | `max_samples_per_launch` | `/rtx/pathtracing/maxSamplesPerLaunch` | | `view_tile_limit` | `/rtx/viewTile/limit` | Without these fields, users can only reach RT2 quality knobs through the `carb_settings` escape-hatch dict, while the existing named fields (`samples_per_pixel`, `enable_translucency`, `enable_reflections`, `enable_direct_lighting`) map to RT1 Legacy carb paths that RT2 ignores entirely. ## 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) - [ ] 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 --------- Signed-off-by: Nicholas Blauch Co-authored-by: ooctipus Co-authored-by: Antoine RICHARD Co-authored-by: isaaclab-review-bot[bot] <270793704+isaaclab-review-bot[bot]@users.noreply.github.com> --- CONTRIBUTORS.md | 1 + .../isaaclab/isaaclab/sim/simulation_cfg.py | 57 +++++++++++++ .../isaaclab/sim/simulation_context.py | 11 +++ .../test/sim/test_simulation_render_config.py | 84 +++++++++++++++++++ 4 files changed, 153 insertions(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2f0733585af1..d5d99f2fcd81 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -136,6 +136,7 @@ Guidelines for modifications: * Narendra Dahile * Neel Anand Jawale * Nicola Loi +* Nicholas Blauch * Norbert Cygiert * Nuoyan Chen (Alvin) * Nuralem Abizov diff --git a/source/isaaclab/isaaclab/sim/simulation_cfg.py b/source/isaaclab/isaaclab/sim/simulation_cfg.py index 6c2e97bb7e82..eb0f9821592c 100644 --- a/source/isaaclab/isaaclab/sim/simulation_cfg.py +++ b/source/isaaclab/isaaclab/sim/simulation_cfg.py @@ -151,6 +151,63 @@ class RenderCfg: This is set by the variable: ``/rtx/domeLight/upperLowerStrategy``. """ + max_bounces: int | None = None + """Maximum number of ray bounces for path tracing (RT2). Default is 2. + + For global illumination (indirect diffuse), this should be at least 3. + + This is set by the variable: ``/rtx/rtpt/maxBounces``. + """ + + split_glass: bool | None = None + """Enables separate glass ray splitting for improved glass rendering (RT2). Default is False. + + Enabling this can reduce noise on glass materials at the cost of performance. + + This is set by the variable: ``/rtx/rtpt/splitGlass``. + """ + + split_clearcoat: bool | None = None + """Enables separate clearcoat ray splitting (RT2). Default is False. + + Enabling this can reduce noise on clearcoat materials at the cost of performance. + + This is set by the variable: ``/rtx/rtpt/splitClearcoat``. + """ + + split_rough_reflection: bool | None = None + """Enables separate rough reflection ray splitting (RT2). Default is False. + + Enabling this can reduce noise on rough reflective materials at the cost of performance. + + This is set by the variable: ``/rtx/rtpt/splitRoughReflection``. + """ + + ambient_light_intensity: float | None = None + """Scene ambient light intensity. Default is 1.0. + + This is set by the variable: ``/rtx/sceneDb/ambientLightIntensity``. + """ + + ambient_occlusion_denoiser_mode: Literal[0, 1] | None = None + """Ambient occlusion denoiser mode. Default is 1. + + Valid values are: + + * 0: Higher quality denoising + * 1: Performance-oriented denoising + + This is set by the variable: ``/rtx/ambientOcclusion/denoiserMode``. + """ + + view_tile_limit: int | None = None + """Maximum number of view tiles. Default is 1000000. + + This setting helps avoid silent trimming of tiles. + + This is set by the variable: ``/rtx/viewTile/limit``. + """ + carb_settings: dict[str, Any] | None = None """A general dictionary for users to supply all carb rendering settings with native names. diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 1fd965185221..175961fcd383 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -274,6 +274,17 @@ def _apply_nested(data: dict[str, Any], path: str = "") -> None: "enable_shadows": "/rtx/shadows/enabled", "enable_ambient_occlusion": "/rtx/ambientOcclusion/enabled", "dome_light_upper_lower_strategy": "/rtx/domeLight/upperLowerStrategy", + "ambient_light_intensity": "/rtx/sceneDb/ambientLightIntensity", + "ambient_occlusion_denoiser_mode": "/rtx/ambientOcclusion/denoiserMode", + "subpixel_mode": "/rtx/raytracing/subpixel/mode", + "enable_cached_raytracing": "/rtx/raytracing/cached/enabled", + "max_samples_per_launch": "/rtx/pathtracing/maxSamplesPerLaunch", + "view_tile_limit": "/rtx/viewTile/limit", + # RT2 path tracing settings + "max_bounces": "/rtx/rtpt/maxBounces", + "split_glass": "/rtx/rtpt/splitGlass", + "split_clearcoat": "/rtx/rtpt/splitClearcoat", + "split_rough_reflection": "/rtx/rtpt/splitRoughReflection", } for key, value in vars(render_cfg).items(): diff --git a/source/isaaclab/test/sim/test_simulation_render_config.py b/source/isaaclab/test/sim/test_simulation_render_config.py index e5ad4274d184..7675ae896342 100644 --- a/source/isaaclab/test/sim/test_simulation_render_config.py +++ b/source/isaaclab/test/sim/test_simulation_render_config.py @@ -41,6 +41,17 @@ def test_render_cfg(): samples_per_pixel = 4 enable_shadows = True enable_ambient_occlusion = True + # RT2 settings + max_bounces = 4 + split_glass = True + split_clearcoat = True + split_rough_reflection = True + ambient_light_intensity = 0.5 + ambient_occlusion_denoiser_mode = 0 + subpixel_mode = 1 + enable_cached_raytracing = True + max_samples_per_launch = 500000 + view_tile_limit = 500000 render_cfg = RenderCfg( enable_translucency=enable_translucency, @@ -54,6 +65,17 @@ def test_render_cfg(): samples_per_pixel=samples_per_pixel, enable_shadows=enable_shadows, enable_ambient_occlusion=enable_ambient_occlusion, + # RT2 settings + max_bounces=max_bounces, + split_glass=split_glass, + split_clearcoat=split_clearcoat, + split_rough_reflection=split_rough_reflection, + ambient_light_intensity=ambient_light_intensity, + ambient_occlusion_denoiser_mode=ambient_occlusion_denoiser_mode, + subpixel_mode=subpixel_mode, + enable_cached_raytracing=enable_cached_raytracing, + max_samples_per_launch=max_samples_per_launch, + view_tile_limit=view_tile_limit, ) cfg = SimulationCfg(render=render_cfg) @@ -74,6 +96,16 @@ def test_render_cfg(): assert sim.cfg.render.samples_per_pixel == samples_per_pixel assert sim.cfg.render.enable_shadows == enable_shadows assert sim.cfg.render.enable_ambient_occlusion == enable_ambient_occlusion + assert sim.cfg.render.max_bounces == max_bounces + assert sim.cfg.render.split_glass == split_glass + assert sim.cfg.render.split_clearcoat == split_clearcoat + assert sim.cfg.render.split_rough_reflection == split_rough_reflection + assert sim.cfg.render.ambient_light_intensity == ambient_light_intensity + assert sim.cfg.render.ambient_occlusion_denoiser_mode == ambient_occlusion_denoiser_mode + assert sim.cfg.render.subpixel_mode == subpixel_mode + assert sim.cfg.render.enable_cached_raytracing == enable_cached_raytracing + assert sim.cfg.render.max_samples_per_launch == max_samples_per_launch + assert sim.cfg.render.view_tile_limit == view_tile_limit assert sim.get_setting("/rtx/translucency/enabled") == sim.cfg.render.enable_translucency assert sim.get_setting("/rtx/reflections/enabled") == sim.cfg.render.enable_reflections @@ -85,6 +117,16 @@ def test_render_cfg(): assert sim.get_setting("/rtx/directLighting/sampledLighting/samplesPerPixel") == sim.cfg.render.samples_per_pixel assert sim.get_setting("/rtx/shadows/enabled") == sim.cfg.render.enable_shadows assert sim.get_setting("/rtx/ambientOcclusion/enabled") == sim.cfg.render.enable_ambient_occlusion + assert sim.get_setting("/rtx/rtpt/maxBounces") == sim.cfg.render.max_bounces + assert sim.get_setting("/rtx/rtpt/splitGlass") == sim.cfg.render.split_glass + assert sim.get_setting("/rtx/rtpt/splitClearcoat") == sim.cfg.render.split_clearcoat + assert sim.get_setting("/rtx/rtpt/splitRoughReflection") == sim.cfg.render.split_rough_reflection + assert sim.get_setting("/rtx/sceneDb/ambientLightIntensity") == sim.cfg.render.ambient_light_intensity + assert sim.get_setting("/rtx/ambientOcclusion/denoiserMode") == sim.cfg.render.ambient_occlusion_denoiser_mode + assert sim.get_setting("/rtx/raytracing/subpixel/mode") == sim.cfg.render.subpixel_mode + assert sim.get_setting("/rtx/raytracing/cached/enabled") == sim.cfg.render.enable_cached_raytracing + assert sim.get_setting("/rtx/pathtracing/maxSamplesPerLaunch") == sim.cfg.render.max_samples_per_launch + assert sim.get_setting("/rtx/viewTile/limit") == sim.cfg.render.view_tile_limit assert sim.get_setting("/rtx/post/aa/op") == 4 # dlss = 3, dlaa=4 @@ -162,6 +204,17 @@ def test_render_cfg_defaults(): samples_per_pixel = 1 enable_shadows = False enable_ambient_occlusion = False + # RT2 defaults + max_bounces = 2 + split_glass = False + split_clearcoat = False + split_rough_reflection = False + ambient_light_intensity = 1.0 + ambient_occlusion_denoiser_mode = 1 + subpixel_mode = 0 + enable_cached_raytracing = False + max_samples_per_launch = 1000000 + view_tile_limit = 1000000 render_cfg = RenderCfg( enable_translucency=enable_translucency, @@ -175,6 +228,17 @@ def test_render_cfg_defaults(): samples_per_pixel=samples_per_pixel, enable_shadows=enable_shadows, enable_ambient_occlusion=enable_ambient_occlusion, + # RT2 settings + max_bounces=max_bounces, + split_glass=split_glass, + split_clearcoat=split_clearcoat, + split_rough_reflection=split_rough_reflection, + ambient_light_intensity=ambient_light_intensity, + ambient_occlusion_denoiser_mode=ambient_occlusion_denoiser_mode, + subpixel_mode=subpixel_mode, + enable_cached_raytracing=enable_cached_raytracing, + max_samples_per_launch=max_samples_per_launch, + view_tile_limit=view_tile_limit, ) cfg = SimulationCfg(render=render_cfg) @@ -192,6 +256,16 @@ def test_render_cfg_defaults(): assert sim.cfg.render.samples_per_pixel == samples_per_pixel assert sim.cfg.render.enable_shadows == enable_shadows assert sim.cfg.render.enable_ambient_occlusion == enable_ambient_occlusion + assert sim.cfg.render.max_bounces == max_bounces + assert sim.cfg.render.split_glass == split_glass + assert sim.cfg.render.split_clearcoat == split_clearcoat + assert sim.cfg.render.split_rough_reflection == split_rough_reflection + assert sim.cfg.render.ambient_light_intensity == ambient_light_intensity + assert sim.cfg.render.ambient_occlusion_denoiser_mode == ambient_occlusion_denoiser_mode + assert sim.cfg.render.subpixel_mode == subpixel_mode + assert sim.cfg.render.enable_cached_raytracing == enable_cached_raytracing + assert sim.cfg.render.max_samples_per_launch == max_samples_per_launch + assert sim.cfg.render.view_tile_limit == view_tile_limit assert sim.get_setting("/rtx/translucency/enabled") == sim.cfg.render.enable_translucency assert sim.get_setting("/rtx/reflections/enabled") == sim.cfg.render.enable_reflections @@ -203,4 +277,14 @@ def test_render_cfg_defaults(): assert sim.get_setting("/rtx/directLighting/sampledLighting/samplesPerPixel") == sim.cfg.render.samples_per_pixel assert sim.get_setting("/rtx/shadows/enabled") == sim.cfg.render.enable_shadows assert sim.get_setting("/rtx/ambientOcclusion/enabled") == sim.cfg.render.enable_ambient_occlusion + assert sim.get_setting("/rtx/rtpt/maxBounces") == sim.cfg.render.max_bounces + assert sim.get_setting("/rtx/rtpt/splitGlass") == sim.cfg.render.split_glass + assert sim.get_setting("/rtx/rtpt/splitClearcoat") == sim.cfg.render.split_clearcoat + assert sim.get_setting("/rtx/rtpt/splitRoughReflection") == sim.cfg.render.split_rough_reflection + assert sim.get_setting("/rtx/sceneDb/ambientLightIntensity") == sim.cfg.render.ambient_light_intensity + assert sim.get_setting("/rtx/ambientOcclusion/denoiserMode") == sim.cfg.render.ambient_occlusion_denoiser_mode + assert sim.get_setting("/rtx/raytracing/subpixel/mode") == sim.cfg.render.subpixel_mode + assert sim.get_setting("/rtx/raytracing/cached/enabled") == sim.cfg.render.enable_cached_raytracing + assert sim.get_setting("/rtx/pathtracing/maxSamplesPerLaunch") == sim.cfg.render.max_samples_per_launch + assert sim.get_setting("/rtx/viewTile/limit") == sim.cfg.render.view_tile_limit assert sim.get_setting("/rtx/post/aa/op") == 3 # dlss = 3, dlaa=4 From a7c791e3b442646863774b26dd144c380a278c42 Mon Sep 17 00:00:00 2001 From: Krishna Lakhi Date: Thu, 14 May 2026 08:24:52 +0530 Subject: [PATCH 055/133] Fix LeappDeploymentEnv missing extras attribute (#5560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommandManager terms call self._env.extras during reset(). LeappDeploymentEnv bypasses the standard ManagerBasedRLEnv init path and never initializes this dict, causing an AttributeError on the first reset() call. Add self.extras: dict = {} to __init__ to fix the crash. # 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 - [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 - [ ] 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 --- .../changelog.d/klakhi-fix-leapp-deployment-env-extras.rst | 6 ++++++ source/isaaclab/isaaclab/envs/leapp_deployment_env.py | 1 + 2 files changed, 7 insertions(+) create mode 100644 source/isaaclab/changelog.d/klakhi-fix-leapp-deployment-env-extras.rst diff --git a/source/isaaclab/changelog.d/klakhi-fix-leapp-deployment-env-extras.rst b/source/isaaclab/changelog.d/klakhi-fix-leapp-deployment-env-extras.rst new file mode 100644 index 000000000000..1253f5ab83d9 --- /dev/null +++ b/source/isaaclab/changelog.d/klakhi-fix-leapp-deployment-env-extras.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed :class:`~envs.LeappDeploymentEnv` crashing on ``reset()`` with + ``AttributeError: 'LeappDeploymentEnv' object has no attribute 'extras'`` + by initializing ``self.extras`` in ``__init__``. diff --git a/source/isaaclab/isaaclab/envs/leapp_deployment_env.py b/source/isaaclab/isaaclab/envs/leapp_deployment_env.py index 3284284570fe..a2ad5b897647 100644 --- a/source/isaaclab/isaaclab/envs/leapp_deployment_env.py +++ b/source/isaaclab/isaaclab/envs/leapp_deployment_env.py @@ -175,6 +175,7 @@ def __init__(self, cfg: Any, leapp_yaml_path: str): self._leapp_yaml_path = leapp_yaml_path self._step_count = 0 self._sim_step_counter = 0 + self.extras: dict = {} # ── Simulation + scene ──────────────────────────────────── self.sim = SimulationContext(cfg.sim) From d12cfce669a6453fac9915f864e06993428441fd Mon Sep 17 00:00:00 2001 From: myurasov-nv <168484206+myurasov-nv@users.noreply.github.com> Date: Wed, 13 May 2026 19:55:08 -0700 Subject: [PATCH 056/133] Fixes some nvbugs (#5584) Fixes some nvbugs: - NVBug 6141356: added `pip` to `environment.yml` so aarch64 conda envs (e.g. DGX Spark) get pip seeded, which conda-forge does not pull in transitively. - NVBug 6129956: wrapped the four command blocks in the kitless install page in synced Linux / Windows tab-sets so Windows users have copy-paste commands. - NVBug 5984996: `isaaclab.bat` and `isaaclab.sh` now warn when `_isaac_sim` is present but `setup_conda_env.bat` is missing; shipping the file is a Sim-side follow-up. - NVBug 6125054 (follow-up): moved `viser` to an opt-in extra in `python_packages.toml` since `viser>=1.0.16` pulls `websockets>=13.1` which collides with `isaacsim==6.0.0.0`'s `websockets==12.0`. Also fixed a broken link surfaced by the docs link checker: `https://robosuite.ai/` (404) -> `https://robosuite.ai/docs/overview.html` in `ecosystem.rst`. Drive-by: removed `numpy` from `autodoc_mock_imports` in `docs/conf.py` since it's already in `docs/requirements.txt`; fixes pre-existing `Build Latest Docs` regression from PR#5082. ## 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` - [ ] 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 - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` since CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Kelly Guo Co-authored-by: Kelly Guo --- docs/conf.py | 1 - .../installation/kitless_installation.rst | 116 ++++++++++++++---- environment.yml | 1 + isaaclab.bat | 9 +- isaaclab.sh | 11 +- .../changelog.d/myurasov-conda-env-pip.rst | 9 ++ .../myurasov-nvbug-6125054-viser-extra.rst | 12 ++ tools/wheel_builder/res/python_packages.toml | 11 +- 8 files changed, 141 insertions(+), 29 deletions(-) create mode 100644 source/isaaclab/changelog.d/myurasov-conda-env-pip.rst create mode 100644 source/isaaclab/changelog.d/myurasov-nvbug-6125054-viser-extra.rst diff --git a/docs/conf.py b/docs/conf.py index a52ff90d31cc..bcc355812afa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -157,7 +157,6 @@ autodoc_mock_imports = [ "torch", "torchvision", - "numpy", "matplotlib", "scipy", "carb", diff --git a/docs/source/setup/installation/kitless_installation.rst b/docs/source/setup/installation/kitless_installation.rst index 506a70eeb0bd..30d03533d239 100644 --- a/docs/source/setup/installation/kitless_installation.rst +++ b/docs/source/setup/installation/kitless_installation.rst @@ -11,22 +11,46 @@ fastest way to get started and is ideal for users who only need the Newton physi Cloning and installing Isaac Lab -------------------------------- -With the virtual environment activated, clone the repository and run the kit-less installer: +With the virtual environment activated, clone the repository: .. code-block:: bash - # Clone Isaac Lab git clone https://github.com/isaac-sim/IsaacLab.git cd IsaacLab - # Install Isaac Lab (Newton backend, no Isaac Sim required) - ./isaaclab.sh --install # or ./isaaclab.sh -i +Then install Isaac Lab (Newton backend, no Isaac Sim required) and kickoff training +with MJWarp physics and the Newton visualizer: - # Kickoff training with MJWarp physics and Newton visualizer - ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ - --task=Isaac-Cartpole-Direct-v0 \ - --num_envs=16 --max_iterations=10 \ - presets=newton_mjwarp --visualizer newton +.. tab-set:: + :sync-group: os + + .. tab-item:: :icon:`fa-brands fa-linux` Linux + :sync: linux + + .. code-block:: bash + + # Install Isaac Lab (Newton backend, no Isaac Sim required) + ./isaaclab.sh --install # or ./isaaclab.sh -i + + # Kickoff training with MJWarp physics and Newton visualizer + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task=Isaac-Cartpole-Direct-v0 \ + --num_envs=16 --max_iterations=10 \ + presets=newton_mjwarp --visualizer newton + + .. tab-item:: :icon:`fa-brands fa-windows` Windows + :sync: windows + + .. code-block:: batch + + :: Install Isaac Lab (Newton backend, no Isaac Sim required) + isaaclab.bat --install :: or isaaclab.bat -i + + :: Kickoff training with MJWarp physics and Newton visualizer + isaaclab.bat -p scripts\reinforcement_learning\rsl_rl\train.py ^ + --task=Isaac-Cartpole-Direct-v0 ^ + --num_envs=16 --max_iterations=10 ^ + presets=newton_mjwarp --visualizer newton **Features available in kit-less mode (Newton backend, no Isaac Sim):** @@ -92,13 +116,30 @@ sub-package names: Examples: -.. code-block:: bash +.. tab-set:: + :sync-group: os + + .. tab-item:: :icon:`fa-brands fa-linux` Linux + :sync: linux + + .. code-block:: bash + + # Minimal Newton setup + ./isaaclab.sh -i newton,tasks,assets,ov,rl[rsl_rl] + + # Newton with OVRTX, RSL-RL, and Newton visualizer + ./isaaclab.sh -i newton,tasks,assets,ov[ovrtx],rl[rsl_rl],visualizers[newton] + + .. tab-item:: :icon:`fa-brands fa-windows` Windows + :sync: windows - # Minimal Newton setup - ./isaaclab.sh -i newton,tasks,assets,ov,rl[rsl_rl] + .. code-block:: batch - # Newton with OVRTX, RSL-RL, and Newton visualizer - ./isaaclab.sh -i newton,tasks,assets,ov[ovrtx],rl[rsl_rl],visualizers[newton] + :: Minimal Newton setup + isaaclab.bat -i newton,tasks,assets,ov,rl[rsl_rl] + + :: Newton with OVRTX, RSL-RL, and Newton visualizer + isaaclab.bat -i newton,tasks,assets,ov[ovrtx],rl[rsl_rl],visualizers[newton] .. _installation-ovrtx: @@ -108,19 +149,50 @@ OVRTX Rendering OVRTX provides GPU-accelerated rendering for vision tasks without Kit. -.. code-block:: bash +.. tab-set:: + :sync-group: os + + .. tab-item:: :icon:`fa-brands fa-linux` Linux + :sync: linux + + .. code-block:: bash + + ./isaaclab.sh -i ov[ovrtx] + + ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + --task Isaac-Repose-Cube-Shadow-Vision-Benchmark-Direct-v0 \ + --headless --enable_cameras --num_envs 16 --max_iterations 10 \ + presets=newton_mjwarp,ovrtx_renderer,simple_shading_diffuse_mdl + + .. tab-item:: :icon:`fa-brands fa-windows` Windows + :sync: windows - ./isaaclab.sh -i ov[ovrtx] + .. code-block:: batch - ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ - --task Isaac-Repose-Cube-Shadow-Vision-Benchmark-Direct-v0 \ - --headless --enable_cameras --num_envs 16 --max_iterations 10 \ - presets=newton_mjwarp,ovrtx_renderer,simple_shading_diffuse_mdl + isaaclab.bat -i ov[ovrtx] + + isaaclab.bat -p scripts\benchmarks\benchmark_rsl_rl.py ^ + --task Isaac-Repose-Cube-Shadow-Vision-Benchmark-Direct-v0 ^ + --headless --enable_cameras --num_envs 16 --max_iterations 10 ^ + presets=newton_mjwarp,ovrtx_renderer,simple_shading_diffuse_mdl Running Installation Tests -------------------------- -.. code-block:: bash +.. tab-set:: + :sync-group: os + + .. tab-item:: :icon:`fa-brands fa-linux` Linux + :sync: linux + + .. code-block:: bash + + ./isaaclab.sh -p -m pytest source/isaaclab/test/cli/test_cli_utils.py -v + + .. tab-item:: :icon:`fa-brands fa-windows` Windows + :sync: windows + + .. code-block:: batch - ./isaaclab.sh -p -m pytest source/isaaclab/test/cli/test_cli_utils.py -v + isaaclab.bat -p -m pytest source\isaaclab\test\cli\test_cli_utils.py -v diff --git a/environment.yml b/environment.yml index 7ea6fdfcf9a9..98908d816e28 100644 --- a/environment.yml +++ b/environment.yml @@ -8,4 +8,5 @@ channels: - defaults dependencies: - python=3.12 + - pip - importlib_metadata diff --git a/isaaclab.bat b/isaaclab.bat index 1d8fb8275467..8977159cedc6 100644 --- a/isaaclab.bat +++ b/isaaclab.bat @@ -29,8 +29,13 @@ 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 +if exist "%ISAACLAB_PATH%\_isaac_sim\" ( + if exist "%ISAACLAB_PATH%\_isaac_sim\setup_conda_env.bat" ( + call "%ISAACLAB_PATH%\_isaac_sim\setup_conda_env.bat" >NUL + ) else ( + echo [WARNING] _isaac_sim is present but _isaac_sim\setup_conda_env.bat is missing; Isaac Sim env vars not exported. 1>&2 + echo [WARNING] Re-extract the Isaac Sim Windows zip if you intend to use the bundled binary. 1>&2 + ) ) rem Execute CLI. diff --git a/isaaclab.sh b/isaaclab.sh index d4042353e88f..7e45dfb1246b 100755 --- a/isaaclab.sh +++ b/isaaclab.sh @@ -31,9 +31,14 @@ 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 +if [ -d "$ISAACLAB_PATH/_isaac_sim" ]; then + 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 + else + echo "[WARNING] _isaac_sim is present but _isaac_sim/setup_conda_env.sh is missing; Isaac Sim env vars not exported." >&2 + echo "[WARNING] Re-extract the Isaac Sim binary zip if you intend to use the bundled binary." >&2 + fi fi # Execute CLI. diff --git a/source/isaaclab/changelog.d/myurasov-conda-env-pip.rst b/source/isaaclab/changelog.d/myurasov-conda-env-pip.rst new file mode 100644 index 000000000000..e4310fc0306e --- /dev/null +++ b/source/isaaclab/changelog.d/myurasov-conda-env-pip.rst @@ -0,0 +1,9 @@ +Fixed +^^^^^ + +* Fixed ``./isaaclab.sh -p -m pip ...`` failing with ``No module named pip`` + in the conda env created from ``environment.yml`` on Linux aarch64 + (e.g. DGX Spark / GB10). The conda-forge solver was not pulling + ``pip`` in transitively on aarch64, so the resulting ``env_isaaclab`` + had no pip. ``environment.yml`` now lists ``pip`` explicitly so it + is seeded on every platform. diff --git a/source/isaaclab/changelog.d/myurasov-nvbug-6125054-viser-extra.rst b/source/isaaclab/changelog.d/myurasov-nvbug-6125054-viser-extra.rst new file mode 100644 index 000000000000..46dd451bd81a --- /dev/null +++ b/source/isaaclab/changelog.d/myurasov-nvbug-6125054-viser-extra.rst @@ -0,0 +1,12 @@ +Fixed +^^^^^ + +* Fixed ``pip install isaaclab[isaacsim,all]==3.0.0`` failing with + ``No solution found`` (UV) or ``error: resolution-too-deep`` (pip) when + resolving against ``isaacsim==6.0.0.0``. ``viser>=1.0.16`` was a base + dependency of the built ``isaaclab`` wheel and transitively requires + ``websockets>=13.1``, but ``isaacsim-kernel==6.0.0.0`` pins + ``websockets==12.0``. Moved ``viser`` to an opt-in ``viser`` extra in + ``tools/wheel_builder/res/python_packages.toml`` so the base wheel is + installable alongside ``isaacsim==6.0.0.0``. Users who want the Viser + visualizer can request it explicitly with ``isaaclab[viser]``. diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index 7fbe39505863..da477961d03c 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -41,7 +41,10 @@ pyproject.dependencies.all = [ # visualizers "imgui-bundle==1.92.4", "rerun-sdk>=0.29.0", - "viser>=1.0.16", + # viser is intentionally not a base dep: viser>=1.0.16 pulls websockets>=13.1, + # but isaacsim-kernel==6.0.0.0 pins websockets==12.0. Users who want the viser + # visualizer install isaaclab[viser] explicitly (see the optional-dependencies + # block below). "typing_extensions==4.12.2", "lazy_loader>=0.4", "pin ; platform_system == 'Linux'", @@ -99,6 +102,12 @@ pyproject.optional-dependencies.all = [ # { "rl_games" = ["rl-games==1.6.1"] }, # TODO: re-enable when rl-games Python package supports Python 3.11 { "rsl-rl" = ["rsl-rl-lib==3.1.2", "onnxscript>=0.5"] }, { "rsl_rl" = ["rsl-rl-lib==3.1.2", "onnxscript>=0.5"] }, + # ================================================================================ + # https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_visualizers/setup.py + # ================================================================================ + # Viser visualizer (opt-in: viser pulls websockets>=13.1 which collides with + # isaacsim-kernel==6.0.0.0's websockets==12.0; do not include in [all]). + { "viser" = ["viser>=1.0.16"] }, # RL libraries (all) { "all" = [ "stable-baselines3>=2.6", From ce95adbb9893be592fe1cb0abfa42be338fac0b8 Mon Sep 17 00:00:00 2001 From: hujc Date: Wed, 13 May 2026 19:56:57 -0700 Subject: [PATCH 057/133] [Newton] Drop explicit mujoco/mujoco-warp pins, defer to newton[sim] (#5566) ## Summary - `mujoco` and `mujoco-warp` are already declared under Newton's `[sim]` optional-dependency extra. Re-listing them in IsaacLab's `setup.py` was redundant and pinned them at IsaacLab's chosen versions instead of Newton's. - The pins also lived in `source/isaaclab/setup.py`'s top-level `INSTALL_REQUIRES`, so users installing only the PhysX or Kit backends were forced to pull MuJoCo even though nothing in `isaaclab` core imports it. - Switch the Newton spec to `newton[sim] @ git+...` and remove the direct pins from both setup.py files plus the wheel builder manifest. Newton becomes the single source of truth for those versions. ## Verification - `grep -rn "import mujoco\|import mujoco_warp\|MjModel\|MjData"` across the repo: zero direct usages. The Newton backend uses `newton.solvers.SolverMuJoCo` (transitive). MJCF asset import goes through Isaac Sim's `isaacsim.asset.importer.mjcf`, not the `mujoco` Python package. - Newton's [`pyproject.toml`](https://github.com/newton-physics/newton/blob/v1.2.0rc2/pyproject.toml) `[sim]` extra: `mujoco~=3.8.0`, `mujoco-warp>=3.8.0.1,~=3.8.0`. Newton's compatible-release spec already covers the patch versions IsaacLab was hard-pinning. - All other install pathways (`./isaaclab.sh -i`, `cli/commands/install.py`, `docker/Dockerfile.*`, `environment.yml`, root `pyproject.toml`, per-package `pyproject.toml`) read deps from these three files; no other version pins exist. - `./isaaclab.sh -f` passes. ## Test plan - [ ] Fresh `./isaaclab.sh -i --install newton` install resolves Newton, `mujoco`, and `mujoco-warp` transitively. - [ ] Smoke-test a Newton-backed task to confirm the MuJoCo Warp solver still loads. - [ ] Wheel build with `tools/wheel_builder` produces wheels with the same set of MuJoCo packages as before. --------- Co-authored-by: Kelly Guo --- .../changelog.d/jichuanh-drop-mujoco-deps.rst | 8 ++++++++ source/isaaclab/setup.py | 4 +--- .../changelog.d/jichuanh-drop-mujoco-deps.rst | 8 ++++++++ source/isaaclab_newton/setup.py | 4 +--- .../changelog.d/jichuanh-drop-mujoco-deps.rst | 8 ++++++++ source/isaaclab_physx/setup.py | 2 +- .../changelog.d/jichuanh-drop-mujoco-deps.rst | 8 ++++++++ source/isaaclab_visualizers/setup.py | 13 ++++++++++--- tools/wheel_builder/res/python_packages.toml | 4 +--- 9 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-drop-mujoco-deps.rst create mode 100644 source/isaaclab_newton/changelog.d/jichuanh-drop-mujoco-deps.rst create mode 100644 source/isaaclab_physx/changelog.d/jichuanh-drop-mujoco-deps.rst create mode 100644 source/isaaclab_visualizers/changelog.d/jichuanh-drop-mujoco-deps.rst diff --git a/source/isaaclab/changelog.d/jichuanh-drop-mujoco-deps.rst b/source/isaaclab/changelog.d/jichuanh-drop-mujoco-deps.rst new file mode 100644 index 000000000000..67e2eef5aa2a --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-drop-mujoco-deps.rst @@ -0,0 +1,8 @@ +Removed +^^^^^^^ + +* Removed explicit ``mujoco`` and ``mujoco-warp`` dependencies from + :mod:`isaaclab`. These packages are not used by ``isaaclab`` core and are + now resolved transitively through Newton's ``[sim]`` extra in + :mod:`isaaclab_newton`. Users installing only the PhysX or Kit backends no + longer pull in MuJoCo. diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index 94479116a24f..1f3be503574e 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -30,12 +30,10 @@ # procedural-generation "trimesh", "pyglet>=2.1.6,<3", - "mujoco==3.8.0", - "mujoco-warp==3.8.0.2", # image processing "transformers==4.57.6", "einops", # needed for transformers, doesn't always auto-install - "warp-lang>=1.13.0", + "warp-lang==1.13.0", "matplotlib>=3.10.3", # minimum version for Python 3.12 support # make sure this is consistent with isaac sim version "pillow==12.1.1", diff --git a/source/isaaclab_newton/changelog.d/jichuanh-drop-mujoco-deps.rst b/source/isaaclab_newton/changelog.d/jichuanh-drop-mujoco-deps.rst new file mode 100644 index 000000000000..152b6744d80a --- /dev/null +++ b/source/isaaclab_newton/changelog.d/jichuanh-drop-mujoco-deps.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Switched the Newton install to ``newton[sim]`` so that ``mujoco`` and + ``mujoco-warp`` are pulled in transitively via Newton's ``[sim]`` extra. + The explicit ``mujoco==3.8.0`` and ``mujoco-warp==3.8.0.1`` pins were + removed from :mod:`isaaclab_newton` — Newton is now the single source of + truth for those versions. diff --git a/source/isaaclab_newton/setup.py b/source/isaaclab_newton/setup.py index ad480ef9697d..4c4a43633b9f 100644 --- a/source/isaaclab_newton/setup.py +++ b/source/isaaclab_newton/setup.py @@ -38,10 +38,8 @@ def run(self): EXTRAS_REQUIRE = { "all": [ "prettytable==3.3.0", - "mujoco==3.8.0", - "mujoco-warp==3.8.0.2", "PyOpenGL-accelerate==3.1.10", - "newton==1.2.0rc3", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", ], } diff --git a/source/isaaclab_physx/changelog.d/jichuanh-drop-mujoco-deps.rst b/source/isaaclab_physx/changelog.d/jichuanh-drop-mujoco-deps.rst new file mode 100644 index 000000000000..24821359c927 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/jichuanh-drop-mujoco-deps.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Switched the Newton install spec to ``newton[sim]`` in the ``newton`` + extra so the MuJoCo solver dependencies are pulled in transitively. + Required because pip resolves a git-URL requirement once for the URL; + a bare ``newton @ git+...`` here would shadow the ``[sim]`` extra + requested elsewhere. diff --git a/source/isaaclab_physx/setup.py b/source/isaaclab_physx/setup.py index 77611a3ee365..09fc76bdac69 100644 --- a/source/isaaclab_physx/setup.py +++ b/source/isaaclab_physx/setup.py @@ -20,7 +20,7 @@ EXTRAS_REQUIRE = { "newton": [ - "newton==1.2.0rc3", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", ], } diff --git a/source/isaaclab_visualizers/changelog.d/jichuanh-drop-mujoco-deps.rst b/source/isaaclab_visualizers/changelog.d/jichuanh-drop-mujoco-deps.rst new file mode 100644 index 000000000000..043188cbee35 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/jichuanh-drop-mujoco-deps.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Switched the Newton install spec to ``newton[sim]`` in the ``newton``, + ``rerun``, and ``viser`` extras so the MuJoCo solver dependencies are + pulled in transitively. Required because pip resolves a git-URL + requirement once for the URL; a bare ``newton @ git+...`` here would + shadow the ``[sim]`` extra requested elsewhere. diff --git a/source/isaaclab_visualizers/setup.py b/source/isaaclab_visualizers/setup.py index 78269a201fea..008fe15c8d6c 100644 --- a/source/isaaclab_visualizers/setup.py +++ b/source/isaaclab_visualizers/setup.py @@ -13,20 +13,27 @@ "numpy", ] +# Every Newton declaration in the repo must use the SAME extra spec (`newton[sim]`). +# Pip resolves a git-URL requirement once per URL: if any package declares bare +# `newton @ git+...` while another declares `newton[sim] @ git+...`, the first +# resolution wins and silently drops the `[sim]` extra. That breaks `isaaclab_newton` +# at import time because `mujoco` / `mujoco-warp` go missing. So even the rerun/viser +# extras — which don't use the MuJoCo solver directly — must pin `newton[sim]` to +# stay consistent with `isaaclab_newton`. EXTRAS_REQUIRE = { "kit": [], "newton": [ "warp-lang", - "newton==1.2.0rc3", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", "PyOpenGL-accelerate", "imgui-bundle>=1.92.5", ], "rerun": [ - "newton==1.2.0rc3", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", "rerun-sdk>=0.29.0", ], "viser": [ - "newton==1.2.0rc3", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", "viser>=1.0.16", ], } diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index da477961d03c..369c9ef899e1 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -87,9 +87,7 @@ pyproject.optional-dependencies.all = [ # ================================================================================ { "newton" = [ "warp-lang==1.13.0", - "mujoco==3.8.0", - "mujoco-warp==3.8.0.2", - "newton==1.2.0rc3", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", "PyOpenGL-accelerate==3.1.10" ] }, # ================================================================================ From 0c497773fd8b923a2818b2fa553e80aed16bbce3 Mon Sep 17 00:00:00 2001 From: hujc Date: Wed, 13 May 2026 19:57:40 -0700 Subject: [PATCH 058/133] [Newton] Add Shadow-Hand-Over MAPPO Newton backend (depends on #5433) (#5437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds Newton backend support for \`Isaac-Shadow-Hand-Over-Direct-v0\` (multi-agent MAPPO/IPPO). Selectable via \`--preset newton\` / Hydra preset resolution; PhysX path unchanged. The headline calibration finding: Newton needs \`ImplicitActuatorCfg(stiffness=20.0, damping=2.0)\` on the Shadow Hand fingers — vs PhysX's \`1.0/0.1\` on fingers and \`5.0/0.5\` on wrists — because PhysX layers \`fixed_tendons_props(limit_stiffness=30, damping=0.1)\` and runs \`solver_position_iteration_count=8\` per substep. Both amplify the effective torque per unit nominal gain. Newton's MJWarp implicit-PD path has neither, so larger nominal gains are needed for comparable joint authority. With the bump, MAPPO mean reward at iter 200 / 2048 envs goes from ~27 (no catch learned) to ~777, vs the PhysX baseline of ~247. ## Stack Builds on top of: - **#5433** [Newton] Rename per-env labels in physics replication — required for Shadow Hand fixed tendons to parse correctly under Newton. After #5433 merges, this PR's diff vs develop drops to the 3 shadow-hand-specific files. ## What this PR does ### Newton port wiring (\`shadow_hand_over_env_cfg.py\`) The Newton variant of the Shadow Hand articulation is built as a **delta of the single-agent \`ShadowHandRobotCfg.newton_mjwarp\`** (cross-task import from \`direct/shadow_hand\`), parameterized per-robot \`prim_path\` / \`init_pos\` / \`init_rot\`. Reuses the single-agent's USD path, \`rot\` reapplication workaround, effort limits, and joint regex. Two \`ImplicitActuatorCfg\` overrides on top of the single-agent cfg: * **\`fingers\`** (wrist + per-finger joints): \`stiffness=20.0\` / \`damping=2.0\`. The catch-task gain calibration fix. * **\`distal_passive\`** (the four \`robot0_(FF|MF|RF|LF)J0\` joints): \`stiffness=10.0\` / \`damping=0.1\`. The Newton USD bakes \`stiffness=286 / damping=57\` on these joints from the MJCF→USD translation (which fights the \`MjcTendon\` coupling and bounces the ball). \`stiffness=10\` keeps the joints near-passive while the tendon constraint dominates. PhysX uses tendon coupling on these joints directly and does not need an analogous override. \`PresetCfg\` subclasses follow the established \`physx\`/\`newton_mjwarp\`/\`default=physx\` pattern — same shape as the single-agent Shadow Hand port already on develop. Newton \`ObjectCfg\` drops PhysX-only \`rigid_props\` knobs (per-shape solver iterations, sleep thresholds, max depenetration velocity, custom physics material). Newton scene cloning sets \`clone_in_fabric=False\`. ### Backend portability fix (\`shadow_hand_over_env.py\`) One line: \`self.right_hand.root_view.get_dof_limits()\` → \`self.right_hand.data.joint_limits\`. \`root_view\` is PhysX-only; \`data.joint_limits\` is the backend-portable accessor available on both PhysX and Newton articulations. ### Drift alignment with develop * Newton-preset slot name \`newton_mjwarp\` (matches develop's current convention). * \`PhysxCfg(bounce_threshold_velocity=0.2, gpu_max_rigid_contact_count=2**23, gpu_max_rigid_patch_count=2**23)\` — matches single-agent Shadow Hand's contact-buffer sizing for 2048-env scale. ## Numbers (200 iter / 2048 envs / seed 42 / MAPPO) Captured from tfevents in \`~/workspaces/IsaacLab/logs/skrl/shadow_hand_over/\`: | Setting | Stiffness | Damping | Reward (mean) | Catch learned? | |---|---:|---:|---:|:---:| | PhysX baseline | 1.0 (5.0 wrist) | 0.1 (0.5 wrist) + tendon=30/0.1 | **246.7** | yes | | Newton, develop default | 1.0 | 0.1 | 23.4 | **no** | | Newton, pinned default | 1.0 | 0.1 | 27.7 | **no** | | Newton, h1 probe | 50.0 | 5.0 | 617.8 | yes | | **Newton, this PR** | **20.0** | **2.0** | **777.1** | yes | 20/2 was chosen because it stays closer to the nominal effort-limit budget while still providing enough control authority. Anything in roughly \`[10, 50]\` works. ## Test plan - [x] PhysX 200-iter baseline at 2048 envs (mean 246.7). - [x] Newton 200-iter at stiffness 20/2 — mean 777.1, catch learned. - [x] Newton 200-iter at stiffness 50/5 cross-check — mean 617.8, catch learned. - [x] \`./isaaclab.sh -f\` clean (pre-commit hooks). - [x] Changelog fragment under \`source/isaaclab_tasks/changelog.d/jichuanh-shadow-hand-newton-parity.minor.rst\` (CI-driven version bump on merge; no per-PR \`extension.toml\` edit). ## Out of scope (follow-ups) - **\`EventCfg\` not wired into the env class.** The multi-agent's \`EventCfg\` is defined but never referenced by \`ShadowHandOverEnvCfg.events\` — single-agent's pattern (\`events: ShadowHandEventCfg = ShadowHandEventCfg()\` with PhysX/Newton variants) hasn't been ported. Independent feature add; not a parity blocker. - **Migrating Shadow Hand Newton USD to the \`mujoco-usd-converter\`-produced asset in \`newton-physics/newton-assets/shadow_hand/usd_structured/\`.** That asset uses \`MjcActuator + MjcTendon\` natively (no baked stiffness=286 problem). Switching would let the \`distal_passive\` override be deleted. Requires Nucleus/S3 asset migration + matching the right-hand asset (newton-assets has only left); separate work. - **Behavior-level parity** beyond shaped reward (catch rate, drop rate, ball-trajectory smoothness) is left for a follow-up evaluation. --- .../jichuanh-newton-replicate-tendon-fix.rst | 14 + .../cloner/newton_replicate.py | 59 +++- .../test/cloner/test_rename_builder_labels.py | 281 ++++++++++++++++++ ...chuanh-shadow-hand-newton-parity.minor.rst | 16 + .../shadow_hand_over/shadow_hand_over_env.py | 3 +- .../shadow_hand_over_env_cfg.py | 201 ++++++++++--- 6 files changed, 526 insertions(+), 48 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst create mode 100644 source/isaaclab_newton/test/cloner/test_rename_builder_labels.py create mode 100644 source/isaaclab_tasks/changelog.d/jichuanh-shadow-hand-newton-parity.minor.rst diff --git a/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst b/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst new file mode 100644 index 000000000000..12a62ab4d414 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst @@ -0,0 +1,14 @@ +Fixed +^^^^^ + +* Fixed per-environment string identifiers (e.g. ``mujoco:tendon_label``) + keeping the source proto path after replication. + :func:`~isaaclab_newton.cloner.newton_replicate._rename_builder_labels` + now also walks string-typed custom-attribute columns whose frequency + declares a ``references="world"`` companion, rewriting their per-row + source-path prefix to the destination world root in the same pass that + handles built-in label arrays. Adds ``constraint_mimic`` and + ``equality_constraint`` to that built-in pass for completeness. The + prefix match uses a path-separator boundary so a source path that is a + string prefix of another (e.g. ``/Sources/protoA`` vs + ``/Sources/protoAB``) does not cross-contaminate during the rename. diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py index 544756858d51..46d4f967d51f 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py @@ -127,6 +127,15 @@ def _rename_builder_labels( ) -> None: """Rename builder labels/keys from source roots to destination roots. + Walks both built-in label arrays (``body``, ``joint``, ``shape``, + ``articulation``, ``constraint_mimic``, ``equality_constraint``) and any + string-typed custom-attribute column whose frequency declares a sibling + world column (``references="world"``). + The ``startswith(src_prefix)`` guard makes the rewrite a no-op for strings that + are not paths under the source, so non-path custom string columns are passed + through untouched and any future solver-registered string column is handled + automatically without changes here. + Args: builder: Newton model builder to update in-place. sources: Source prim root paths. @@ -136,21 +145,55 @@ def _rename_builder_labels( """ # per-source, per-world renaming (strict prefix swap), compact style preserved for i, src_path in enumerate(sources): - src_prefix_len = len(src_path.rstrip("/")) + # Boundary-terminated prefix prevents over-matching when one source path is a + # prefix of another (e.g. ``/Sources/protoA`` vs ``/Sources/protoAB``). + src_prefix = src_path.rstrip("/") + "/" + src_prefix_len = len(src_prefix) - 1 # slice index keeps the leading "/" in the suffix swap = lambda name, new_root: new_root + name[src_prefix_len:] # noqa: E731 world_cols = torch.nonzero(mapping[i], as_tuple=True)[0].tolist() # Map Newton world IDs (sequential) to destination paths using env_ids world_roots = {int(env_ids[c]): destinations[i].format(int(env_ids[c])) for c in world_cols} - for t in ("body", "joint", "shape", "articulation"): + def _rename_pair(values, worlds): + if len(values) != len(worlds): + raise ValueError(f"label/world column length mismatch: {len(values)} vs {len(worlds)}") + for k in range(len(values)): + world_id = int(worlds[k]) + if world_id in world_roots and isinstance(values[k], str) and values[k].startswith(src_prefix): + values[k] = swap(values[k], world_roots[world_id]) + + # Pass 1: built-in label arrays. Each has a paired ``*_world`` int column. + # Use ``is None`` (not ``or``) so an empty-but-defined ``*_label`` column + # is recognized — falling through to ``*_key`` would over-match a + # builder that legitimately exposes both attributes. + for t in ("body", "joint", "shape", "articulation", "constraint_mimic", "equality_constraint"): labels = getattr(builder, f"{t}_label", None) if labels is None: - labels = getattr(builder, f"{t}_key") - worlds_arr = getattr(builder, f"{t}_world") - for k, w in enumerate(worlds_arr): - world_id = int(w) - if world_id in world_roots and labels[k].startswith(src_path): - labels[k] = swap(labels[k], world_roots[world_id]) + labels = getattr(builder, f"{t}_key", None) + worlds_arr = getattr(builder, f"{t}_world", None) + if labels is None or worlds_arr is None: + continue + _rename_pair(labels, worlds_arr) + + # Pass 2: string-typed custom-attribute columns (e.g. ``mujoco:tendon_label``) + # paired with a world companion declared via ``references="world"``. Index + # world companions by frequency for O(1) lookup, then walk the str columns. + custom = builder.custom_attributes + world_by_freq: dict[str, ModelBuilder.CustomAttribute] = {} + for attr in custom.values(): + if getattr(attr, "references", None) == "world": + world_by_freq[attr.frequency] = attr + for attr in custom.values(): + if attr.dtype is not str: + continue + world_attr = world_by_freq.get(attr.frequency) + if world_attr is None: + continue + values = attr.values + worlds = world_attr.values + if not values or not worlds: + continue + _rename_pair(values, worlds) def newton_physics_replicate( diff --git a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py new file mode 100644 index 000000000000..2fe930f8b520 --- /dev/null +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -0,0 +1,281 @@ +# 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 ``_rename_builder_labels``. + +Covers both passes of the rewrite: + + * Pass 1 — built-in label arrays (``body``, ``joint``, ``shape``, + ``articulation``, ``constraint_mimic``, ``equality_constraint``). + * Pass 2 — any string-typed custom-attribute column whose frequency declares a + sibling ``references="world"`` companion (e.g. ``mujoco:tendon_label``). + +The contract under test: every label whose row maps to a world in ``env_ids`` +and whose value starts with the source root is rewritten to the destination +template's per-env path; everything else is left alone. +""" + +import unittest + +import newton +import torch +from isaaclab_newton.cloner.newton_replicate import _rename_builder_labels +from newton.solvers import SolverMuJoCo + +_TENDON_FREQ = "mujoco:tendon" +_SRC = "/Sources/protoA" +_DST = "/World/envs/env_{}" + + +# ─── helpers ───────────────────────────────────────────────────────────────── + + +def _inject_builtins(builder: newton.ModelBuilder, types: tuple[str, ...], src_path: str, worlds: list[int]) -> None: + """Append ``len(worlds)`` synthetic entries to each built-in ``*_label``/``*_world`` pair.""" + for t in types: + labels = getattr(builder, f"{t}_label") + worlds_arr = getattr(builder, f"{t}_world") + for w in worlds: + labels.append(f"{src_path}/{t}_{w}") + worlds_arr.append(w) + + +def _inject_tendon_strings(builder: newton.ModelBuilder, src_path: str, worlds: list[int]) -> None: + """Append synthetic ``mujoco:tendon_label`` + ``mujoco:tendon_world`` rows.""" + label_attr = builder.custom_attributes["mujoco:tendon_label"] + world_attr = builder.custom_attributes["mujoco:tendon_world"] + if label_attr.values is None: + label_attr.values = [] + if world_attr.values is None: + world_attr.values = [] + for w in worlds: + label_attr.values.append(f"{src_path}/Tendon_{w}") + world_attr.values.append(w) + builder._custom_frequency_counts[_TENDON_FREQ] = builder._custom_frequency_counts.get(_TENDON_FREQ, 0) + len(worlds) + + +def _make_builder_with_entries(worlds: list[int]) -> newton.ModelBuilder: + """Builder pre-populated with one row per world for every label class under test.""" + b = newton.ModelBuilder() + SolverMuJoCo.register_custom_attributes(b) + _inject_builtins( + b, ("body", "joint", "shape", "articulation", "constraint_mimic", "equality_constraint"), _SRC, worlds + ) + _inject_tendon_strings(b, _SRC, worlds) + return b + + +# ─── tests ─────────────────────────────────────────────────────────────────── + + +class TestRenameBuilderLabels(unittest.TestCase): + """Both passes rewrite to the same per-env destination pattern.""" + + def setUp(self): + self.worlds = [0, 1, 2] + self.env_ids = torch.tensor(self.worlds, dtype=torch.int32) + self.mapping = torch.ones(1, len(self.worlds), dtype=torch.bool) + + def _rename(self, builder): + _rename_builder_labels(builder, [_SRC], [_DST], self.env_ids, self.mapping) + + # Pass 1 --------------------------------------------------------------- + + def test_builtin_labels_rewritten_per_world(self): + b = _make_builder_with_entries(self.worlds) + self._rename(b) + for t in ("body", "joint", "shape", "articulation", "constraint_mimic", "equality_constraint"): + labels = getattr(b, f"{t}_label") + worlds_arr = getattr(b, f"{t}_world") + for k, w in enumerate(worlds_arr): + self.assertEqual( + labels[k], + f"{_DST.format(int(w))}/{t}_{int(w)}", + msg=f"{t}_label[{k}] not rewritten correctly", + ) + + # Pass 2 --------------------------------------------------------------- + + def test_tendon_label_string_custom_attr_rewritten(self): + b = _make_builder_with_entries(self.worlds) + self._rename(b) + labels = b.custom_attributes["mujoco:tendon_label"].values + worlds_arr = b.custom_attributes["mujoco:tendon_world"].values + for k, w in enumerate(worlds_arr): + self.assertEqual(labels[k], f"{_DST.format(int(w))}/Tendon_{int(w)}") + + # Cross-pass consistency ---------------------------------------------- + + def test_all_renamed_labels_share_the_per_env_root(self): + """Every label written by either pass must live under ``/World/envs/env_/``.""" + b = _make_builder_with_entries(self.worlds) + self._rename(b) + per_world = {int(w): _DST.format(int(w)) + "/" for w in self.env_ids.tolist()} + for t in ("body", "joint", "shape", "articulation", "constraint_mimic", "equality_constraint"): + for label, w in zip(getattr(b, f"{t}_label"), getattr(b, f"{t}_world")): + self.assertTrue(label.startswith(per_world[int(w)]), msg=f"{t}: {label!r}") + tendon_labels = b.custom_attributes["mujoco:tendon_label"].values + tendon_worlds = b.custom_attributes["mujoco:tendon_world"].values + for label, w in zip(tendon_labels, tendon_worlds): + self.assertTrue(label.startswith(per_world[int(w)]), msg=f"tendon: {label!r}") + + # Guards --------------------------------------------------------------- + + def test_non_path_string_left_untouched(self): + """Strings that don't start with ``src_path`` must pass through unchanged.""" + b = _make_builder_with_entries(self.worlds) + # Inject one tendon row whose label is an opaque identifier, not a path. + b.custom_attributes["mujoco:tendon_label"].values.append("named_tendon") + b.custom_attributes["mujoco:tendon_world"].values.append(self.worlds[0]) + self._rename(b) + self.assertEqual(b.custom_attributes["mujoco:tendon_label"].values[-1], "named_tendon") + + def test_world_outside_env_ids_left_untouched(self): + """A row whose world is not in ``env_ids`` must keep its original label.""" + b = _make_builder_with_entries(self.worlds) + # Inject one extra row tagged with a world id not present in env_ids. + b.body_label.append(f"{_SRC}/body_99") + b.body_world.append(99) + self._rename(b) + self.assertEqual(b.body_label[-1], f"{_SRC}/body_99") + + def test_sparse_env_ids(self): + """Non-contiguous ``env_ids`` (e.g. [10, 20, 30]) must rewrite using the right per-env root.""" + worlds = [10, 20, 30] + b = newton.ModelBuilder() + SolverMuJoCo.register_custom_attributes(b) + _inject_builtins(b, ("body",), _SRC, worlds) + env_ids = torch.tensor(worlds, dtype=torch.int32) + mapping = torch.ones(1, len(worlds), dtype=torch.bool) + _rename_builder_labels(b, [_SRC], [_DST], env_ids, mapping) + for k, w in enumerate(b.body_world): + self.assertEqual(b.body_label[k], f"/World/envs/env_{int(w)}/body_{int(w)}") + + +class TestRenamePass2Generality(unittest.TestCase): + """Pass 2 must generalize across coexisting frequencies and multiple string columns.""" + + def setUp(self): + self.worlds = [0, 1] + self.env_ids = torch.tensor(self.worlds, dtype=torch.int32) + self.mapping = torch.ones(1, len(self.worlds), dtype=torch.bool) + + def _register_synthetic_freq(self, builder, freq_name, world_attr_name, str_attr_names): + """Register a ``syn:`` frequency with one world int column and N string columns.""" + freq = f"syn:{freq_name}" + builder.add_custom_frequency(newton.ModelBuilder.CustomFrequency(name=freq_name, namespace="syn")) + builder.add_custom_attribute( + newton.ModelBuilder.CustomAttribute( + name=world_attr_name, + frequency=freq, + dtype=int, + default=0, + namespace="syn", + references="world", + ) + ) + for n in str_attr_names: + builder.add_custom_attribute( + newton.ModelBuilder.CustomAttribute( + name=n, + frequency=freq, + dtype=str, + default="", + namespace="syn", + ) + ) + + def _populate(self, builder, freq, world_attr_name, str_attr_names, worlds): + wa = builder.custom_attributes[f"syn:{world_attr_name}"] + if wa.values is None: + wa.values = [] + for w in worlds: + wa.values.append(w) + for n in str_attr_names: + sa = builder.custom_attributes[f"syn:{n}"] + if sa.values is None: + sa.values = [] + for w in worlds: + sa.values.append(f"{_SRC}/{n}_{w}") + builder._custom_frequency_counts[freq] = builder._custom_frequency_counts.get(freq, 0) + len(worlds) + + def test_two_coexisting_custom_frequencies(self): + """Each registered ``references='world'`` companion must drive its own frequency's str columns.""" + b = newton.ModelBuilder() + self._register_synthetic_freq(b, "freqA", "freqA_world", ["freqA_label"]) + self._register_synthetic_freq(b, "freqB", "freqB_world", ["freqB_label"]) + self._populate(b, "syn:freqA", "freqA_world", ["freqA_label"], self.worlds) + self._populate(b, "syn:freqB", "freqB_world", ["freqB_label"], self.worlds) + _rename_builder_labels(b, [_SRC], [_DST], self.env_ids, self.mapping) + for n in ("freqA_label", "freqB_label"): + wa = b.custom_attributes[f"syn:{n.split('_')[0]}_world"].values + sa = b.custom_attributes[f"syn:{n}"].values + for k, w in enumerate(wa): + self.assertEqual(sa[k], f"/World/envs/env_{int(w)}/{n}_{int(w)}") + + def test_multiple_string_columns_at_one_frequency(self): + """Two str columns sharing one frequency must both be rewritten using the shared world companion.""" + b = newton.ModelBuilder() + self._register_synthetic_freq(b, "freqA", "freqA_world", ["freqA_label", "freqA_alt"]) + self._populate(b, "syn:freqA", "freqA_world", ["freqA_label", "freqA_alt"], self.worlds) + _rename_builder_labels(b, [_SRC], [_DST], self.env_ids, self.mapping) + wa = b.custom_attributes["syn:freqA_world"].values + for n in ("freqA_label", "freqA_alt"): + sa = b.custom_attributes[f"syn:{n}"].values + for k, w in enumerate(wa): + self.assertEqual(sa[k], f"/World/envs/env_{int(w)}/{n}_{int(w)}") + + def test_empty_values_pass_through(self): + """A registered-but-empty string column must not crash the rename pass.""" + b = newton.ModelBuilder() + self._register_synthetic_freq(b, "freqA", "freqA_world", ["freqA_label"]) + # values stay None (registered, never populated) + _rename_builder_labels(b, [_SRC], [_DST], self.env_ids, self.mapping) + # Fully populate after the no-op rename: ensures the early-return guard didn't corrupt state. + self._populate(b, "syn:freqA", "freqA_world", ["freqA_label"], self.worlds) + self.assertEqual(len(b.custom_attributes["syn:freqA_label"].values), len(self.worlds)) + + +class TestRenameMultiSource(unittest.TestCase): + """Multi-source handling must not cross-contaminate when source paths share a string prefix.""" + + def test_prefix_overlap_does_not_cross_contaminate(self): + """Sources whose paths share a string prefix and that both feed the same envs must not cross-rename. + + Common IL pattern: a robot proto and an object proto both feed every env. If the two source + paths share a string prefix (``/Sources/protoA`` and ``/Sources/protoAB``), iter 0 + (``src=protoA``) sees the protoAB rows for the same world ids it owns and would over-match + them under a non-boundary ``startswith``. The world-id guard alone does not catch this case + because both sources contribute to the same set of worlds. + """ + sources = ["/Sources/protoA", "/Sources/protoAB"] + # 2 envs, both fed by both sources. + env_ids = torch.tensor([0, 1], dtype=torch.int32) + mapping = torch.tensor([[1, 1], [1, 1]], dtype=torch.bool) + b = newton.ModelBuilder() + SolverMuJoCo.register_custom_attributes(b) + # One body row from each source per env: 4 rows total, world ids interleaved. + b.body_label.extend( + [ + f"{sources[0]}/body", # row 0: protoA, world 0 + f"{sources[1]}/body", # row 1: protoAB, world 0 + f"{sources[0]}/body", # row 2: protoA, world 1 + f"{sources[1]}/body", # row 3: protoAB, world 1 + ] + ) + b.body_world.extend([0, 0, 1, 1]) + _rename_builder_labels(b, sources, ["/World/envs/env_{}", "/World/envs/env_{}"], env_ids, mapping) + # Each row must end up under its own per-env root with the suffix preserved verbatim. + # Without the "/" boundary on ``startswith``, iter 0 (src=protoA) would match rows 1 and 3 + # because ``/Sources/protoAB/body``.startswith(``/Sources/protoA``) is True, rewriting them + # to ``/World/envs/env_/B/body`` (wrong suffix). + self.assertEqual(b.body_label[0], "/World/envs/env_0/body") + self.assertEqual(b.body_label[1], "/World/envs/env_0/body") + self.assertEqual(b.body_label[2], "/World/envs/env_1/body") + self.assertEqual(b.body_label[3], "/World/envs/env_1/body") + + +if __name__ == "__main__": + unittest.main() diff --git a/source/isaaclab_tasks/changelog.d/jichuanh-shadow-hand-newton-parity.minor.rst b/source/isaaclab_tasks/changelog.d/jichuanh-shadow-hand-newton-parity.minor.rst new file mode 100644 index 000000000000..2450c041c6dc --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/jichuanh-shadow-hand-newton-parity.minor.rst @@ -0,0 +1,16 @@ +Added +^^^^^ + +* Added Newton backend support for the multi-agent + ``Isaac-Shadow-Hand-Over-Direct-v0`` (MAPPO/IPPO) env. Mirrors the + single-agent Shadow Hand Newton port: per-hand + :class:`~isaaclab.actuators.ImplicitActuatorCfg`, + ``shadow_hand_instanceable_newton.usd``, per-backend + :class:`~isaaclab_tasks.utils.PresetCfg` wrappers for sim physics, the + hand-over object (``RigidObjectCfg`` on both backends, dropping + PhysX-only knobs on Newton), and the two robot configs. Selectable via + ``--preset newton`` / Hydra preset resolution; PhysX behavior unchanged. + Migration details (Newton-side actuator gain overrides for ``fingers`` + and ``distal_passive``, and the ``ccd_iterations`` bump for multi-finger + contacts) live in + ``source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env_cfg.py``. diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env.py b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env.py index a692253cf2fb..66b9012d036b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env.py @@ -10,7 +10,6 @@ import numpy as np import torch -import warp as wp import isaaclab.sim as sim_utils from isaaclab.assets import Articulation, RigidObject @@ -64,7 +63,7 @@ def __init__(self, cfg: ShadowHandOverEnvCfg, render_mode: str | None = None, ** self.num_fingertips = len(self.finger_bodies) # joint limits - joint_pos_limits = wp.to_torch(self.right_hand.root_view.get_dof_limits()).to(self.device) + joint_pos_limits = self.right_hand.data.joint_limits.torch.to(self.device) self.hand_dof_lower_limits = joint_pos_limits[..., 0] self.hand_dof_upper_limits = joint_pos_limits[..., 1] diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env_cfg.py index acf37a54c4b0..e2d8bdbd8d6d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env_cfg.py @@ -3,10 +3,12 @@ # # SPDX-License-Identifier: BSD-3-Clause +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.envs.mdp as mdp import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.assets import ArticulationCfg, RigidObjectCfg from isaaclab.envs import DirectMARLEnvCfg from isaaclab.managers import EventTermCfg as EventTerm @@ -17,12 +19,21 @@ from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg from isaaclab.utils import configclass +from isaaclab_tasks.direct.shadow_hand.shadow_hand_env_cfg import ShadowHandRobotCfg +from isaaclab_tasks.utils import PresetCfg, preset + from isaaclab_assets.robots.shadow_hand import SHADOW_HAND_CFG @configclass class EventCfg: - """Configuration for randomization.""" + """Configuration for randomization (PhysX path). + + Note: this config is currently not wired into ``ShadowHandOverEnvCfg.events`` - + it is kept as a reference for future event-randomization work. The event + terms here use PhysX-only APIs (rigid-body materials, fixed tendons), so + they would need a Newton variant before being enabled in the env. + """ # -- robot robot_physics_material = EventTerm( @@ -113,6 +124,146 @@ class EventCfg: ) +# Reuse the single-agent Shadow Hand Newton port (USD path, ``rot`` reapplication +# workaround, effort limits, joint regex). The multi-agent variant only diverges +# in actuator gains (stiffness/damping bumped for the catch task) and adds a +# ``distal_passive`` override for the J0 USD-baked values. +_SHADOW_HAND_NEWTON_CFG = ShadowHandRobotCfg().newton_mjwarp + + +def _shadow_hand_cfg( + prim_path: str, + init_pos: tuple[float, float, float], + init_rot: tuple[float, float, float, float], +) -> PresetCfg: + """Per-hand Shadow Hand preset (PhysX and Newton MJWarp variants). + + Both variants are placed at *prim_path* with the same init pose; per-hand + differences (right vs left) come from the caller's *prim_path* / *init_pos* / + *init_rot* — the gain tuning is identical on both hands. + + The Newton variant layers two :class:`~isaaclab.actuators.ImplicitActuatorCfg` + overrides on top of the single-agent Newton port: + + * ``fingers`` actuator: ``stiffness=20.0`` / ``damping=2.0`` (vs PhysX's + ``5.0`` / ``0.5`` on wrists and ``1.0`` / ``0.1`` on fingers). PhysX layers + ``fixed_tendons_props(limit_stiffness=30, damping=0.1)`` and runs + ``solver_position_iteration_count=8`` per substep — both amplify the + effective torque per unit nominal gain. Newton's MJWarp implicit-PD path + has neither, so a larger nominal gain is needed for comparable joint + authority. ``20.0`` / ``2.0`` is the smallest tested setting at which + MAPPO learns the catch (mean reward at iter 200 / 2048 envs goes from + ~27 at PhysX-mirrored gains to ~777). + * ``distal_passive`` on the four ``robot0_(FF|MF|RF|LF)J0`` joints with + ``stiffness=10.0`` / ``damping=0.1``. The Newton USD bakes + ``stiffness=286 / damping=57`` on these joints from the MJCF→USD + translation, which fights the ``MjcTendon`` coupling and bounces the + ball. ``stiffness=10`` (~1/3 of PhysX's ``limit_stiffness=30``) keeps + the joints near-passive while the tendon constraint dominates. + """ + physx_cfg = SHADOW_HAND_CFG.replace(prim_path=prim_path).replace( + init_state=ArticulationCfg.InitialStateCfg(pos=init_pos, rot=init_rot, joint_pos={".*": 0.0}) + ) + newton_cfg = _SHADOW_HAND_NEWTON_CFG.replace( + prim_path=prim_path, + init_state=_SHADOW_HAND_NEWTON_CFG.init_state.replace(pos=init_pos, rot=init_rot), + actuators={ + "fingers": _SHADOW_HAND_NEWTON_CFG.actuators["fingers"].replace(stiffness=20.0, damping=2.0), + "distal_passive": ImplicitActuatorCfg( + joint_names_expr=["robot0_(FF|MF|RF|LF)J0"], + stiffness=10.0, + damping=0.1, + friction=1e-2, + armature=2e-3, + ), + }, + ) + return preset(default=physx_cfg, physx=physx_cfg, newton_mjwarp=newton_cfg) + + +@configclass +class ObjectCfg(PresetCfg): + """Hand-over object preset. + + Both backends spawn the same procedural sphere as a free rigid body: + Newton's :class:`~isaaclab_newton.assets.RigidObject` resolves the + asset via the ``UsdPhysics.RigidBodyAPI`` that + :class:`~isaaclab.sim.RigidBodyPropertiesCfg` applies. The Newton + variant drops PhysX-only knobs (per-shape solver iterations, sleep + thresholds, max depenetration velocity, custom physics material). + """ + + physx = RigidObjectCfg( + prim_path="/World/envs/env_.*/object", + spawn=sim_utils.SphereCfg( + radius=0.0335, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.8, 1.0, 0.0)), + physics_material=sim_utils.RigidBodyMaterialCfg(static_friction=0.7), + rigid_props=sim_utils.RigidBodyPropertiesCfg( + kinematic_enabled=False, + disable_gravity=False, + enable_gyroscopic_forces=True, + solver_position_iteration_count=8, + solver_velocity_iteration_count=0, + sleep_threshold=0.005, + stabilization_threshold=0.0025, + max_depenetration_velocity=1000.0, + ), + collision_props=sim_utils.CollisionPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(density=500.0), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, -0.39, 0.54), rot=(0.0, 0.0, 0.0, 1.0)), + ) + newton_mjwarp = RigidObjectCfg( + prim_path="/World/envs/env_.*/object", + spawn=sim_utils.SphereCfg( + radius=0.0335, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.8, 1.0, 0.0)), + rigid_props=sim_utils.RigidBodyPropertiesCfg( + kinematic_enabled=False, + disable_gravity=False, + enable_gyroscopic_forces=True, + ), + collision_props=sim_utils.CollisionPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(density=500.0), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, -0.39, 0.54), rot=(0.0, 0.0, 0.0, 1.0)), + ) + default = physx + + +@configclass +class PhysicsCfg(PresetCfg): + """Physics-backend preset (PhysX vs Newton/MJWarp). + + Newton settings mirror the single-agent ShadowHand Newton port: elliptic + cone, ``impratio=10`` (favors normal contacts over friction), 100 solver + iterations, 2 substeps. Empirically converges on the single-agent ShadowHand + tasks; tuning may be needed for handover-specific contact dynamics. + """ + + physx = PhysxCfg( + bounce_threshold_velocity=0.2, + gpu_max_rigid_contact_count=2**23, + gpu_max_rigid_patch_count=2**23, + ) + newton_mjwarp = NewtonCfg( + solver_cfg=MJWarpSolverCfg( + solver="newton", + integrator="implicitfast", + njmax=200, + nconmax=70, + impratio=10.0, + cone="elliptic", + update_data_interval=2, + ccd_iterations=50, # bumped from default 35 for multi-finger contact geometry + ), + num_substeps=2, + debug_mode=False, + ) + default = physx + + @configclass class ShadowHandOverEnvCfg(DirectMARLEnvCfg): # env @@ -131,24 +282,18 @@ class ShadowHandOverEnvCfg(DirectMARLEnvCfg): static_friction=1.0, dynamic_friction=1.0, ), - physics=PhysxCfg( - bounce_threshold_velocity=0.2, - ), + physics=PhysicsCfg(), ) # robot - right_robot_cfg: ArticulationCfg = SHADOW_HAND_CFG.replace(prim_path="/World/envs/env_.*/RightRobot").replace( - init_state=ArticulationCfg.InitialStateCfg( - pos=(0.0, 0.0, 0.5), - rot=(0.0, 0.0, 0.0, 1.0), - joint_pos={".*": 0.0}, - ) - ) - left_robot_cfg: ArticulationCfg = SHADOW_HAND_CFG.replace(prim_path="/World/envs/env_.*/LeftRobot").replace( - init_state=ArticulationCfg.InitialStateCfg( - pos=(0.0, -1.0, 0.5), - rot=(0.0, 0.0, 1.0, 0.0), - joint_pos={".*": 0.0}, - ) + right_robot_cfg: PresetCfg = _shadow_hand_cfg( + prim_path="/World/envs/env_.*/RightRobot", + init_pos=(0.0, 0.0, 0.5), + init_rot=(0.0, 0.0, 0.0, 1.0), + ) + left_robot_cfg: PresetCfg = _shadow_hand_cfg( + prim_path="/World/envs/env_.*/LeftRobot", + init_pos=(0.0, -1.0, 0.5), + init_rot=(0.0, 0.0, 1.0, 0.0), ) actuated_joint_names = [ "robot0_WRJ1", @@ -181,27 +326,7 @@ class ShadowHandOverEnvCfg(DirectMARLEnvCfg): ] # in-hand object - object_cfg: RigidObjectCfg = RigidObjectCfg( - prim_path="/World/envs/env_.*/object", - spawn=sim_utils.SphereCfg( - radius=0.0335, - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.8, 1.0, 0.0)), - physics_material=sim_utils.RigidBodyMaterialCfg(static_friction=0.7), - rigid_props=sim_utils.RigidBodyPropertiesCfg( - kinematic_enabled=False, - disable_gravity=False, - enable_gyroscopic_forces=True, - solver_position_iteration_count=8, - solver_velocity_iteration_count=0, - sleep_threshold=0.005, - stabilization_threshold=0.0025, - max_depenetration_velocity=1000.0, - ), - collision_props=sim_utils.CollisionPropertiesCfg(), - mass_props=sim_utils.MassPropertiesCfg(density=500.0), - ), - init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, -0.39, 0.54), rot=(0.0, 0.0, 0.0, 1.0)), - ) + object_cfg: ObjectCfg = ObjectCfg() # goal object goal_object_cfg: VisualizationMarkersCfg = VisualizationMarkersCfg( prim_path="/Visuals/goal_marker", From b65a1ac2b73950f4c5e5aea55568209d392bcdf9 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 06:09:20 +0000 Subject: [PATCH 059/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 5.1.1 → 5.2.0 - isaaclab_mimic: 1.2.6 → 1.2.7 - isaaclab_newton: 0.8.1 → 0.9.0 - isaaclab_ov: 0.1.8 → 0.1.9 - isaaclab_ovphysx: 0.1.4 → 1.0.0 - isaaclab_physx: 0.6.4 → 0.7.0 - isaaclab_tasks: 1.5.38 → 1.6.0 --- .../antoiner-feat-ovphysx_rigidobject.skip | 0 .../dev-scene-data-provider-api.minor.rst | 34 ----- .../changelog.d/jichuanh-drop-mujoco-deps.rst | 8 -- .../jichuanh-ik-newton-compat-mvp.minor.rst | 60 --------- ...klakhi-fix-leapp-deployment-env-extras.rst | 6 - .../changelog.d/myurasov-conda-env-pip.rst | 9 -- .../myurasov-nvbug-6125054-viser-extra.rst | 12 -- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 119 ++++++++++++++++++ .../changelog.d/fix-mimic-datagen-import.rst | 5 - source/isaaclab_mimic/config/extension.toml | 2 +- source/isaaclab_mimic/docs/CHANGELOG.rst | 10 ++ ...ca-fix-newton-contact-sensor-migration.rst | 7 -- .../dev-scene-data-provider-api.minor.rst | 31 ----- .../changelog.d/jichuanh-drop-mujoco-deps.rst | 8 -- .../jichuanh-ik-newton-compat-mvp.minor.rst | 40 ------ .../jichuanh-newton-replicate-tendon-fix.rst | 14 --- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 93 ++++++++++++++ .../dev-scene-data-provider-api.rst | 9 -- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 14 +++ ...ntoiner-feat-ovphysx_rigidobject.major.rst | 50 -------- .../jichuanh-ik-newton-compat-mvp.rst | 12 -- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 64 ++++++++++ .../dev-scene-data-provider-api.minor.rst | 19 --- .../changelog.d/jichuanh-drop-mujoco-deps.rst | 8 -- .../jichuanh-ik-newton-compat-mvp.rst | 31 ----- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 61 +++++++++ .../dev-scene-data-provider-api.rst | 13 -- .../jichuanh-ik-newton-compat-mvp.rst | 13 -- ...chuanh-shadow-hand-newton-parity.minor.rst | 16 --- .../changelog.d/rsl-rl-model-configs.rst | 5 - source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 51 ++++++++ 37 files changed, 419 insertions(+), 417 deletions(-) delete mode 100644 source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobject.skip delete mode 100644 source/isaaclab/changelog.d/dev-scene-data-provider-api.minor.rst delete mode 100644 source/isaaclab/changelog.d/jichuanh-drop-mujoco-deps.rst delete mode 100644 source/isaaclab/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst delete mode 100644 source/isaaclab/changelog.d/klakhi-fix-leapp-deployment-env-extras.rst delete mode 100644 source/isaaclab/changelog.d/myurasov-conda-env-pip.rst delete mode 100644 source/isaaclab/changelog.d/myurasov-nvbug-6125054-viser-extra.rst delete mode 100644 source/isaaclab_mimic/changelog.d/fix-mimic-datagen-import.rst delete mode 100644 source/isaaclab_newton/changelog.d/ca-fix-newton-contact-sensor-migration.rst delete mode 100644 source/isaaclab_newton/changelog.d/dev-scene-data-provider-api.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/jichuanh-drop-mujoco-deps.rst delete mode 100644 source/isaaclab_newton/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst delete mode 100644 source/isaaclab_ov/changelog.d/dev-scene-data-provider-api.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject.major.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/jichuanh-ik-newton-compat-mvp.rst delete mode 100644 source/isaaclab_physx/changelog.d/dev-scene-data-provider-api.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/jichuanh-drop-mujoco-deps.rst delete mode 100644 source/isaaclab_physx/changelog.d/jichuanh-ik-newton-compat-mvp.rst delete mode 100644 source/isaaclab_tasks/changelog.d/dev-scene-data-provider-api.rst delete mode 100644 source/isaaclab_tasks/changelog.d/jichuanh-ik-newton-compat-mvp.rst delete mode 100644 source/isaaclab_tasks/changelog.d/jichuanh-shadow-hand-newton-parity.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/rsl-rl-model-configs.rst diff --git a/source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobject.skip b/source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobject.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab/changelog.d/dev-scene-data-provider-api.minor.rst b/source/isaaclab/changelog.d/dev-scene-data-provider-api.minor.rst deleted file mode 100644 index cf94f454adbd..000000000000 --- a/source/isaaclab/changelog.d/dev-scene-data-provider-api.minor.rst +++ /dev/null @@ -1,34 +0,0 @@ -Added -^^^^^ - -* Added :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.usd_stage`, - :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.num_envs`, and - :meth:`~isaaclab.scene.scene_data_provider.SceneDataProvider.get_camera_transforms` - so visualizers and renderers can pull stage-derived data through the same - Warp-native provider that already exposes transforms. - -Changed -^^^^^^^ - -* **Breaking:** :class:`~isaaclab.visualizers.base_visualizer.BaseVisualizer` - subclasses now receive a - :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` in - :meth:`~isaaclab.visualizers.base_visualizer.BaseVisualizer.initialize` - instead of the removed ``BaseSceneDataProvider``. Read environment count - from :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.num_envs` - and call - :meth:`~isaaclab.scene.scene_data_provider.SceneDataProvider.get_camera_transforms` - on the new provider; both replace the previous ``get_metadata()`` / - ``get_camera_transforms()`` calls on the legacy interface. - -Removed -^^^^^^^ - -* **Breaking:** Removed ``isaaclab.physics.BaseSceneDataProvider``, - ``isaaclab.physics.SceneDataProvider`` (the legacy factory), - ``SimulationContext.initialize_scene_data_provider()``, and - ``SimulationContext.update_scene_data_provider()``. Use - :meth:`~isaaclab.sim.simulation_context.SimulationContext.get_scene_data_provider` - to obtain the new provider; consumers that previously called - ``get_newton_model()`` / ``get_newton_state()`` should call - ``NewtonManager.get_model()`` / ``NewtonManager.get_state()`` instead. diff --git a/source/isaaclab/changelog.d/jichuanh-drop-mujoco-deps.rst b/source/isaaclab/changelog.d/jichuanh-drop-mujoco-deps.rst deleted file mode 100644 index 67e2eef5aa2a..000000000000 --- a/source/isaaclab/changelog.d/jichuanh-drop-mujoco-deps.rst +++ /dev/null @@ -1,8 +0,0 @@ -Removed -^^^^^^^ - -* Removed explicit ``mujoco`` and ``mujoco-warp`` dependencies from - :mod:`isaaclab`. These packages are not used by ``isaaclab`` core and are - now resolved transitively through Newton's ``[sim]`` extra in - :mod:`isaaclab_newton`. Users installing only the PhysX or Kit backends no - longer pull in MuJoCo. diff --git a/source/isaaclab/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst b/source/isaaclab/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst deleted file mode 100644 index b68d62e6b744..000000000000 --- a/source/isaaclab/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst +++ /dev/null @@ -1,60 +0,0 @@ -Added -^^^^^ - -* Added :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` and - :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w` properties, - exposing the per-body geometric Jacobian referenced at the link origin and - body center of mass respectively. The pair mirrors the existing - :attr:`~isaaclab.assets.BaseArticulationData.body_link_pose_w` / - :attr:`~isaaclab.assets.BaseArticulationData.body_com_pose_w` and - :attr:`~isaaclab.assets.BaseArticulationData.body_link_vel_w` / - :attr:`~isaaclab.assets.BaseArticulationData.body_com_vel_w` exposure pattern. - Backends without a native primitive raise :class:`NotImplementedError`. -* Added :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix` property, - exposing the joint-space generalized mass matrix ``M(q)``. -* Added :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` - property, exposing the joint-space gravity-loading torque vector ``g(q)``. -* Added :attr:`~isaaclab.assets.BaseArticulation.num_base_dofs` — number of - free DoFs of the floating base (``0`` for fixed-base, ``6`` for floating- - base). Use it to map an actuated-joint index ``j`` to its column in the - Jacobian / mass matrix / gravity vector via ``j + num_base_dofs``. - -The Jacobian / mass-matrix / gravity-comp DoF axis includes the floating- -base DoFs at the front: shape ``(N, num_jacobi_bodies, 6, num_joints + -num_base_dofs)`` for the Jacobian and ``(N, num_joints + num_base_dofs, -num_joints + num_base_dofs)`` for the mass matrix. This matches the -cross-library industry convention (Pinocchio's ``nv = 6 + n_actuated``, -Drake's ephemeral floating joint, MuJoCo's ````, RBDL's -``JointTypeFloatingBase``, OCS2's ``generalizedCoordinatesNum = -6 + actuatedJointsNum``, iDynTree's ``getFreeFloatingMassMatrix`` -returning ``(6 + dofs, 6 + dofs)``). - -Changed -^^^^^^^ - -* Migrated :class:`~isaaclab.envs.mdp.actions.task_space_actions.DifferentialInverseKinematicsAction`, - :class:`~isaaclab.envs.mdp.actions.task_space_actions.OperationalSpaceControllerAction`, - and :class:`~isaaclab.envs.mdp.actions.rmpflow_task_space_actions.RMPFlowAction` - to fetch dynamic quantities through the new - :class:`~isaaclab.assets.BaseArticulationData` properties instead of the - PhysX-only ``root_view``. The OSC action term now also gates the - per-step mass-matrix and gravity-compensation fetches behind the - controller cfg's :attr:`inertial_dynamics_decoupling`, - :attr:`nullspace_control`, and :attr:`gravity_compensation` flags - so backends without a native primitive are not invoked when the - controller does not consume the result. -* Action terms (DiffIK / OSC / RMPFlow / Pink) compute their Jacobian - joint-axis indices via - ``[j + asset.num_base_dofs for j in joint_ids]``, which is ``0`` for - fixed-base and ``+6`` for floating-base. Pink IK previously hardcoded - a private ``_physx_floating_joint_indices_offset = 6``; that was - removed in favor of the cross-backend property. -* PhysX backend's :attr:`body_link_jacobian_w` applies the COM→origin shift to - PhysX's natively COM-referenced Jacobian. The previously-exposed - ``Articulation.get_jacobians()`` was a passthrough that returned the raw - COM-referenced Jacobian, while IK / OSC consumers also read - :attr:`body_link_pose_w` as the EE pose setpoint — a frame mismatch that - produced a ``ω × r_com_w`` per-body bias in tracking. The new property - reads the same engine buffer and applies the shift so ``J · q_dot`` matches - ``body_link_lin_vel_w``. Consumers that intentionally want the raw - COM-referenced form can read :attr:`body_com_jacobian_w`. diff --git a/source/isaaclab/changelog.d/klakhi-fix-leapp-deployment-env-extras.rst b/source/isaaclab/changelog.d/klakhi-fix-leapp-deployment-env-extras.rst deleted file mode 100644 index 1253f5ab83d9..000000000000 --- a/source/isaaclab/changelog.d/klakhi-fix-leapp-deployment-env-extras.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Fixed :class:`~envs.LeappDeploymentEnv` crashing on ``reset()`` with - ``AttributeError: 'LeappDeploymentEnv' object has no attribute 'extras'`` - by initializing ``self.extras`` in ``__init__``. diff --git a/source/isaaclab/changelog.d/myurasov-conda-env-pip.rst b/source/isaaclab/changelog.d/myurasov-conda-env-pip.rst deleted file mode 100644 index e4310fc0306e..000000000000 --- a/source/isaaclab/changelog.d/myurasov-conda-env-pip.rst +++ /dev/null @@ -1,9 +0,0 @@ -Fixed -^^^^^ - -* Fixed ``./isaaclab.sh -p -m pip ...`` failing with ``No module named pip`` - in the conda env created from ``environment.yml`` on Linux aarch64 - (e.g. DGX Spark / GB10). The conda-forge solver was not pulling - ``pip`` in transitively on aarch64, so the resulting ``env_isaaclab`` - had no pip. ``environment.yml`` now lists ``pip`` explicitly so it - is seeded on every platform. diff --git a/source/isaaclab/changelog.d/myurasov-nvbug-6125054-viser-extra.rst b/source/isaaclab/changelog.d/myurasov-nvbug-6125054-viser-extra.rst deleted file mode 100644 index 46dd451bd81a..000000000000 --- a/source/isaaclab/changelog.d/myurasov-nvbug-6125054-viser-extra.rst +++ /dev/null @@ -1,12 +0,0 @@ -Fixed -^^^^^ - -* Fixed ``pip install isaaclab[isaacsim,all]==3.0.0`` failing with - ``No solution found`` (UV) or ``error: resolution-too-deep`` (pip) when - resolving against ``isaacsim==6.0.0.0``. ``viser>=1.0.16`` was a base - dependency of the built ``isaaclab`` wheel and transitively requires - ``websockets>=13.1``, but ``isaacsim-kernel==6.0.0.0`` pins - ``websockets==12.0``. Moved ``viser`` to an opt-in ``viser`` extra in - ``tools/wheel_builder/res/python_packages.toml`` so the base wheel is - installable alongside ``isaacsim==6.0.0.0``. Users who want the Viser - visualizer can request it explicitly with ``isaaclab[viser]``. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 3a1dc987c390..125e08a54cf2 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 = "5.1.1" +version = "5.2.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 4413fa3b710e..8cc881bae674 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,125 @@ Changelog --------- +5.2.0 (2026-05-14) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` and + :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w` properties, + exposing the per-body geometric Jacobian referenced at the link origin and + body center of mass respectively. The pair mirrors the existing + :attr:`~isaaclab.assets.BaseArticulationData.body_link_pose_w` / + :attr:`~isaaclab.assets.BaseArticulationData.body_com_pose_w` and + :attr:`~isaaclab.assets.BaseArticulationData.body_link_vel_w` / + :attr:`~isaaclab.assets.BaseArticulationData.body_com_vel_w` exposure pattern. + Backends without a native primitive raise :class:`NotImplementedError`. +* Added :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix` property, + exposing the joint-space generalized mass matrix ``M(q)``. +* Added :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` + property, exposing the joint-space gravity-loading torque vector ``g(q)``. +* Added :attr:`~isaaclab.assets.BaseArticulation.num_base_dofs` — number of + free DoFs of the floating base (``0`` for fixed-base, ``6`` for floating- + base). Use it to map an actuated-joint index ``j`` to its column in the + Jacobian / mass matrix / gravity vector via ``j + num_base_dofs``. + +The Jacobian / mass-matrix / gravity-comp DoF axis includes the floating- +base DoFs at the front: shape ``(N, num_jacobi_bodies, 6, num_joints + +num_base_dofs)`` for the Jacobian and ``(N, num_joints + num_base_dofs, +num_joints + num_base_dofs)`` for the mass matrix. This matches the +cross-library industry convention (Pinocchio's ``nv = 6 + n_actuated``, +Drake's ephemeral floating joint, MuJoCo's ````, RBDL's +``JointTypeFloatingBase``, OCS2's ``generalizedCoordinatesNum = +6 + actuatedJointsNum``, iDynTree's ``getFreeFloatingMassMatrix`` +returning ``(6 + dofs, 6 + dofs)``). +* Added :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.usd_stage`, + :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.num_envs`, and + :meth:`~isaaclab.scene.scene_data_provider.SceneDataProvider.get_camera_transforms` + so visualizers and renderers can pull stage-derived data through the same + Warp-native provider that already exposes transforms. + +Changed +^^^^^^^ + +* Migrated :class:`~isaaclab.envs.mdp.actions.task_space_actions.DifferentialInverseKinematicsAction`, + :class:`~isaaclab.envs.mdp.actions.task_space_actions.OperationalSpaceControllerAction`, + and :class:`~isaaclab.envs.mdp.actions.rmpflow_task_space_actions.RMPFlowAction` + to fetch dynamic quantities through the new + :class:`~isaaclab.assets.BaseArticulationData` properties instead of the + PhysX-only ``root_view``. The OSC action term now also gates the + per-step mass-matrix and gravity-compensation fetches behind the + controller cfg's :attr:`inertial_dynamics_decoupling`, + :attr:`nullspace_control`, and :attr:`gravity_compensation` flags + so backends without a native primitive are not invoked when the + controller does not consume the result. +* Action terms (DiffIK / OSC / RMPFlow / Pink) compute their Jacobian + joint-axis indices via + ``[j + asset.num_base_dofs for j in joint_ids]``, which is ``0`` for + fixed-base and ``+6`` for floating-base. Pink IK previously hardcoded + a private ``_physx_floating_joint_indices_offset = 6``; that was + removed in favor of the cross-backend property. +* PhysX backend's :attr:`body_link_jacobian_w` applies the COM→origin shift to + PhysX's natively COM-referenced Jacobian. The previously-exposed + ``Articulation.get_jacobians()`` was a passthrough that returned the raw + COM-referenced Jacobian, while IK / OSC consumers also read + :attr:`body_link_pose_w` as the EE pose setpoint — a frame mismatch that + produced a ``ω × r_com_w`` per-body bias in tracking. The new property + reads the same engine buffer and applies the shift so ``J · q_dot`` matches + ``body_link_lin_vel_w``. Consumers that intentionally want the raw + COM-referenced form can read :attr:`body_com_jacobian_w`. +* **Breaking:** :class:`~isaaclab.visualizers.base_visualizer.BaseVisualizer` + subclasses now receive a + :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` in + :meth:`~isaaclab.visualizers.base_visualizer.BaseVisualizer.initialize` + instead of the removed ``BaseSceneDataProvider``. Read environment count + from :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.num_envs` + and call + :meth:`~isaaclab.scene.scene_data_provider.SceneDataProvider.get_camera_transforms` + on the new provider; both replace the previous ``get_metadata()`` / + ``get_camera_transforms()`` calls on the legacy interface. + +Removed +^^^^^^^ + +* **Breaking:** Removed ``isaaclab.physics.BaseSceneDataProvider``, + ``isaaclab.physics.SceneDataProvider`` (the legacy factory), + ``SimulationContext.initialize_scene_data_provider()``, and + ``SimulationContext.update_scene_data_provider()``. Use + :meth:`~isaaclab.sim.simulation_context.SimulationContext.get_scene_data_provider` + to obtain the new provider; consumers that previously called + ``get_newton_model()`` / ``get_newton_state()`` should call + ``NewtonManager.get_model()`` / ``NewtonManager.get_state()`` instead. +* Removed explicit ``mujoco`` and ``mujoco-warp`` dependencies from + :mod:`isaaclab`. These packages are not used by ``isaaclab`` core and are + now resolved transitively through Newton's ``[sim]`` extra in + :mod:`isaaclab_newton`. Users installing only the PhysX or Kit backends no + longer pull in MuJoCo. + +Fixed +^^^^^ + +* Fixed :class:`~envs.LeappDeploymentEnv` crashing on ``reset()`` with + ``AttributeError: 'LeappDeploymentEnv' object has no attribute 'extras'`` + by initializing ``self.extras`` in ``__init__``. +* Fixed ``./isaaclab.sh -p -m pip ...`` failing with ``No module named pip`` + in the conda env created from ``environment.yml`` on Linux aarch64 + (e.g. DGX Spark / GB10). The conda-forge solver was not pulling + ``pip`` in transitively on aarch64, so the resulting ``env_isaaclab`` + had no pip. ``environment.yml`` now lists ``pip`` explicitly so it + is seeded on every platform. +* Fixed ``pip install isaaclab[isaacsim,all]==3.0.0`` failing with + ``No solution found`` (UV) or ``error: resolution-too-deep`` (pip) when + resolving against ``isaacsim==6.0.0.0``. ``viser>=1.0.16`` was a base + dependency of the built ``isaaclab`` wheel and transitively requires + ``websockets>=13.1``, but ``isaacsim-kernel==6.0.0.0`` pins + ``websockets==12.0``. Moved ``viser`` to an opt-in ``viser`` extra in + ``tools/wheel_builder/res/python_packages.toml`` so the base wheel is + installable alongside ``isaacsim==6.0.0.0``. Users who want the Viser + visualizer can request it explicitly with ``isaaclab[viser]``. + + 5.1.1 (2026-05-13) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_mimic/changelog.d/fix-mimic-datagen-import.rst b/source/isaaclab_mimic/changelog.d/fix-mimic-datagen-import.rst deleted file mode 100644 index 2c7a0ef6d4f7..000000000000 --- a/source/isaaclab_mimic/changelog.d/fix-mimic-datagen-import.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed :mod:`isaaclab_mimic.datagen` imports in packaged installs and avoided - importing task configuration modules until data generation config setup. diff --git a/source/isaaclab_mimic/config/extension.toml b/source/isaaclab_mimic/config/extension.toml index 6646522f5f1e..1a4f579a323c 100644 --- a/source/isaaclab_mimic/config/extension.toml +++ b/source/isaaclab_mimic/config/extension.toml @@ -1,7 +1,7 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "1.2.6" +version = "1.2.7" # Description category = "isaaclab" diff --git a/source/isaaclab_mimic/docs/CHANGELOG.rst b/source/isaaclab_mimic/docs/CHANGELOG.rst index 08da411a579c..e1e50f99e72b 100644 --- a/source/isaaclab_mimic/docs/CHANGELOG.rst +++ b/source/isaaclab_mimic/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +1.2.7 (2026-05-14) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed :mod:`isaaclab_mimic.datagen` imports in packaged installs and avoided + importing task configuration modules until data generation config setup. + + 1.2.6 (2026-05-08) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/changelog.d/ca-fix-newton-contact-sensor-migration.rst b/source/isaaclab_newton/changelog.d/ca-fix-newton-contact-sensor-migration.rst deleted file mode 100644 index 5acd1c0cf4f0..000000000000 --- a/source/isaaclab_newton/changelog.d/ca-fix-newton-contact-sensor-migration.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed :class:`~isaaclab_newton.sensors.ContactSensor` metadata extraction - after the migration to Newton 1.1, where ``sensing_obj_type`` and - ``counterpart_type`` became scalar strings and ``counterpart_indices`` - became per-row. diff --git a/source/isaaclab_newton/changelog.d/dev-scene-data-provider-api.minor.rst b/source/isaaclab_newton/changelog.d/dev-scene-data-provider-api.minor.rst deleted file mode 100644 index ae662cf3dcaa..000000000000 --- a/source/isaaclab_newton/changelog.d/dev-scene-data-provider-api.minor.rst +++ /dev/null @@ -1,31 +0,0 @@ -Added -^^^^^ - -* Added :meth:`~isaaclab_newton.physics.NewtonManager.get_state` and - :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state` so - Newton-based renderers, visualizers, and video recorders can fetch a Newton - ``Model``/``State`` regardless of the active sim backend. When the sim - backend is PhysX the manager builds a shadow Newton model directly from the - USD stage (via - :meth:`~isaaclab_newton.physics.NewtonManager.instantiate_builder_from_stage`) - and refreshes ``state_0.body_q`` from rigid-body transforms supplied by the - :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` each render - frame. - -Changed -^^^^^^^ - -* **Breaking:** :class:`~isaaclab_newton.renderers.NewtonWarpRenderer`, - :class:`~isaaclab_newton.video_recording.NewtonGlPerspectiveVideo`, and the - Newton/Rerun/Viser visualizers now read Newton ``Model``/``State`` from - :class:`~isaaclab_newton.physics.NewtonManager` instead of the removed - ``BaseSceneDataProvider.get_newton_model()`` / ``get_newton_state()``. - -Removed -^^^^^^^ - -* **Breaking:** Removed the ``isaaclab_newton.scene_data_providers`` package - (``NewtonSceneDataProvider``). Replace direct uses with - :meth:`~isaaclab_newton.physics.NewtonManager.get_model` / - :meth:`~isaaclab_newton.physics.NewtonManager.get_state` and the - Warp-native :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider`. diff --git a/source/isaaclab_newton/changelog.d/jichuanh-drop-mujoco-deps.rst b/source/isaaclab_newton/changelog.d/jichuanh-drop-mujoco-deps.rst deleted file mode 100644 index 152b6744d80a..000000000000 --- a/source/isaaclab_newton/changelog.d/jichuanh-drop-mujoco-deps.rst +++ /dev/null @@ -1,8 +0,0 @@ -Changed -^^^^^^^ - -* Switched the Newton install to ``newton[sim]`` so that ``mujoco`` and - ``mujoco-warp`` are pulled in transitively via Newton's ``[sim]`` extra. - The explicit ``mujoco==3.8.0`` and ``mujoco-warp==3.8.0.1`` pins were - removed from :mod:`isaaclab_newton` — Newton is now the single source of - truth for those versions. diff --git a/source/isaaclab_newton/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst b/source/isaaclab_newton/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst deleted file mode 100644 index aea1e28e52b9..000000000000 --- a/source/isaaclab_newton/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst +++ /dev/null @@ -1,40 +0,0 @@ -Added -^^^^^ - -* Added Newton implementations of - :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w`, - :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w`, and - :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix` on - :class:`~isaaclab_newton.assets.ArticulationData`. The properties wrap - ``ArticulationView.eval_jacobian`` and ``ArticulationView.eval_mass_matrix`` - with view-sized output buffers cached via the standard timestamped-buffer - pattern. Per-step behavior is allocation-free and safe under CUDA-graph - capture: source / scratch / output buffers are pre-allocated in - ``_create_buffers``, and - :func:`~isaaclab_newton.assets.articulation.kernels.gather_jacobian_rows` - and :func:`~isaaclab_newton.assets.articulation.kernels.gather_mass_matrix_rows` - Warp kernels gather just this view's rows from the model-sized buffers - Newton populates. The DoF axis preserves the leading 6 floating-base - columns Newton fills for floating-base articulations (matching the - cross-library industry convention and PhysX's layout). -* Added the - :func:`~isaaclab_newton.assets.articulation.kernels.shift_jacobian_com_to_origin` - Warp kernel applying the - ``v_origin = v_com - omega x (R · body_com_pos_b)`` shift to the - linear-velocity rows of the gathered, view-sized Jacobian, so the link- - origin form matches the cross-backend - :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` - contract. - -Changed -^^^^^^^ - -* :attr:`~isaaclab_newton.assets.ArticulationData.gravity_compensation_forces` - raises :class:`NotImplementedError` with a message pointing at the - upstream gap. Newton's ``ArticulationView`` does not expose an - inverse-dynamics primitive yet (upstream Newton issues - `#2497 `_, - `#2529 `_, - `#2625 `_). - OSC users on Newton must set ``gravity_compensation=False`` until - upstream lands the primitive. diff --git a/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst b/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst deleted file mode 100644 index 12a62ab4d414..000000000000 --- a/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst +++ /dev/null @@ -1,14 +0,0 @@ -Fixed -^^^^^ - -* Fixed per-environment string identifiers (e.g. ``mujoco:tendon_label``) - keeping the source proto path after replication. - :func:`~isaaclab_newton.cloner.newton_replicate._rename_builder_labels` - now also walks string-typed custom-attribute columns whose frequency - declares a ``references="world"`` companion, rewriting their per-row - source-path prefix to the destination world root in the same pass that - handles built-in label arrays. Adds ``constraint_mimic`` and - ``equality_constraint`` to that built-in pass for completeness. The - prefix match uses a path-separator boundary so a source path that is a - string prefix of another (e.g. ``/Sources/protoA`` vs - ``/Sources/protoAB``) does not cross-contaminate during the rename. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 2aa95e8f185b..1de90e078dc2 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.8.1" +version = "0.9.0" # 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 ee88d55a3241..d2da55554a63 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,99 @@ Changelog --------- +0.9.0 (2026-05-14) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added Newton implementations of + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w`, + :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w`, and + :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix` on + :class:`~isaaclab_newton.assets.ArticulationData`. The properties wrap + ``ArticulationView.eval_jacobian`` and ``ArticulationView.eval_mass_matrix`` + with view-sized output buffers cached via the standard timestamped-buffer + pattern. Per-step behavior is allocation-free and safe under CUDA-graph + capture: source / scratch / output buffers are pre-allocated in + ``_create_buffers``, and + :func:`~isaaclab_newton.assets.articulation.kernels.gather_jacobian_rows` + and :func:`~isaaclab_newton.assets.articulation.kernels.gather_mass_matrix_rows` + Warp kernels gather just this view's rows from the model-sized buffers + Newton populates. The DoF axis preserves the leading 6 floating-base + columns Newton fills for floating-base articulations (matching the + cross-library industry convention and PhysX's layout). +* Added the + :func:`~isaaclab_newton.assets.articulation.kernels.shift_jacobian_com_to_origin` + Warp kernel applying the + ``v_origin = v_com - omega x (R · body_com_pos_b)`` shift to the + linear-velocity rows of the gathered, view-sized Jacobian, so the link- + origin form matches the cross-backend + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` + contract. +* Added :meth:`~isaaclab_newton.physics.NewtonManager.get_state` and + :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state` so + Newton-based renderers, visualizers, and video recorders can fetch a Newton + ``Model``/``State`` regardless of the active sim backend. When the sim + backend is PhysX the manager builds a shadow Newton model directly from the + USD stage (via + :meth:`~isaaclab_newton.physics.NewtonManager.instantiate_builder_from_stage`) + and refreshes ``state_0.body_q`` from rigid-body transforms supplied by the + :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` each render + frame. + +Changed +^^^^^^^ + +* :attr:`~isaaclab_newton.assets.ArticulationData.gravity_compensation_forces` + raises :class:`NotImplementedError` with a message pointing at the + upstream gap. Newton's ``ArticulationView`` does not expose an + inverse-dynamics primitive yet (upstream Newton issues + `#2497 `_, + `#2529 `_, + `#2625 `_). + OSC users on Newton must set ``gravity_compensation=False`` until + upstream lands the primitive. +* **Breaking:** :class:`~isaaclab_newton.renderers.NewtonWarpRenderer`, + :class:`~isaaclab_newton.video_recording.NewtonGlPerspectiveVideo`, and the + Newton/Rerun/Viser visualizers now read Newton ``Model``/``State`` from + :class:`~isaaclab_newton.physics.NewtonManager` instead of the removed + ``BaseSceneDataProvider.get_newton_model()`` / ``get_newton_state()``. +* Switched the Newton install to ``newton[sim]`` so that ``mujoco`` and + ``mujoco-warp`` are pulled in transitively via Newton's ``[sim]`` extra. + The explicit ``mujoco==3.8.0`` and ``mujoco-warp==3.8.0.1`` pins were + removed from :mod:`isaaclab_newton` — Newton is now the single source of + truth for those versions. + +Removed +^^^^^^^ + +* **Breaking:** Removed the ``isaaclab_newton.scene_data_providers`` package + (``NewtonSceneDataProvider``). Replace direct uses with + :meth:`~isaaclab_newton.physics.NewtonManager.get_model` / + :meth:`~isaaclab_newton.physics.NewtonManager.get_state` and the + Warp-native :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider`. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_newton.sensors.ContactSensor` metadata extraction + after the migration to Newton 1.1, where ``sensing_obj_type`` and + ``counterpart_type`` became scalar strings and ``counterpart_indices`` + became per-row. +* Fixed per-environment string identifiers (e.g. ``mujoco:tendon_label``) + keeping the source proto path after replication. + :func:`~isaaclab_newton.cloner.newton_replicate._rename_builder_labels` + now also walks string-typed custom-attribute columns whose frequency + declares a ``references="world"`` companion, rewriting their per-row + source-path prefix to the destination world root in the same pass that + handles built-in label arrays. Adds ``constraint_mimic`` and + ``equality_constraint`` to that built-in pass for completeness. The + prefix match uses a path-separator boundary so a source path that is a + string prefix of another (e.g. ``/Sources/protoA`` vs + ``/Sources/protoAB``) does not cross-contaminate during the rename. + + 0.8.1 (2026-05-13) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/changelog.d/dev-scene-data-provider-api.rst b/source/isaaclab_ov/changelog.d/dev-scene-data-provider-api.rst deleted file mode 100644 index a34f01f0654e..000000000000 --- a/source/isaaclab_ov/changelog.d/dev-scene-data-provider-api.rst +++ /dev/null @@ -1,9 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** :class:`~isaaclab_ov.renderers.OVRTXRenderer` now reads the - Newton ``Model`` and ``State`` it binds OVRTX attributes against from - :meth:`~isaaclab_newton.physics.NewtonManager.get_model` / - :meth:`~isaaclab_newton.physics.NewtonManager.get_state` instead of the - removed ``BaseSceneDataProvider.get_newton_model()`` / - ``get_newton_state()``. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 3f2861d1bf6a..540638e401ca 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.1.8" +version = "0.1.9" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index 177c9235cb98..a4846a27bee8 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,20 @@ Changelog --------- +0.1.9 (2026-05-14) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* **Breaking:** :class:`~isaaclab_ov.renderers.OVRTXRenderer` now reads the + Newton ``Model`` and ``State`` it binds OVRTX attributes against from + :meth:`~isaaclab_newton.physics.NewtonManager.get_model` / + :meth:`~isaaclab_newton.physics.NewtonManager.get_state` instead of the + removed ``BaseSceneDataProvider.get_newton_model()`` / + ``get_newton_state()``. + + 0.1.8 (2026-05-13) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject.major.rst deleted file mode 100644 index 2a43911295c2..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject.major.rst +++ /dev/null @@ -1,50 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_ovphysx.assets.RigidObject` and - :class:`~isaaclab_ovphysx.assets.RigidObjectData` for single-actor rigid-body - simulation against the OVPhysX backend, satisfying the - :class:`~isaaclab.assets.BaseRigidObject` and - :class:`~isaaclab.assets.BaseRigidObjectData` contracts. Public surface - matches the PhysX/Newton conventions: ``write_root_*_to_sim_index`` / - ``write_root_*_to_sim_mask`` writers (link- and com-frame variants), - ``set_masses_*``, ``set_coms_*``, ``set_inertias_*`` setters, and the - external-wrench composers exposed via - :meth:`~isaaclab_ovphysx.assets.RigidObject.set_external_force_and_torque`. -* Added the ``RIGID_BODY_*`` :class:`TensorType` aliases in - :mod:`isaaclab_ovphysx.tensor_types` (``POSE``, ``VELOCITY``, ``WRENCH``, - ``MASS``, ``COM_POSE``, ``INERTIA``; plus ``ACCELERATION``, ``INV_MASS``, - ``INV_INERTIA`` declared for forward compatibility once the wheel ships - them). -* Added :class:`~isaaclab_ovphysx.assets.kernels` as a shared Warp-kernel - module (frame conversions, state concatenation, finite-difference - acceleration, index- and mask-style scatter writers) consumed by both the - rigid-object and articulation assets. -* Added USD prim-scan validation in - :meth:`~isaaclab_ovphysx.assets.RigidObject._initialize_impl`: a clear - ``RuntimeError`` is raised when ``cfg.prim_path`` resolves to no - ``UsdPhysics.RigidBodyAPI`` prim, multiple rigid-body prims, or a prim with - an enabled ``UsdPhysics.ArticulationRootAPI``. - -Changed -^^^^^^^ - -* Changed :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._release_physx` to - perform a soft reset (``physx.reset()``) and keep the cached - :class:`ovphysx.PhysX` reference alive across - :class:`~isaaclab.sim.SimulationContext` lifetimes, instead of dropping the - reference and triggering the wheel's dual-Carbonite static-destructor race. - :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._warmup_and_load` now reuses - the cached instance on subsequent calls. -* Changed :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._warmup_and_load` to - raise a clear ``RuntimeError`` when a later - :class:`~isaaclab.sim.SimulationContext` requests a different device than - the one the process is locked to, surfacing the wheel's process-global - device-mode lock as a Python error before - :exc:`ovphysx.types.PhysXDeviceError` would fire. -* Changed :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._configure_physx_scene_prim` - to apply the ``UsdPhysics.PhysxSceneAPI`` schema and - ``enableSceneQuerySupport`` on both CPU and GPU; GPU-only attributes - (``enableGPUDynamics``, ``broadphaseType``, the ``gpu*`` capacity attributes - from :class:`~isaaclab_ovphysx.physics.OvPhysxCfg`) remain gated on - ``device == "gpu"``. diff --git a/source/isaaclab_ovphysx/changelog.d/jichuanh-ik-newton-compat-mvp.rst b/source/isaaclab_ovphysx/changelog.d/jichuanh-ik-newton-compat-mvp.rst deleted file mode 100644 index f2cc47afe8a2..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/jichuanh-ik-newton-compat-mvp.rst +++ /dev/null @@ -1,12 +0,0 @@ -Changed -^^^^^^^ - -* Inherits the base - :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w`, - :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w`, - :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix`, and - :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` - :class:`NotImplementedError` defaults — ovphysx's OmniGraph-based view - does not expose articulation Jacobians, mass matrices, or gravity - compensation. Use the PhysX or Newton backends for task-space - controllers. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 1ad422a1df32..f280545b5e2e 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.4" +version = "1.0.0" # 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 cea22cdc70c5..0858c693523f 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,70 @@ Changelog --------- +1.0.0 (2026-05-14) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.assets.RigidObject` and + :class:`~isaaclab_ovphysx.assets.RigidObjectData` for single-actor rigid-body + simulation against the OVPhysX backend, satisfying the + :class:`~isaaclab.assets.BaseRigidObject` and + :class:`~isaaclab.assets.BaseRigidObjectData` contracts. Public surface + matches the PhysX/Newton conventions: ``write_root_*_to_sim_index`` / + ``write_root_*_to_sim_mask`` writers (link- and com-frame variants), + ``set_masses_*``, ``set_coms_*``, ``set_inertias_*`` setters, and the + external-wrench composers exposed via + :meth:`~isaaclab_ovphysx.assets.RigidObject.set_external_force_and_torque`. +* Added the ``RIGID_BODY_*`` :class:`TensorType` aliases in + :mod:`isaaclab_ovphysx.tensor_types` (``POSE``, ``VELOCITY``, ``WRENCH``, + ``MASS``, ``COM_POSE``, ``INERTIA``; plus ``ACCELERATION``, ``INV_MASS``, + ``INV_INERTIA`` declared for forward compatibility once the wheel ships + them). +* Added :class:`~isaaclab_ovphysx.assets.kernels` as a shared Warp-kernel + module (frame conversions, state concatenation, finite-difference + acceleration, index- and mask-style scatter writers) consumed by both the + rigid-object and articulation assets. +* Added USD prim-scan validation in + :meth:`~isaaclab_ovphysx.assets.RigidObject._initialize_impl`: a clear + ``RuntimeError`` is raised when ``cfg.prim_path`` resolves to no + ``UsdPhysics.RigidBodyAPI`` prim, multiple rigid-body prims, or a prim with + an enabled ``UsdPhysics.ArticulationRootAPI``. + +Changed +^^^^^^^ + +* Changed :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._release_physx` to + perform a soft reset (``physx.reset()``) and keep the cached + :class:`ovphysx.PhysX` reference alive across + :class:`~isaaclab.sim.SimulationContext` lifetimes, instead of dropping the + reference and triggering the wheel's dual-Carbonite static-destructor race. + :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._warmup_and_load` now reuses + the cached instance on subsequent calls. +* Changed :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._warmup_and_load` to + raise a clear ``RuntimeError`` when a later + :class:`~isaaclab.sim.SimulationContext` requests a different device than + the one the process is locked to, surfacing the wheel's process-global + device-mode lock as a Python error before + :exc:`ovphysx.types.PhysXDeviceError` would fire. +* Changed :meth:`~isaaclab_ovphysx.physics.OvPhysxManager._configure_physx_scene_prim` + to apply the ``UsdPhysics.PhysxSceneAPI`` schema and + ``enableSceneQuerySupport`` on both CPU and GPU; GPU-only attributes + (``enableGPUDynamics``, ``broadphaseType``, the ``gpu*`` capacity attributes + from :class:`~isaaclab_ovphysx.physics.OvPhysxCfg`) remain gated on + ``device == "gpu"``. +* Inherits the base + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w`, + :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w`, + :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix`, and + :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` + :class:`NotImplementedError` defaults — ovphysx's OmniGraph-based view + does not expose articulation Jacobians, mass matrices, or gravity + compensation. Use the PhysX or Newton backends for task-space + controllers. + + 0.1.4 (2026-05-09) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/changelog.d/dev-scene-data-provider-api.minor.rst b/source/isaaclab_physx/changelog.d/dev-scene-data-provider-api.minor.rst deleted file mode 100644 index 77d2850749cc..000000000000 --- a/source/isaaclab_physx/changelog.d/dev-scene-data-provider-api.minor.rst +++ /dev/null @@ -1,19 +0,0 @@ -Added -^^^^^ - -* Added :meth:`~isaaclab_physx.physics.PhysxManager.pre_render` so the - PhysX backend can drive - :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state` - once per render frame when the active visualizer/renderer set requires a - Newton model. - -Removed -^^^^^^^ - -* **Breaking:** Removed the ``isaaclab_physx.scene_data_providers`` package - (``PhysxSceneDataProvider``). The Warp-native - :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` now exposes - PhysX rigid-body transforms via - :class:`~isaaclab_physx.physics.PhysxSceneDataBackend`, and the - PhysX→Newton state sync used by Newton visualizers/renderers moved to - :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state`. diff --git a/source/isaaclab_physx/changelog.d/jichuanh-drop-mujoco-deps.rst b/source/isaaclab_physx/changelog.d/jichuanh-drop-mujoco-deps.rst deleted file mode 100644 index 24821359c927..000000000000 --- a/source/isaaclab_physx/changelog.d/jichuanh-drop-mujoco-deps.rst +++ /dev/null @@ -1,8 +0,0 @@ -Changed -^^^^^^^ - -* Switched the Newton install spec to ``newton[sim]`` in the ``newton`` - extra so the MuJoCo solver dependencies are pulled in transitively. - Required because pip resolves a git-URL requirement once for the URL; - a bare ``newton @ git+...`` here would shadow the ``[sim]`` extra - requested elsewhere. diff --git a/source/isaaclab_physx/changelog.d/jichuanh-ik-newton-compat-mvp.rst b/source/isaaclab_physx/changelog.d/jichuanh-ik-newton-compat-mvp.rst deleted file mode 100644 index 8ffa5ad63b15..000000000000 --- a/source/isaaclab_physx/changelog.d/jichuanh-ik-newton-compat-mvp.rst +++ /dev/null @@ -1,31 +0,0 @@ -Added -^^^^^ - -* Added PhysX implementations of - :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w`, - :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w`, - :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix`, and - :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` - on :class:`~isaaclab_physx.assets.ArticulationData`. The COM - variant is a passthrough to ``physx.ArticulationView.get_jacobians``; - the link-origin variant applies a new - :func:`~isaaclab_physx.assets.articulation.kernels.shift_jacobian_com_to_origin` - Warp kernel to convert the COM-referenced linear-velocity rows to - link-origin references using each body's pose and COM offset. All - four properties preserve the full DoF axis, including the 6 leading - floating-base columns/rows PhysX's raw tensor view prepends on - floating-base assets — matching the cross-library industry convention - (Pinocchio, Drake, MuJoCo, RBDL, OCS2, iDynTree) and Newton's - ``ArticulationView`` layout. - -Fixed -^^^^^ - -* Fixed a latent correctness bug in IK / OSC controllers on the PhysX - backend, where the previously-exposed Jacobian was COM-referenced but - the controllers used :attr:`~isaaclab_physx.assets.ArticulationData.body_link_pose_w` - as the EE pose setpoint. The frame mismatch caused tracking error on - bodies whose COM offset is non-trivial. The new - :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` - applies the COM→origin shift so the Jacobian and pose share a - reference point. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 3371307fa567..6eb2ff012c5e 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.6.4" +version = "0.7.0" # 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 0eef200d5f15..b62942448482 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,67 @@ Changelog --------- +0.7.0 (2026-05-14) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added PhysX implementations of + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w`, + :attr:`~isaaclab.assets.BaseArticulationData.body_com_jacobian_w`, + :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix`, and + :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` + on :class:`~isaaclab_physx.assets.ArticulationData`. The COM + variant is a passthrough to ``physx.ArticulationView.get_jacobians``; + the link-origin variant applies a new + :func:`~isaaclab_physx.assets.articulation.kernels.shift_jacobian_com_to_origin` + Warp kernel to convert the COM-referenced linear-velocity rows to + link-origin references using each body's pose and COM offset. All + four properties preserve the full DoF axis, including the 6 leading + floating-base columns/rows PhysX's raw tensor view prepends on + floating-base assets — matching the cross-library industry convention + (Pinocchio, Drake, MuJoCo, RBDL, OCS2, iDynTree) and Newton's + ``ArticulationView`` layout. +* Added :meth:`~isaaclab_physx.physics.PhysxManager.pre_render` so the + PhysX backend can drive + :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state` + once per render frame when the active visualizer/renderer set requires a + Newton model. + +Changed +^^^^^^^ + +* Switched the Newton install spec to ``newton[sim]`` in the ``newton`` + extra so the MuJoCo solver dependencies are pulled in transitively. + Required because pip resolves a git-URL requirement once for the URL; + a bare ``newton @ git+...`` here would shadow the ``[sim]`` extra + requested elsewhere. + +Removed +^^^^^^^ + +* **Breaking:** Removed the ``isaaclab_physx.scene_data_providers`` package + (``PhysxSceneDataProvider``). The Warp-native + :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` now exposes + PhysX rigid-body transforms via + :class:`~isaaclab_physx.physics.PhysxSceneDataBackend`, and the + PhysX→Newton state sync used by Newton visualizers/renderers moved to + :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state`. + +Fixed +^^^^^ + +* Fixed a latent correctness bug in IK / OSC controllers on the PhysX + backend, where the previously-exposed Jacobian was COM-referenced but + the controllers used :attr:`~isaaclab_physx.assets.ArticulationData.body_link_pose_w` + as the EE pose setpoint. The frame mismatch caused tracking error on + bodies whose COM offset is non-trivial. The new + :attr:`~isaaclab.assets.BaseArticulationData.body_link_jacobian_w` + applies the COM→origin shift so the Jacobian and pose share a + reference point. + + 0.6.4 (2026-05-13) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/changelog.d/dev-scene-data-provider-api.rst b/source/isaaclab_tasks/changelog.d/dev-scene-data-provider-api.rst deleted file mode 100644 index 3beb9dba21a1..000000000000 --- a/source/isaaclab_tasks/changelog.d/dev-scene-data-provider-api.rst +++ /dev/null @@ -1,13 +0,0 @@ -Fixed -^^^^^ - -* Fixed ``Isaac-Navigation-3DObstacles-ARL-Robot-1-v0`` config load - raising ``TypeError: only 0-dimensional arrays can be converted to - Python scalars`` under NumPy 2.0+. The wall-color sampling now - requests a scalar from :func:`numpy.random.randint` instead of a - shape-``(1,)`` array. -* Fixed ``make current-docs`` failing to import - :mod:`isaaclab_mimic.datagen` because the ``assemble_trocar`` robot - config evaluated ``np.pi`` at module scope, which raised - ``TypeError`` under Sphinx's mocked ``numpy``. Switched the constant - factors to :data:`math.pi`. diff --git a/source/isaaclab_tasks/changelog.d/jichuanh-ik-newton-compat-mvp.rst b/source/isaaclab_tasks/changelog.d/jichuanh-ik-newton-compat-mvp.rst deleted file mode 100644 index 361dbe9e52b1..000000000000 --- a/source/isaaclab_tasks/changelog.d/jichuanh-ik-newton-compat-mvp.rst +++ /dev/null @@ -1,13 +0,0 @@ -Changed -^^^^^^^ - -* Removed the ``self.sim.physics = PhysxCfg(...)`` overrides from - ``Isaac-Reach-Franka-{IK-Abs,IK-Rel,OSC}-v0`` env configs so they - inherit the parent ``ReachPhysicsCfg`` preset. Selecting - ``presets=newton`` now picks ``NewtonCfg``; the previous - ``bounce_threshold_velocity=0.2`` PhysX behavior is preserved as - the default in ``ReachPhysicsCfg``. Direct-workflow callers in - ``automate``, ``factory``, and the deploy MDP events module were - migrated to the new - :class:`~isaaclab.assets.BaseArticulationData` properties - (:attr:`body_link_jacobian_w`, :attr:`mass_matrix`). diff --git a/source/isaaclab_tasks/changelog.d/jichuanh-shadow-hand-newton-parity.minor.rst b/source/isaaclab_tasks/changelog.d/jichuanh-shadow-hand-newton-parity.minor.rst deleted file mode 100644 index 2450c041c6dc..000000000000 --- a/source/isaaclab_tasks/changelog.d/jichuanh-shadow-hand-newton-parity.minor.rst +++ /dev/null @@ -1,16 +0,0 @@ -Added -^^^^^ - -* Added Newton backend support for the multi-agent - ``Isaac-Shadow-Hand-Over-Direct-v0`` (MAPPO/IPPO) env. Mirrors the - single-agent Shadow Hand Newton port: per-hand - :class:`~isaaclab.actuators.ImplicitActuatorCfg`, - ``shadow_hand_instanceable_newton.usd``, per-backend - :class:`~isaaclab_tasks.utils.PresetCfg` wrappers for sim physics, the - hand-over object (``RigidObjectCfg`` on both backends, dropping - PhysX-only knobs on Newton), and the two robot configs. Selectable via - ``--preset newton`` / Hydra preset resolution; PhysX behavior unchanged. - Migration details (Newton-side actuator gain overrides for ``fingers`` - and ``distal_passive``, and the ``ccd_iterations`` bump for multi-finger - contacts) live in - ``source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env_cfg.py``. diff --git a/source/isaaclab_tasks/changelog.d/rsl-rl-model-configs.rst b/source/isaaclab_tasks/changelog.d/rsl-rl-model-configs.rst deleted file mode 100644 index 3ef1b32d5d07..000000000000 --- a/source/isaaclab_tasks/changelog.d/rsl-rl-model-configs.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Changed RSL-RL task agent configs to use ``actor`` and ``critic`` model - configs with distribution configs instead of deprecated ``policy`` configs. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 93ec41a16c75..f8894daa24b6 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.38" +version = "1.6.0" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 6f97bf866892..5f61ba12b099 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,57 @@ Changelog --------- +1.6.0 (2026-05-14) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added Newton backend support for the multi-agent + ``Isaac-Shadow-Hand-Over-Direct-v0`` (MAPPO/IPPO) env. Mirrors the + single-agent Shadow Hand Newton port: per-hand + :class:`~isaaclab.actuators.ImplicitActuatorCfg`, + ``shadow_hand_instanceable_newton.usd``, per-backend + :class:`~isaaclab_tasks.utils.PresetCfg` wrappers for sim physics, the + hand-over object (``RigidObjectCfg`` on both backends, dropping + PhysX-only knobs on Newton), and the two robot configs. Selectable via + ``--preset newton`` / Hydra preset resolution; PhysX behavior unchanged. + Migration details (Newton-side actuator gain overrides for ``fingers`` + and ``distal_passive``, and the ``ccd_iterations`` bump for multi-finger + contacts) live in + ``source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env_cfg.py``. + +Changed +^^^^^^^ + +* Removed the ``self.sim.physics = PhysxCfg(...)`` overrides from + ``Isaac-Reach-Franka-{IK-Abs,IK-Rel,OSC}-v0`` env configs so they + inherit the parent ``ReachPhysicsCfg`` preset. Selecting + ``presets=newton`` now picks ``NewtonCfg``; the previous + ``bounce_threshold_velocity=0.2`` PhysX behavior is preserved as + the default in ``ReachPhysicsCfg``. Direct-workflow callers in + ``automate``, ``factory``, and the deploy MDP events module were + migrated to the new + :class:`~isaaclab.assets.BaseArticulationData` properties + (:attr:`body_link_jacobian_w`, :attr:`mass_matrix`). +* Changed RSL-RL task agent configs to use ``actor`` and ``critic`` model + configs with distribution configs instead of deprecated ``policy`` configs. + +Fixed +^^^^^ + +* Fixed ``Isaac-Navigation-3DObstacles-ARL-Robot-1-v0`` config load + raising ``TypeError: only 0-dimensional arrays can be converted to + Python scalars`` under NumPy 2.0+. The wall-color sampling now + requests a scalar from :func:`numpy.random.randint` instead of a + shape-``(1,)`` array. +* Fixed ``make current-docs`` failing to import + :mod:`isaaclab_mimic.datagen` because the ``assemble_trocar`` robot + config evaluated ``np.pi`` at module scope, which raised + ``TypeError`` under Sphinx's mocked ``numpy``. Switched the constant + factors to :data:`math.pi`. + + 1.5.38 (2026-05-13) ~~~~~~~~~~~~~~~~~~~ From ac538fd8133fce8f9afb4f042816981a7b8d2290 Mon Sep 17 00:00:00 2001 From: frlai Date: Thu, 14 May 2026 22:49:50 +0800 Subject: [PATCH 060/133] Fix leapp docs (#5512) 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 --- .../exporting_policies_with_leapp.rst | 2 +- ...ng_direct_workflow_policies_with_leapp.rst | 31 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/source/policy_deployment/05_leapp/exporting_policies_with_leapp.rst b/docs/source/policy_deployment/05_leapp/exporting_policies_with_leapp.rst index dc07a42fc141..c8eb20df178e 100644 --- a/docs/source/policy_deployment/05_leapp/exporting_policies_with_leapp.rst +++ b/docs/source/policy_deployment/05_leapp/exporting_policies_with_leapp.rst @@ -26,7 +26,7 @@ LEAPP requires Python >= 3.8 and PyTorch >= 2.6. Install it with: .. code-block:: bash - pip install leapp + ./isaaclab.sh -p -m pip install leapp Ensure you have a trained RSL-RL checkpoint before proceeding. The standard Isaac Lab training workflow produces checkpoints under ``logs/rsl_rl//``. diff --git a/docs/source/tutorials/06_exporting/exporting_direct_workflow_policies_with_leapp.rst b/docs/source/tutorials/06_exporting/exporting_direct_workflow_policies_with_leapp.rst index 1ce0aa3a82e6..01d26963b1dc 100644 --- a/docs/source/tutorials/06_exporting/exporting_direct_workflow_policies_with_leapp.rst +++ b/docs/source/tutorials/06_exporting/exporting_direct_workflow_policies_with_leapp.rst @@ -19,12 +19,27 @@ dormant during normal environment execution and only add a small amount of overhead until export time. They are activated by ``scripts/reinforcement_learning/leapp/rsl_rl/export.py`` when you run the export flow. -This tutorial uses ``scripts/tutorials/06_deploy/anymal_c_env.py`` as the example. -The script is based on the existing ANYmal-C direct environment at -``source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env.py`` and adds -the annotations needed to make it compatible with the export script. Once you have added -the annotations to your direct RL environment, you can export a trained policy -with: +This tutorial uses ``scripts/tutorials/06_deploy/anymal_c_env.py`` as a concrete +example of adding LEAPP annotations to a Direct workflow environment. Apply the same +annotation pattern to your own Direct RL environment. + +Before exporting, install LEAPP into the Isaac Lab Python environment: + +.. code-block:: bash + + ./isaaclab.sh -p -m pip install leapp + +If you want to run the exported example with the existing +``Isaac-Velocity-Rough-Anymal-C-Direct-v0`` task registration, copy the annotated +tutorial environment into the task package: + +.. code-block:: bash + + cp scripts/tutorials/06_deploy/anymal_c_env.py \ + source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env.py + +After your environment includes the required LEAPP input, output, and state +annotations, export a trained policy with: .. code-block:: bash @@ -41,7 +56,7 @@ artifacts. If you omit it, the export is written next to the checkpoint. .. warning:: - This tutorial covers exporting Direct workflow policies only. Direct workflow + This tutorial covers exporting direct rl policies only. direct rl policies are not currently supported by ``scripts/reinforcement_learning/leapp/deploy.py``. @@ -54,7 +69,7 @@ For more information on the export arguments, see the .. literalinclude:: ../../../../scripts/tutorials/06_deploy/anymal_c_env.py :language: python - :emphasize-lines: 20, 100-118, 85-88 + :emphasize-lines: 20, 75-77, 92-105 :linenos: From 98d4bbab5d4c767a6b964d98738599d8539f3f41 Mon Sep 17 00:00:00 2001 From: hujc Date: Thu, 14 May 2026 10:04:13 -0700 Subject: [PATCH 061/133] [Changelog] Fix orphan paragraph in 5.2.0 entry and gate future fragments (#5611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `Build Latest Docs` is failing on every open PR with: ``` source/isaaclab/docs/CHANGELOG.rst:35: ERROR: Unexpected indentation. [docutils] build finished with problems, 1 warning (with warnings treated as errors). ``` (failing job: https://github.com/isaac-sim/IsaacLab/actions/runs/25847575509/job/75946304877) The 2026-05-14 auto-version-bump (`b65a1ac2b73`) compiled fragment `source/isaaclab/changelog.d/jichuanh-ik-newton-compat-mvp.minor.rst` verbatim into the new `5.2.0` block. That fragment contained a flush-left paragraph inside the `Added` bullet list, which Sphinx `-W` rejects. ## Fix 1. **Repromote the orphan paragraph to a `*` bullet** in `source/isaaclab/docs/CHANGELOG.rst` so the bullet list under `Added` stays well-formed. 2. **Catch the same shape in future fragments**: `Fragment.validate()` now scans every section body and rejects any non-blank line that is neither a `* ` bullet start nor a continuation (leading whitespace). The error message points back at the exact offending line so the contributor sees it in the `Changelog Fragment Check` PR gate output before merge. Replayed the new check against all 131 historical fragments — it flags exactly one, the one that caused this incident. Zero false positives elsewhere. ## Test plan - [x] `pytest tools/changelog/test/` — 83 pass (24 prior validate tests + 1 new orphan-paragraph fixture test). - [x] Validator on the historical bad fragment returns the new orphan-paragraph error message. - [x] `pre-commit run` on all touched files — clean. - [x] `Build Latest Docs` will pass on this PR (the `Unexpected indentation` line is gone). ## Files - `source/isaaclab/docs/CHANGELOG.rst` — bullet-prefix the orphan paragraph. - `tools/changelog/cli.py` — orphan-paragraph rejection in `Fragment.validate()`. - `tools/changelog/test/test_validate.py` + `test/invalid_content/3004.rst` — fixture + test for the new rule. - `source/isaaclab/changelog.d/jichuanh-fix-docs-changelog-indentation.skip` — satisfies the gate (CHANGELOG-only edit, no user-visible entry needed). --- ...chuanh-fix-docs-changelog-indentation.skip | 0 source/isaaclab/docs/CHANGELOG.rst | 18 +++++++-------- tools/changelog/cli.py | 22 +++++++++++++++++++ tools/changelog/test/invalid_content/3004.rst | 9 ++++++++ tools/changelog/test/test_validate.py | 8 +++++++ 5 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-fix-docs-changelog-indentation.skip create mode 100644 tools/changelog/test/invalid_content/3004.rst diff --git a/source/isaaclab/changelog.d/jichuanh-fix-docs-changelog-indentation.skip b/source/isaaclab/changelog.d/jichuanh-fix-docs-changelog-indentation.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 8cc881bae674..8c87cd5862b9 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -25,15 +25,15 @@ Added base). Use it to map an actuated-joint index ``j`` to its column in the Jacobian / mass matrix / gravity vector via ``j + num_base_dofs``. -The Jacobian / mass-matrix / gravity-comp DoF axis includes the floating- -base DoFs at the front: shape ``(N, num_jacobi_bodies, 6, num_joints + -num_base_dofs)`` for the Jacobian and ``(N, num_joints + num_base_dofs, -num_joints + num_base_dofs)`` for the mass matrix. This matches the -cross-library industry convention (Pinocchio's ``nv = 6 + n_actuated``, -Drake's ephemeral floating joint, MuJoCo's ````, RBDL's -``JointTypeFloatingBase``, OCS2's ``generalizedCoordinatesNum = -6 + actuatedJointsNum``, iDynTree's ``getFreeFloatingMassMatrix`` -returning ``(6 + dofs, 6 + dofs)``). +* The Jacobian / mass-matrix / gravity-comp DoF axis includes the floating- + base DoFs at the front: shape ``(N, num_jacobi_bodies, 6, num_joints + + num_base_dofs)`` for the Jacobian and ``(N, num_joints + num_base_dofs, + num_joints + num_base_dofs)`` for the mass matrix. This matches the + cross-library industry convention (Pinocchio's ``nv = 6 + n_actuated``, + Drake's ephemeral floating joint, MuJoCo's ````, RBDL's + ``JointTypeFloatingBase``, OCS2's ``generalizedCoordinatesNum = + 6 + actuatedJointsNum``, iDynTree's ``getFreeFloatingMassMatrix`` + returning ``(6 + dofs, 6 + dofs)``). * Added :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.usd_stage`, :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.num_envs`, and :meth:`~isaaclab.scene.scene_data_provider.SceneDataProvider.get_camera_transforms` diff --git a/tools/changelog/cli.py b/tools/changelog/cli.py index 16ad551b4ca3..d316a416f89f 100644 --- a/tools/changelog/cli.py +++ b/tools/changelog/cli.py @@ -299,6 +299,28 @@ def validate(self) -> str | None: f"section(s) {', '.join(repr(s) for s in empty)} have no bullet entries — " "use ``* `` to start each entry, or remove the heading" ) + # Every line inside a section body must be a bullet (``* ``), a + # continuation (leading whitespace), or blank. A column-0 non-blank + # line that isn't a bullet terminates the list under RST rules and + # then sits as a paragraph adjacent to the next ``* `` — which the + # compile step splices into ``CHANGELOG.rst`` under the same + # ``^^^`` subheading and Sphinx then rejects with + # ``Unexpected indentation``. Catch it here before merge. + for section, lines in sections.items(): + for offset, line in enumerate(lines): + if not line.strip(): + continue + if line[0].isspace() or line.lstrip().startswith("*"): + continue + snippet = line.strip()[:80] + return ( + f"section {section!r} contains an orphan paragraph " + f"(non-bullet line {offset + 1}: {snippet!r}). Every line under " + "a section heading must start with ``* `` (new bullet) or whitespace " + "(continuation of the previous bullet). A flush-left paragraph here " + "splits the bullet list and Sphinx fails the doc build with " + "``Unexpected indentation``." + ) return None diff --git a/tools/changelog/test/invalid_content/3004.rst b/tools/changelog/test/invalid_content/3004.rst new file mode 100644 index 000000000000..57aa8df9bb87 --- /dev/null +++ b/tools/changelog/test/invalid_content/3004.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added ``foo()`` to support feature X. + +This is a free-form paragraph that lives at column 0 inside the Added +section, neither a bullet nor a continuation of the previous bullet. +The bot would splice this verbatim into ``CHANGELOG.rst`` and Sphinx +then rejects the build with ``Unexpected indentation [docutils]``. diff --git a/tools/changelog/test/test_validate.py b/tools/changelog/test/test_validate.py index e02e1b95e990..fd1af7a31dfe 100644 --- a/tools/changelog/test/test_validate.py +++ b/tools/changelog/test/test_validate.py @@ -70,6 +70,14 @@ def test_validate_rejects_section_without_bullets_from_fixture(): assert err is not None and "bullet" in err.lower() +def test_validate_rejects_orphan_paragraph_from_fixture(): + """A flush-left paragraph between bullets / after the last bullet must be + rejected — the compile step would splice it verbatim into ``CHANGELOG.rst`` + and Sphinx then fails the doc build with ``Unexpected indentation``.""" + err = cli.Fragment(FIXTURES / "invalid_content" / "3004.rst").validate() + assert err is not None and "orphan" in err.lower() + + # --------------------------------------------------------------------------- # check_fragments — gate orchestration: immutability, slug uniqueness, and # the "PR must add at least one fragment per touched package" rule From e0a217da18d301a43997a788621f4aed51fc2e7f Mon Sep 17 00:00:00 2001 From: jmart-nv Date: Thu, 14 May 2026 13:17:15 -0500 Subject: [PATCH 062/133] OMPE-92490: Fix singular rotation matrix and non-rotation quaternion (#5609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description When calculating the "look-at" quaternion for a camera, an **orthonormal** rotation matrix is first calculated using the camera's eye position, look-at target, and world up vectors: - `forward = target - eye` *("camera forward")* - `camera_z = -normalize(forward)` *("camera backward")* - `camera_x = world_up × camera_z ` *("camera right")* - `camera_y = camera_z × camera_x` *("camera up")* - return `R = [camera_x, camera_y, camera_z ]` *(OpenGL convention)* However, if `forward` is parallel to `world_up` then the cross product `camera_x` is zero, leading to a **singular** non-invertible matrix returned from `create_rotation_matrix_from_view()`. Then `quat_from_matrix()` would silently convert this to a non-unit quaternion and return this garbage back to the caller. This change fixes both issues as follows: **`create_rotation_matrix_from_view`:** - When the cross product collapses, it falls back on the world X-axis as an alternate `world_up` vector and re-calculates the matrix. - Previously, `camera_y × camera_z` was used as the fallback, which was already zero due to the problem described above. - X-axis is guaranteed to be perpendicular to `world_up` since `world_up` is restricted to Y or Z. - When truly undefined input is provided (`eye == target` or non-finite values) it now returns per-row `NaN` that the caller can detect and handle. **`quat_from_matrix`:** - Now returns `NaN` when the input is not a valid rotation matrix (singular, reflection, or non-orthonormal). All callers in IsaacLab have been updated to detect `NaN` where appropriate and fail gracefully, or avoid passing degenerate input altogether where possible. Added 11 new unit tests and removed the 0.1 x-nudge workaround from the integration tests (PR #5470 and #5380) ## 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 --- .../changelog.d/jmart-singular-rotation.rst | 16 +++ .../isaaclab/sensors/camera/camera.py | 27 ++++- .../sensors/ray_caster/ray_caster_camera.py | 28 ++++- source/isaaclab/isaaclab/utils/math.py | 43 +++++-- .../test_multi_mesh_ray_caster_camera.py | 4 +- .../test/sensors/test_ray_caster_camera.py | 4 +- source/isaaclab/test/utils/test_math.py | 114 ++++++++++++++++++ .../changelog.d/jmart-singular-rotation.rst | 7 ++ .../isaaclab_newton/sensors/pva/pva.py | 29 +++-- .../changelog.d/jmart-singular-rotation.rst | 7 ++ .../isaaclab_physx/sensors/pva/pva.py | 29 +++-- 11 files changed, 266 insertions(+), 42 deletions(-) create mode 100644 source/isaaclab/changelog.d/jmart-singular-rotation.rst create mode 100644 source/isaaclab_newton/changelog.d/jmart-singular-rotation.rst create mode 100644 source/isaaclab_physx/changelog.d/jmart-singular-rotation.rst diff --git a/source/isaaclab/changelog.d/jmart-singular-rotation.rst b/source/isaaclab/changelog.d/jmart-singular-rotation.rst new file mode 100644 index 000000000000..61b703707531 --- /dev/null +++ b/source/isaaclab/changelog.d/jmart-singular-rotation.rst @@ -0,0 +1,16 @@ +Fixed +^^^^^ + +* Fixed :func:`~isaaclab.utils.math.create_rotation_matrix_from_view` returning a singular + matrix when the look-at direction was parallel to the up axis. The function now produces + a valid orthonormal frame via an alternate reference vector, and fills NaN for rows with + truly undefined forward direction (``eyes == targets`` or non-finite input). Callers + detect per-row failure with ``torch.isnan(R).any(dim=(-2, -1))``. +* Fixed :func:`~isaaclab.utils.math.quat_from_matrix` silently returning a non-unit + quaternion for non-rotation input (singular, reflection, or scale-error matrices). + Such inputs now return NaN, detectable via :func:`torch.isnan`. +* Fixed :meth:`~isaaclab.sensors.camera.Camera.set_world_poses_from_view` and + :meth:`~isaaclab.sensors.ray_caster.RayCasterCamera.set_world_poses_from_view` silently + applying garbage poses when an eye position equaled its target. Degenerate rows are now + skipped (with a logged warning), and ``ValueError`` is raised if every row in the batch + is degenerate. diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index c481002e524e..1dc05becae9b 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -314,16 +314,33 @@ def set_world_poses_from_view( Raises: RuntimeError: If the camera prim is not set. Need to call :meth:`initialize` method first. NotImplementedError: If the stage up-axis is not "Y" or "Z". + ValueError: If every eye position equals its target (look-at direction undefined for the + whole batch). When only some rows are degenerate, those rows are skipped and the + remaining poses are still applied; a warning is logged. """ - # resolve env_ids + # resolve env_ids to a tensor up front so we can index it during partial-failure filtering if env_ids is None: env_ids = self._ALL_INDICES - # get up axis of current stage - 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)) if not isinstance(env_ids, torch.Tensor): env_ids = torch.tensor(env_ids, dtype=torch.int32, device=self._device) + # get up axis of current stage + up_axis = UsdGeom.GetStageUpAxis(self.stage) + # set camera poses using the view; degenerate rows (eye == target) come back as NaN + rotation_matrix = create_rotation_matrix_from_view(eyes, targets, up_axis, device=self._device) + valid_indices = (~torch.isnan(rotation_matrix).any(dim=(-2, -1))).nonzero(as_tuple=True)[0] + n_valid = valid_indices.numel() + n_total = rotation_matrix.shape[0] + if n_valid == 0: + raise ValueError("look-at is undefined: every eye position equals its target") + if n_valid < n_total: + logger.warning( + "set_world_poses_from_view: skipping %d pose(s) where eye equals target", + n_total - n_valid, + ) + rotation_matrix = rotation_matrix.index_select(0, valid_indices) + eyes = eyes.index_select(0, valid_indices) + env_ids = env_ids.index_select(0, valid_indices) + orientations = quat_from_matrix(rotation_matrix) 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) 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 257b25698deb..a9b7239b8991 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py @@ -252,13 +252,35 @@ def set_world_poses_from_view( Raises: RuntimeError: If the camera prim is not set. Need to call :meth:`initialize` method first. NotImplementedError: If the stage up-axis is not "Y" or "Z". + ValueError: If every eye position equals its target (look-at direction undefined for the + whole batch). When only some rows are degenerate, those rows are skipped and the + remaining poses are still applied; a warning is logged. """ + # resolve env_ids to a tensor up front so we can index it during partial-failure filtering + if env_ids is None: + env_ids = self._ALL_INDICES + if not isinstance(env_ids, torch.Tensor): + env_ids = torch.tensor(env_ids, dtype=torch.long, device=self._device) # get up axis of current stage up_axis = UsdGeom.GetStageUpAxis(self.stage) - # camera position and rotation in opengl convention - orientations = math_utils.quat_from_matrix( - math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis=up_axis, device=self._device) + # camera position and rotation in opengl convention; degenerate rows (eye == target) come back as NaN + rotation_matrix = math_utils.create_rotation_matrix_from_view( + eyes, targets, up_axis=up_axis, device=self._device ) + valid_indices = (~torch.isnan(rotation_matrix).any(dim=(-2, -1))).nonzero(as_tuple=True)[0] + n_valid = valid_indices.numel() + n_total = rotation_matrix.shape[0] + if n_valid == 0: + raise ValueError("look-at is undefined: every eye position equals its target") + if n_valid < n_total: + logger.warning( + "set_world_poses_from_view: skipping %d pose(s) where eye equals target", + n_total - n_valid, + ) + rotation_matrix = rotation_matrix.index_select(0, valid_indices) + eyes = eyes.index_select(0, valid_indices) + env_ids = env_ids.index_select(0, valid_indices) + orientations = math_utils.quat_from_matrix(rotation_matrix) self.set_world_poses(eyes, orientations, env_ids, convention="opengl") """ diff --git a/source/isaaclab/isaaclab/utils/math.py b/source/isaaclab/isaaclab/utils/math.py index b01d7e7ab4fe..9b33252091aa 100644 --- a/source/isaaclab/isaaclab/utils/math.py +++ b/source/isaaclab/isaaclab/utils/math.py @@ -322,7 +322,9 @@ def quat_from_matrix(matrix: torch.Tensor) -> torch.Tensor: matrix: The rotation matrices. Shape is (..., 3, 3). Returns: - The quaternion in (x, y, z, w). Shape is (..., 4). + The quaternion in (x, y, z, w). Shape is (..., 4). Rows whose input is not a + valid rotation (e.g. singular, reflection, or scale-error matrices) are filled + with NaN, so callers can detect them via :func:`torch.isnan`. Reference: https://github.com/facebookresearch/pytorch3d/blob/main/pytorch3d/transforms/rotation_conversions.py#L102-L161 @@ -368,9 +370,13 @@ def quat_from_matrix(matrix: torch.Tensor) -> torch.Tensor: # if not for numerical problems, quat_candidates[i] should be same (up to a sign), # forall i; we pick the best-conditioned one (with the largest denominator) - return quat_candidates[torch.nn.functional.one_hot(q_abs.argmax(dim=-1), num_classes=4) > 0.5, :].reshape( + quat = quat_candidates[torch.nn.functional.one_hot(q_abs.argmax(dim=-1), num_classes=4) > 0.5, :].reshape( batch_dim + (4,) ) + # guard against non-rotation input: a valid rotation must yield a unit quaternion. + # Threshold is 2x the worst-case float32 accumulated error (~1e-5) through this function. + invalid = (quat.norm(p=2, dim=-1, keepdim=True) - 1.0).abs() > 2e-5 + return torch.where(invalid, torch.full_like(quat, float("nan")), quat) def _axis_angle_rotation(axis: Literal["X", "Y", "Z"], angle: torch.Tensor) -> torch.Tensor: @@ -1633,7 +1639,17 @@ def create_rotation_matrix_from_view( The vectors are broadcast against each other so they all have shape (N, 3). Returns: - R: (N, 3, 3) batched rotation matrices + ``(N, 3, 3)`` batched rotation matrices. Rows with an undefined forward + direction (``eyes == targets`` or non-finite input) are filled with NaN. + Callers detect per-row failure with ``torch.isnan(R).any(dim=(-2, -1))`` + and total failure with ``.all()``. + + Note: + When the look-at direction is parallel to ``up_axis`` the camera roll + is mathematically undefined; a deterministic frame is returned via an + alternate reference vector. Tracking a target continuously through the + singularity will produce a discontinuous rotation -- smooth tracking + requires interpolation at the caller (e.g., quaternion slerp). Reference: Based on PyTorch3D (https://github.com/facebookresearch/pytorch3d/blob/eaf0709d6af0025fe94d1ee7cec454bc3054826a/pytorch3d/renderer/cameras.py#L1635-L1685) @@ -1645,16 +1661,27 @@ def create_rotation_matrix_from_view( else: raise ValueError(f"Invalid up axis: {up_axis}. Valid options are 'Y' and 'Z'.") + forward = targets - eyes + # 1e-5 matches the torch.nn.functional.normalize eps below: smaller magnitudes produce a sub-unit z_axis + undefined_forward = (torch.linalg.norm(forward, dim=1, keepdim=True) < 1e-5) | ~torch.isfinite(forward).all( + dim=1, keepdim=True + ) + # get rotation matrix in opengl format (-Z forward, +Y up) - z_axis = -torch.nn.functional.normalize(targets - eyes, eps=1e-5) + z_axis = -torch.nn.functional.normalize(forward, eps=1e-5) x_axis = torch.nn.functional.normalize(torch.cross(up_axis_vec, z_axis, dim=1), eps=1e-5) y_axis = torch.nn.functional.normalize(torch.cross(z_axis, x_axis, dim=1), eps=1e-5) is_close = torch.isclose(x_axis, torch.tensor(0.0), atol=5e-3).all(dim=1, keepdim=True) if is_close.any(): - replacement = torch.nn.functional.normalize(torch.cross(y_axis, z_axis, dim=1), eps=1e-5) - x_axis = torch.where(is_close, replacement, x_axis) - R = torch.cat((x_axis[:, None, :], y_axis[:, None, :], z_axis[:, None, :]), dim=1) - return R.transpose(1, 2) + # alt-up substitution when up_axis_vec is parallel to z_axis; both x and y must be recomputed. + # World X is non-parallel to z whenever the symptom fires for the supported up_axis values. + alt_up = torch.tensor((1.0, 0.0, 0.0), device=device, dtype=torch.float32).repeat(eyes.shape[0], 1) + replacement_x = torch.nn.functional.normalize(torch.cross(alt_up, z_axis, dim=1), eps=1e-5) + replacement_y = torch.nn.functional.normalize(torch.cross(z_axis, replacement_x, dim=1), eps=1e-5) + x_axis = torch.where(is_close, replacement_x, x_axis) + y_axis = torch.where(is_close, replacement_y, y_axis) + R = torch.cat((x_axis[:, None, :], y_axis[:, None, :], z_axis[:, None, :]), dim=1).transpose(1, 2) + return torch.where(undefined_forward.unsqueeze(-1), torch.full_like(R, float("nan")), R) def make_pose(pos: torch.Tensor, rot: torch.Tensor) -> torch.Tensor: 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 7e7efe16d091..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 @@ -752,11 +752,11 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_simulation): # set camera position camera_warp.set_world_poses_from_view( - eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_warp.device), + eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_warp.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_warp.device), ) camera_usd.set_world_poses_from_view( - eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_usd.device), + eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_usd.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_usd.device), ) diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index 752734936934..cc10b092a806 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -898,11 +898,11 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_sim, focal_length_ # set camera position camera_warp.set_world_poses_from_view( - eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_warp.device), + eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_warp.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_warp.device), ) camera_usd.set_world_poses_from_view( - eyes=torch.tensor([[0.1, 0.0, 5.0]], device=camera_usd.device), + eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_usd.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_usd.device), ) diff --git a/source/isaaclab/test/utils/test_math.py b/source/isaaclab/test/utils/test_math.py index 6bff0b31e267..000bf00eb859 100644 --- a/source/isaaclab/test/utils/test_math.py +++ b/source/isaaclab/test/utils/test_math.py @@ -1326,3 +1326,117 @@ def test_euler_xyz_from_quat(): wrapped = expected % (2 * torch.pi) output = torch.stack(math_utils.euler_xyz_from_quat(quat, wrap_to_2pi=True), dim=-1) torch.testing.assert_close(output, wrapped) + + +@pytest.mark.parametrize("device", ("cpu", "cuda:0")) +def test_create_rotation_matrix_from_view_lookat_along_up_axis_z(device): + """Camera above target on +Z axis with Z-up should return a valid orthonormal frame.""" + eyes = torch.tensor([[0.0, 0.0, 5.0]], device=device) + targets = torch.tensor([[0.0, 0.0, 0.0]], device=device) + R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Z", device=device) + assert R is not None + identity = torch.eye(3, device=device).expand(1, 3, 3) + torch.testing.assert_close(R @ R.transpose(-1, -2), identity, atol=1e-5, rtol=1e-5) + torch.testing.assert_close(torch.linalg.det(R), torch.ones(1, device=device), atol=1e-5, rtol=1e-5) + + +@pytest.mark.parametrize("device", ("cpu", "cuda:0")) +def test_create_rotation_matrix_from_view_lookat_along_up_axis_y(device): + """Camera at +Y looking at origin with Y-up should return a valid orthonormal frame.""" + eyes = torch.tensor([[0.0, 5.0, 0.0]], device=device) + targets = torch.tensor([[0.0, 0.0, 0.0]], device=device) + R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Y", device=device) + assert R is not None + identity = torch.eye(3, device=device).expand(1, 3, 3) + torch.testing.assert_close(R @ R.transpose(-1, -2), identity, atol=1e-5, rtol=1e-5) + torch.testing.assert_close(torch.linalg.det(R), torch.ones(1, device=device), atol=1e-5, rtol=1e-5) + + +@pytest.mark.parametrize("device", ("cpu", "cuda:0")) +def test_create_rotation_matrix_from_view_lookat_along_negative_up_axis(device): + """Camera below target looking up (-Z alignment with Z-up) should return a valid orthonormal frame.""" + eyes = torch.tensor([[0.0, 0.0, -5.0]], device=device) + targets = torch.tensor([[0.0, 0.0, 0.0]], device=device) + R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Z", device=device) + assert R is not None + identity = torch.eye(3, device=device).expand(1, 3, 3) + torch.testing.assert_close(R @ R.transpose(-1, -2), identity, atol=1e-5, rtol=1e-5) + torch.testing.assert_close(torch.linalg.det(R), torch.ones(1, device=device), atol=1e-5, rtol=1e-5) + + +@pytest.mark.parametrize("device", ("cpu", "cuda:0")) +def test_create_rotation_matrix_from_view_zero_forward_returns_nan(device): + """When eyes == targets the forward direction is undefined; all entries of the row are NaN.""" + eyes = torch.tensor([[1.0, 2.0, 3.0]], device=device) + targets = eyes.clone() + R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Z", device=device) + assert torch.isnan(R).all() + + +@pytest.mark.parametrize("device", ("cpu", "cuda:0")) +def test_create_rotation_matrix_from_view_batched_partial_failure(device): + """Mixed batch with one degenerate row should produce NaN in that row and a valid rotation in the other.""" + eyes = torch.tensor([[1.0, 2.0, 3.0], [0.0, 0.0, 5.0]], device=device) + targets = torch.tensor([[1.0, 2.0, 3.0], [0.0, 0.0, 0.0]], device=device) + R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Z", device=device) + assert torch.isnan(R[0]).any() + torch.testing.assert_close(R[1] @ R[1].T, torch.eye(3, device=device), atol=1e-5, rtol=1e-5) + torch.testing.assert_close(torch.linalg.det(R[1]), torch.tensor(1.0, device=device), atol=1e-5, rtol=1e-5) + + +@pytest.mark.parametrize("device", ("cpu", "cuda:0")) +def test_quat_from_matrix_unit_norm_on_valid_input(device): + """quat_from_matrix should produce unit quaternions for any valid rotation matrix.""" + n = 100 + q_rand = math_utils.random_orientation(num=n, device=device) + rot_mat = math_utils.matrix_from_quat(q_rand) + q_value = math_utils.quat_from_matrix(rot_mat) + norms = torch.linalg.norm(q_value, dim=-1) + torch.testing.assert_close(norms, torch.ones(n, device=device), atol=1e-5, rtol=1e-5) + + +@pytest.mark.parametrize("device", ("cpu", "cuda:0")) +def test_quat_from_matrix_singular_matrix_returns_nan(device): + """quat_from_matrix on a singular (non-rotation) matrix should signal NaN, not garbage.""" + singular = torch.tensor([[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]]], device=device) + q = math_utils.quat_from_matrix(singular) + assert torch.isnan(q).all() + + +@pytest.mark.parametrize("device", ("cpu", "cuda:0")) +def test_create_rotation_matrix_from_view_standard(device): + """Sanity: off-axis eye produces an orthonormal frame whose z-axis points from target back to eye.""" + eyes = torch.tensor([[3.0, 0.0, 4.0]], device=device) + targets = torch.tensor([[0.0, 0.0, 0.0]], device=device) + R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Z", device=device) + identity = torch.eye(3, device=device).expand(1, 3, 3) + torch.testing.assert_close(R @ R.transpose(-1, -2), identity, atol=1e-5, rtol=1e-5) + torch.testing.assert_close(torch.linalg.det(R), torch.ones(1, device=device), atol=1e-5, rtol=1e-5) + # z_axis is back-of-camera in OpenGL convention: points from target to eye + expected_z = torch.tensor([[0.6, 0.0, 0.8]], device=device) + torch.testing.assert_close(R[:, :, 2], expected_z, atol=1e-5, rtol=1e-5) + + +@pytest.mark.parametrize("device", ("cpu", "cuda:0")) +def test_create_rotation_matrix_from_view_non_finite_returns_nan(device): + """Non-finite input (NaN or Inf in eyes/targets) should produce NaN rows.""" + eyes = torch.tensor([[float("nan"), 0.0, 0.0]], device=device) + targets = torch.tensor([[0.0, 0.0, 0.0]], device=device) + R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Z", device=device) + assert torch.isnan(R).all() + + +@pytest.mark.parametrize("device", ("cpu", "cuda:0")) +def test_quat_from_matrix_reflection_returns_nan(device): + """A reflection matrix (det = -1) is not a proper rotation; the safeguard should signal NaN.""" + reflection = torch.diag(torch.tensor([1.0, 1.0, -1.0], device=device)).unsqueeze(0) + q = math_utils.quat_from_matrix(reflection) + assert torch.isnan(q).all() + + +@pytest.mark.parametrize("device", ("cpu", "cuda:0")) +def test_quat_from_matrix_non_orthonormal_returns_nan(device): + """A non-orthonormal matrix (1% scale error on one axis) is not a valid rotation; expect NaN.""" + R = torch.diag(torch.tensor([1.01, 1.0, 1.0], device=device)).unsqueeze(0) + q = math_utils.quat_from_matrix(R) + assert torch.isnan(q).all() diff --git a/source/isaaclab_newton/changelog.d/jmart-singular-rotation.rst b/source/isaaclab_newton/changelog.d/jmart-singular-rotation.rst new file mode 100644 index 000000000000..3e479713b7b1 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/jmart-singular-rotation.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed the acceleration-arrow debug visualizer in + :class:`~isaaclab_newton.sensors.pva.Pva` drawing arrows in undefined directions for + bodies with effectively zero acceleration. Such bodies are now skipped from the + visualization. diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/pva/pva.py b/source/isaaclab_newton/isaaclab_newton/sensors/pva/pva.py index c000e17c437a..437140accdf6 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/pva/pva.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/pva/pva.py @@ -214,21 +214,28 @@ def _debug_vis_callback(self, event): # arrow scale default_scale = self.acceleration_visualizer.cfg.markers["arrow"].scale arrow_scale = torch.tensor(default_scale, device=self.device).repeat(self._data.lin_acc_b.torch.shape[0], 1) - # arrow direction from acceleration + # arrow direction from acceleration; filter out bodies with effectively zero accel (no defined direction) up_axis = UsdGeom.GetStageUpAxis(self.stage) pos_w_torch = self._data.pos_w.torch - quat_w_torch = self._data.quat_w.torch - lin_acc_b_torch = self._data.lin_acc_b.torch - quat_opengl = math_utils.quat_from_matrix( - math_utils.create_rotation_matrix_from_view( - pos_w_torch, - pos_w_torch + math_utils.quat_apply(quat_w_torch, lin_acc_b_torch), - up_axis=up_axis, - device=self._device, - ) + accel_w = math_utils.quat_apply(self._data.quat_w.torch, self._data.lin_acc_b.torch) + valid_indices = (torch.linalg.norm(accel_w, dim=-1) > 1e-5).nonzero(as_tuple=True)[0] + if valid_indices.numel() == 0: + return + pos_filtered = pos_w_torch.index_select(0, valid_indices) + accel_filtered = accel_w.index_select(0, valid_indices) + rotation_matrix = math_utils.create_rotation_matrix_from_view( + pos_filtered, + pos_filtered + accel_filtered, + up_axis=up_axis, + device=self._device, ) + quat_opengl = math_utils.quat_from_matrix(rotation_matrix) quat_w = math_utils.convert_camera_frame_orientation_convention(quat_opengl, "opengl", "world") - self.acceleration_visualizer.visualize(base_pos_w, quat_w, arrow_scale) + self.acceleration_visualizer.visualize( + base_pos_w.index_select(0, valid_indices), + quat_w, + arrow_scale.index_select(0, valid_indices), + ) def _invalidate_initialize_callback(self, event): """Clears references for re-initialization and re-registers with NewtonManager.""" diff --git a/source/isaaclab_physx/changelog.d/jmart-singular-rotation.rst b/source/isaaclab_physx/changelog.d/jmart-singular-rotation.rst new file mode 100644 index 000000000000..a2d6330e29b2 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/jmart-singular-rotation.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed the acceleration-arrow debug visualizer in + :class:`~isaaclab_physx.sensors.pva.Pva` drawing arrows in undefined directions for + bodies with effectively zero acceleration. Such bodies are now skipped from the + visualization. diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/pva/pva.py b/source/isaaclab_physx/isaaclab_physx/sensors/pva/pva.py index 32f4549e416d..6f2ad9a70118 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/pva/pva.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/pva/pva.py @@ -284,18 +284,25 @@ def _debug_vis_callback(self, event): arrow_scale = torch.tensor(default_scale, device=self.device).repeat(self._data.lin_acc_b.torch.shape[0], 1) # get up axis of current stage up_axis = UsdGeom.GetStageUpAxis(self.stage) - # arrow-direction + # arrow-direction; filter out bodies with effectively zero accel (no defined direction) pos_w_torch = self._data.pos_w.torch - quat_w_torch = self._data.quat_w.torch - lin_acc_b_torch = self._data.lin_acc_b.torch - quat_opengl = math_utils.quat_from_matrix( - math_utils.create_rotation_matrix_from_view( - pos_w_torch, - pos_w_torch + math_utils.quat_apply(quat_w_torch, lin_acc_b_torch), - up_axis=up_axis, - device=self._device, - ) + accel_w = math_utils.quat_apply(self._data.quat_w.torch, self._data.lin_acc_b.torch) + valid_indices = (torch.linalg.norm(accel_w, dim=-1) > 1e-5).nonzero(as_tuple=True)[0] + if valid_indices.numel() == 0: + return + pos_filtered = pos_w_torch.index_select(0, valid_indices) + accel_filtered = accel_w.index_select(0, valid_indices) + rotation_matrix = math_utils.create_rotation_matrix_from_view( + pos_filtered, + pos_filtered + accel_filtered, + up_axis=up_axis, + device=self._device, ) + quat_opengl = math_utils.quat_from_matrix(rotation_matrix) quat_w = math_utils.convert_camera_frame_orientation_convention(quat_opengl, "opengl", "world") # display markers - self.acceleration_visualizer.visualize(base_pos_w, quat_w, arrow_scale) + self.acceleration_visualizer.visualize( + base_pos_w.index_select(0, valid_indices), + quat_w, + arrow_scale.index_select(0, valid_indices), + ) From 88d3dda23fc4ebda04f79f0cd003da05795bb33a Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 06:15:21 +0000 Subject: [PATCH 063/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 5.2.0 → 5.2.1 - isaaclab_newton: 0.9.0 → 0.9.1 - isaaclab_physx: 0.7.0 → 0.7.1 --- ...chuanh-fix-docs-changelog-indentation.skip | 0 .../changelog.d/jmart-singular-rotation.rst | 16 -------------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 21 +++++++++++++++++++ .../changelog.d/jmart-singular-rotation.rst | 7 ------- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 12 +++++++++++ .../changelog.d/jmart-singular-rotation.rst | 7 ------- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 12 +++++++++++ 10 files changed, 48 insertions(+), 33 deletions(-) delete mode 100644 source/isaaclab/changelog.d/jichuanh-fix-docs-changelog-indentation.skip delete mode 100644 source/isaaclab/changelog.d/jmart-singular-rotation.rst delete mode 100644 source/isaaclab_newton/changelog.d/jmart-singular-rotation.rst delete mode 100644 source/isaaclab_physx/changelog.d/jmart-singular-rotation.rst diff --git a/source/isaaclab/changelog.d/jichuanh-fix-docs-changelog-indentation.skip b/source/isaaclab/changelog.d/jichuanh-fix-docs-changelog-indentation.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab/changelog.d/jmart-singular-rotation.rst b/source/isaaclab/changelog.d/jmart-singular-rotation.rst deleted file mode 100644 index 61b703707531..000000000000 --- a/source/isaaclab/changelog.d/jmart-singular-rotation.rst +++ /dev/null @@ -1,16 +0,0 @@ -Fixed -^^^^^ - -* Fixed :func:`~isaaclab.utils.math.create_rotation_matrix_from_view` returning a singular - matrix when the look-at direction was parallel to the up axis. The function now produces - a valid orthonormal frame via an alternate reference vector, and fills NaN for rows with - truly undefined forward direction (``eyes == targets`` or non-finite input). Callers - detect per-row failure with ``torch.isnan(R).any(dim=(-2, -1))``. -* Fixed :func:`~isaaclab.utils.math.quat_from_matrix` silently returning a non-unit - quaternion for non-rotation input (singular, reflection, or scale-error matrices). - Such inputs now return NaN, detectable via :func:`torch.isnan`. -* Fixed :meth:`~isaaclab.sensors.camera.Camera.set_world_poses_from_view` and - :meth:`~isaaclab.sensors.ray_caster.RayCasterCamera.set_world_poses_from_view` silently - applying garbage poses when an eye position equaled its target. Degenerate rows are now - skipped (with a logged warning), and ``ValueError`` is raised if every row in the batch - is degenerate. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 125e08a54cf2..43ef3f9bcb02 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 = "5.2.0" +version = "5.2.1" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 8c87cd5862b9..1a5b444d68a9 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,27 @@ Changelog --------- +5.2.1 (2026-05-15) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed :func:`~isaaclab.utils.math.create_rotation_matrix_from_view` returning a singular + matrix when the look-at direction was parallel to the up axis. The function now produces + a valid orthonormal frame via an alternate reference vector, and fills NaN for rows with + truly undefined forward direction (``eyes == targets`` or non-finite input). Callers + detect per-row failure with ``torch.isnan(R).any(dim=(-2, -1))``. +* Fixed :func:`~isaaclab.utils.math.quat_from_matrix` silently returning a non-unit + quaternion for non-rotation input (singular, reflection, or scale-error matrices). + Such inputs now return NaN, detectable via :func:`torch.isnan`. +* Fixed :meth:`~isaaclab.sensors.camera.Camera.set_world_poses_from_view` and + :meth:`~isaaclab.sensors.ray_caster.RayCasterCamera.set_world_poses_from_view` silently + applying garbage poses when an eye position equaled its target. Degenerate rows are now + skipped (with a logged warning), and ``ValueError`` is raised if every row in the batch + is degenerate. + + 5.2.0 (2026-05-14) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/changelog.d/jmart-singular-rotation.rst b/source/isaaclab_newton/changelog.d/jmart-singular-rotation.rst deleted file mode 100644 index 3e479713b7b1..000000000000 --- a/source/isaaclab_newton/changelog.d/jmart-singular-rotation.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed the acceleration-arrow debug visualizer in - :class:`~isaaclab_newton.sensors.pva.Pva` drawing arrows in undefined directions for - bodies with effectively zero acceleration. Such bodies are now skipped from the - visualization. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 1de90e078dc2..ba4905bfd73e 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.9.0" +version = "0.9.1" # 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 d2da55554a63..01bcae178b7b 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.9.1 (2026-05-15) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed the acceleration-arrow debug visualizer in + :class:`~isaaclab_newton.sensors.pva.Pva` drawing arrows in undefined directions for + bodies with effectively zero acceleration. Such bodies are now skipped from the + visualization. + + 0.9.0 (2026-05-14) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/changelog.d/jmart-singular-rotation.rst b/source/isaaclab_physx/changelog.d/jmart-singular-rotation.rst deleted file mode 100644 index a2d6330e29b2..000000000000 --- a/source/isaaclab_physx/changelog.d/jmart-singular-rotation.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed the acceleration-arrow debug visualizer in - :class:`~isaaclab_physx.sensors.pva.Pva` drawing arrows in undefined directions for - bodies with effectively zero acceleration. Such bodies are now skipped from the - visualization. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 6eb2ff012c5e..7b3903e18087 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.7.0" +version = "0.7.1" # 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 b62942448482..c344fb19b0ea 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.7.1 (2026-05-15) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed the acceleration-arrow debug visualizer in + :class:`~isaaclab_physx.sensors.pva.Pva` drawing arrows in undefined directions for + bodies with effectively zero acceleration. Such bodies are now skipped from the + visualization. + + 0.7.0 (2026-05-14) ~~~~~~~~~~~~~~~~~~ From a5eb9add4c3428826b2fa75a9c9b3ee50cbb7dde Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 15 May 2026 18:53:20 +0200 Subject: [PATCH 064/133] Fixes OmniHub startup in Docker tests (#5633) # Description This PR is based on and includes the changes from #5620, then adds one CI fix on top: it unsets `HUB__ARGS__DETECT_ONLY` inside the Docker test container before running Isaac Lab commands. Some base images set this flag, which prevents OmniHub from starting and makes cold Nucleus asset retrieval fall back to slow repeated retries. This was reproduced from the failing Actions job: https://github.com/isaac-sim/IsaacLab/actions/runs/25904143763/job/76158743634 The affected `test_rsl_rl_export_flow.py` Dexsuite Kuka-Allegro export timed out at 600 s with the flag set, then completed in about 73 s with the flag unset after clearing the local KukaAllegro mirror. Fixes # N/A ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots N/A - CI-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` - [x] I have made corresponding changes to the documentation (N/A - CI-only change) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works (validated with the affected Docker export test) - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package (N/A for the CI-only commit; #5620 carries its own changelog fragments) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there ## Test Plan - `./isaaclab.sh -f` - Docker reproduction with `HUB__ARGS__DETECT_ONLY=true`: `test_export_flow[Isaac-Dexsuite-Kuka-Allegro-Reorient-v0]` timed out after 600 s. - Docker reproduction with `HUB__ARGS__DETECT_ONLY` unset after clearing the KukaAllegro mirror: `test_export_flow[Isaac-Dexsuite-Kuka-Allegro-Reorient-v0]` passed in 72.75 s. --------- Co-authored-by: Piotr Barejko --- .github/actions/run-tests/action.yml | 10 + .../leapp/rsl_rl/export.py | 418 +++++++++++------- .../changelog.d/pbarejko-debugging.rst | 8 + source/isaaclab/isaaclab/app/app_launcher.py | 21 +- .../isaaclab/physics/physics_manager_cfg.py | 2 +- source/isaaclab/isaaclab/utils/__init__.py | 2 - source/isaaclab/test/utils/test_noise.py | 9 +- .../test/utils/test_wrench_composer.py | 5 - .../changelog.d/pbarejko-debugging.skip | 0 .../physics/newton_collision_cfg.py | 2 +- .../physics/newton_manager_cfg.py | 2 +- .../changelog.d/pbarejko-debugging.skip | 0 .../test_isaac_rtx_renderer_utils.py | 55 +-- .../antoiner-fix-rsl-rl-export-ci.skip | 0 .../test/export/test_rsl_rl_export_flow.py | 221 +++++++-- tools/conftest.py | 1 + 16 files changed, 490 insertions(+), 266 deletions(-) create mode 100644 source/isaaclab/changelog.d/pbarejko-debugging.rst create mode 100644 source/isaaclab_newton/changelog.d/pbarejko-debugging.skip create mode 100644 source/isaaclab_physx/changelog.d/pbarejko-debugging.skip create mode 100644 source/isaaclab_rl/changelog.d/antoiner-fix-rsl-rl-export-ci.skip diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index ab8a6c5e1caa..9d97ac4fd5a5 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -214,9 +214,19 @@ runs: mkdir -p tests rm _isaac_sim || true ln -s /isaac-sim _isaac_sim + # Allow OmniHub to start in the test container. Some base images + # set this detect-only flag, which makes cold asset downloads + # fall back to slow repeated retries. + unset HUB__ARGS__DETECT_ONLY if [ -n \"\${TEST_EXTRA_PIP_PACKAGES:-}\" ]; then echo \"Installing extra pip packages: \${TEST_EXTRA_PIP_PACKAGES}\" ./isaaclab.sh -p -m pip install \${TEST_EXTRA_PIP_PACKAGES} + case \" \${TEST_EXTRA_PIP_PACKAGES} \" in + *\" leapp\"*) + echo \"Resolved LEAPP package:\" + ./isaaclab.sh -p -m pip show leapp || true + ;; + esac fi echo 'Starting pytest with path: $test_path' ./isaaclab.sh -p -m pytest --ignore=tools/conftest.py --ignore=source/isaaclab/test/install_ci $test_path $pytest_options -v --junitxml=tests/$result_file diff --git a/scripts/reinforcement_learning/leapp/rsl_rl/export.py b/scripts/reinforcement_learning/leapp/rsl_rl/export.py index 65bab2f9c221..1aa000069da9 100644 --- a/scripts/reinforcement_learning/leapp/rsl_rl/export.py +++ b/scripts/reinforcement_learning/leapp/rsl_rl/export.py @@ -3,14 +3,14 @@ # # SPDX-License-Identifier: BSD-3-Clause -# ruff: noqa: E402 - """Script to export a checkpoint if an RL agent from RSL-RL.""" -"""Launch Isaac Sim Simulator first.""" +from __future__ import annotations import argparse +import contextlib import importlib.metadata as metadata +import os import sys import time from collections.abc import Mapping @@ -36,85 +36,130 @@ import cli_args # isort: skip -parser = argparse.ArgumentParser(description="Train an RL agent with RSL-RL.") -parser.add_argument( - "--disable_fabric", action="store_true", default=False, help="Disable fabric and use USD I/O operations." -) -parser.add_argument("--task", type=str, default=None, help="Name of the task.") -parser.add_argument( - "--agent", type=str, default="rsl_rl_cfg_entry_point", help="Name of the RL agent configuration entry point." -) -parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") -parser.add_argument( - "--use_pretrained_checkpoint", - action="store_true", - help="Use the pre-trained checkpoint from Nucleus.", -) - -# LEAPP arguments -parser.add_argument( - "--export_task_name", - type=str, - default=None, - help="Name of the exported graph. Defaults to the task name.", -) -parser.add_argument( - "--export_method", - type=str, - default="onnx-dynamo", - choices=["onnx-dynamo", "onnx-torchscript", "jit-script", "jit-trace"], - help="Method to export the policy", -) -parser.add_argument( - "--export_save_path", - type=str, - default=None, - help="Path to save the exported model", -) -parser.add_argument( - "--validation_steps", - type=int, - default=5, - help="Number of steps to validate the exported model", -) -parser.add_argument( - "--disable_graph_visualization", - action="store_true", - default=False, - help="Disable LEAPP graph visualization during compile_graph().", -) - -cli_args.add_rsl_rl_args(parser) -AppLauncher.add_app_launcher_args(parser) -args_cli, hydra_args = parser.parse_known_args() -args_cli.headless = True - -# clear out sys.argv for Hydra -sys.argv = [sys.argv[0]] + hydra_args - -installed_version = metadata.version("rsl-rl-lib") - -app_launcher = AppLauncher(args_cli) -simulation_app = app_launcher.app - -"""Rest everything follows.""" - -import os - -import gymnasium as gym -from rsl_rl.runners import DistillationRunner, OnPolicyRunner +_RUNTIME_IMPORTS_LOADED = False + +gym = None +DistillationRunner = None +OnPolicyRunner = None +ManagerBasedRLEnv = None +RslRlVecEnvWrapper = None +handle_deprecated_rsl_rl_cfg = None +retrieve_file_path = None +patch_env_for_export = None +ensure_env_spec_id = None +get_published_pretrained_checkpoint = None +get_checkpoint_path = None +hydra_task_config = None + + +def create_arg_parser() -> argparse.ArgumentParser: + """Create the command-line parser for RSL-RL policy export.""" + parser = argparse.ArgumentParser(description="Train an RL agent with RSL-RL.") + parser.add_argument( + "--disable_fabric", action="store_true", default=False, help="Disable fabric and use USD I/O operations." + ) + parser.add_argument("--task", type=str, default=None, help="Name of the task.") + parser.add_argument( + "--agent", type=str, default="rsl_rl_cfg_entry_point", help="Name of the RL agent configuration entry point." + ) + parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") + parser.add_argument( + "--use_pretrained_checkpoint", + action="store_true", + help="Use the pre-trained checkpoint from Nucleus.", + ) + + # LEAPP arguments + parser.add_argument( + "--export_task_name", + type=str, + default=None, + help="Name of the exported graph. Defaults to the task name.", + ) + parser.add_argument( + "--export_method", + type=str, + default="onnx-dynamo", + choices=["onnx-dynamo", "onnx-torchscript", "jit-script", "jit-trace"], + help="Method to export the policy", + ) + parser.add_argument( + "--export_save_path", + type=str, + default=None, + help="Path to save the exported model", + ) + parser.add_argument( + "--validation_steps", + type=int, + default=5, + help="Number of steps to validate the exported model", + ) + parser.add_argument( + "--disable_graph_visualization", + action="store_true", + default=False, + help="Disable LEAPP graph visualization during compile_graph().", + ) + + cli_args.add_rsl_rl_args(parser) + AppLauncher.add_app_launcher_args(parser) + return parser + + +def parse_export_args(argv: list[str] | None = None) -> tuple[argparse.Namespace, list[str]]: + """Parse export arguments and return remaining Hydra overrides.""" + parser = create_arg_parser() + args_cli, hydra_args = parser.parse_known_args(argv) + args_cli.headless = True + return args_cli, hydra_args + + +def _load_runtime_dependencies() -> None: + """Import runtime dependencies after Isaac Sim has been launched.""" + global _RUNTIME_IMPORTS_LOADED + global DistillationRunner, ManagerBasedRLEnv, OnPolicyRunner, RslRlVecEnvWrapper, get_checkpoint_path, gym + global ensure_env_spec_id, get_published_pretrained_checkpoint, handle_deprecated_rsl_rl_cfg, hydra_task_config + global patch_env_for_export, retrieve_file_path + + if _RUNTIME_IMPORTS_LOADED: + return + + import gymnasium as gym_module + from rsl_rl.runners import DistillationRunner as DistillationRunnerCls + from rsl_rl.runners import OnPolicyRunner as OnPolicyRunnerCls + + from isaaclab.envs import ManagerBasedRLEnv as ManagerBasedRLEnvCls + from isaaclab.utils.assets import retrieve_file_path as retrieve_file_path_fn + from isaaclab.utils.leapp import patch_env_for_export as patch_env_for_export_fn + from isaaclab.utils.leapp.utils import ensure_env_spec_id as ensure_env_spec_id_fn + + from isaaclab_rl.rsl_rl import RslRlVecEnvWrapper as RslRlVecEnvWrapperCls + from isaaclab_rl.rsl_rl import handle_deprecated_rsl_rl_cfg as handle_deprecated_rsl_rl_cfg_fn + from isaaclab_rl.utils.pretrained_checkpoint import ( + get_published_pretrained_checkpoint as get_published_pretrained_checkpoint_fn, + ) + + __import__("isaaclab_tasks") + from isaaclab_tasks.utils import get_checkpoint_path as get_checkpoint_path_fn + from isaaclab_tasks.utils.hydra import hydra_task_config as hydra_task_config_fn + + gym = gym_module + DistillationRunner = DistillationRunnerCls + OnPolicyRunner = OnPolicyRunnerCls + ManagerBasedRLEnv = ManagerBasedRLEnvCls + RslRlVecEnvWrapper = RslRlVecEnvWrapperCls + handle_deprecated_rsl_rl_cfg = handle_deprecated_rsl_rl_cfg_fn + retrieve_file_path = retrieve_file_path_fn + patch_env_for_export = patch_env_for_export_fn + ensure_env_spec_id = ensure_env_spec_id_fn + get_published_pretrained_checkpoint = get_published_pretrained_checkpoint_fn + get_checkpoint_path = get_checkpoint_path_fn + hydra_task_config = hydra_task_config_fn + _RUNTIME_IMPORTS_LOADED = True -from isaaclab.envs import ManagerBasedRLEnv, ManagerBasedRLEnvCfg -from isaaclab.utils.assets import retrieve_file_path -from isaaclab.utils.leapp import patch_env_for_export -from isaaclab.utils.leapp.utils import ensure_env_spec_id -from isaaclab_rl.rsl_rl import RslRlBaseRunnerCfg, RslRlVecEnvWrapper, handle_deprecated_rsl_rl_cfg -from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint - -import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils import get_checkpoint_path -from isaaclab_tasks.utils.hydra import hydra_task_config +installed_version = metadata.version("rsl-rl-lib") def get_actor_memory_module(policy_nn): @@ -165,13 +210,19 @@ def actor_hidden_from_registered(registered_state, original_hidden): return registered_state -@hydra_task_config(args_cli.task, args_cli.agent) -def main(env_cfg: ManagerBasedRLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): +def export_rsl_rl_agent( + args_cli: argparse.Namespace, + env_cfg, + agent_cfg, + simulation_app, +) -> bool: """Export a RSL-RL agent.""" + _load_runtime_dependencies() + task_name = args_cli.task.split(":")[-1] train_task_name = task_name.replace("-Play", "") - agent_cfg: RslRlBaseRunnerCfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) + agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) env_cfg.scene.num_envs = 1 agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, installed_version) @@ -187,7 +238,7 @@ def main(env_cfg: ManagerBasedRLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): resume_path = get_published_pretrained_checkpoint("rsl_rl", train_task_name) if not resume_path: print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") - return + return False elif args_cli.checkpoint: resume_path = retrieve_file_path(args_cli.checkpoint) else: @@ -197,86 +248,131 @@ def main(env_cfg: ManagerBasedRLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): env_cfg.log_dir = log_dir - env = gym.make(args_cli.task, cfg=env_cfg, render_mode=None) - policy_node_name = ensure_env_spec_id(env) - - graph_name = args_cli.export_task_name if args_cli.export_task_name is not None else task_name - - if isinstance(env.unwrapped, ManagerBasedRLEnv): - # Patch only the observation groups consumed by the actor policy. - # This filters out the critic and teacher observation groups. - obs_groups_cfg = getattr(agent_cfg, "obs_groups", None) - if isinstance(obs_groups_cfg, Mapping): - required_obs_groups = set(obs_groups_cfg.get("actor", ["policy"])) + env = None + leapp_started = False + try: + env = gym.make(args_cli.task, cfg=env_cfg, render_mode=None) + policy_node_name = ensure_env_spec_id(env) + + graph_name = args_cli.export_task_name if args_cli.export_task_name is not None else task_name + + if isinstance(env.unwrapped, ManagerBasedRLEnv): + # Patch only the observation groups consumed by the actor policy. + # This filters out the critic and teacher observation groups. + obs_groups_cfg = getattr(agent_cfg, "obs_groups", None) + if isinstance(obs_groups_cfg, Mapping): + required_obs_groups = set(obs_groups_cfg.get("actor", ["policy"])) + else: + required_obs_groups = {"policy"} + patch_env_for_export( + env, + export_method=args_cli.export_method, + required_obs_groups=required_obs_groups, + ) + + env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) + + print(f"[INFO]: Loading model checkpoint from: {resume_path}") + if agent_cfg.class_name == "OnPolicyRunner": + runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) + elif agent_cfg.class_name == "DistillationRunner": + runner = DistillationRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) else: - required_obs_groups = {"policy"} - patch_env_for_export( - env, - export_method=args_cli.export_method, - required_obs_groups=required_obs_groups, - ) - - env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) - - print(f"[INFO]: Loading model checkpoint from: {resume_path}") - if agent_cfg.class_name == "OnPolicyRunner": - runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) - elif agent_cfg.class_name == "DistillationRunner": - runner = DistillationRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) - else: - raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}") - runner.load(resume_path) + raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}") + runner.load(resume_path) - policy = runner.get_inference_policy(device=env.unwrapped.device) - policy_nn = getattr(policy, "__self__", None) + policy = runner.get_inference_policy(device=env.unwrapped.device) + policy_nn = getattr(policy, "__self__", None) - if args_cli.export_save_path is not None: - save_path = args_cli.export_save_path - elif args_cli.use_pretrained_checkpoint: - # Use a predictable path independent of the Nucleus mirror directory structure. - save_path = os.path.join(".pretrained_checkpoints", "rsl_rl", train_task_name) - else: - save_path = log_dir - leapp.start(graph_name, save_path=save_path, max_cached_io=max(args_cli.validation_steps, 2)) - obs = env.reset()[0] - while not simulation_app.is_running(): - time.sleep(0.5) - - for _ in range(max(args_cli.validation_steps, 2)): - with torch.inference_mode(): - if policy_nn is not None and getattr(policy_nn, "is_recurrent", False): - actor_hidden = ensure_actor_hidden_state_initialized( - policy_nn, - batch_size=env.num_envs, - device=env.unwrapped.device, - dtype=next(policy_nn.parameters()).dtype, - ) - registered_state = annotate.state_tensors( - policy_node_name, - state_dict_from_actor_hidden(actor_hidden), - ) - actor_memory = get_actor_memory_module(policy_nn) - if actor_memory is not None: - actor_memory.hidden_state = actor_hidden_from_registered(registered_state, actor_hidden) - - actions = policy(obs) - - if policy_nn is not None and getattr(policy_nn, "is_recurrent", False): - actor_hidden_after = policy_nn.get_hidden_states()[0] - annotate.update_state( - policy_node_name, - state_dict_from_actor_hidden(actor_hidden_after), - ) - - obs, _, _, _ = env.step(actions) - - leapp.stop() - validate = args_cli.validation_steps > 0 - leapp.compile_graph(visualize=not args_cli.disable_graph_visualization, validate=validate) - - env.close() + if args_cli.export_save_path is not None: + save_path = args_cli.export_save_path + elif args_cli.use_pretrained_checkpoint: + # Use a predictable path independent of the Nucleus mirror directory structure. + save_path = os.path.join(".pretrained_checkpoints", "rsl_rl", train_task_name) + else: + save_path = log_dir + leapp.start(graph_name, save_path=save_path, max_cached_io=max(args_cli.validation_steps, 2)) + leapp_started = True + obs = env.reset()[0] + while not simulation_app.is_running(): + time.sleep(0.5) + + for _ in range(max(args_cli.validation_steps, 2)): + with torch.inference_mode(): + if policy_nn is not None and getattr(policy_nn, "is_recurrent", False): + actor_hidden = ensure_actor_hidden_state_initialized( + policy_nn, + batch_size=env.num_envs, + device=env.unwrapped.device, + dtype=next(policy_nn.parameters()).dtype, + ) + registered_state = annotate.state_tensors( + policy_node_name, + state_dict_from_actor_hidden(actor_hidden), + ) + actor_memory = get_actor_memory_module(policy_nn) + if actor_memory is not None: + actor_memory.hidden_state = actor_hidden_from_registered(registered_state, actor_hidden) + + actions = policy(obs) + + if policy_nn is not None and getattr(policy_nn, "is_recurrent", False): + actor_hidden_after = policy_nn.get_hidden_states()[0] + annotate.update_state( + policy_node_name, + state_dict_from_actor_hidden(actor_hidden_after), + ) + + obs, _, _, _ = env.step(actions) + + leapp.stop() + leapp_started = False + validate = args_cli.validation_steps > 0 + leapp.compile_graph(visualize=not args_cli.disable_graph_visualization, validate=validate) + finally: + if leapp_started: + with contextlib.suppress(Exception): + leapp.stop() + if env is not None: + env.close() + + return True + + +def run_export_with_hydra(args_cli: argparse.Namespace, hydra_args: list[str], simulation_app) -> bool: + """Resolve Hydra task configuration and export one RSL-RL policy.""" + _load_runtime_dependencies() + + original_argv = sys.argv + sys.argv = [sys.argv[0]] + hydra_args + exported = False + + try: + + @hydra_task_config(args_cli.task, args_cli.agent) + def _main(env_cfg, agent_cfg) -> None: + nonlocal exported + exported = export_rsl_rl_agent(args_cli, env_cfg, agent_cfg, simulation_app) + + _main() + finally: + sys.argv = original_argv + + return exported + + +def main_cli(argv: list[str] | None = None) -> bool: + """Run the command-line export flow.""" + args_cli, hydra_args = parse_export_args(argv) + + app_launcher = AppLauncher(args_cli) + simulation_app = app_launcher.app + + try: + return run_export_with_hydra(args_cli, hydra_args, simulation_app) + finally: + simulation_app.close() if __name__ == "__main__": - main() - simulation_app.close() + main_cli() diff --git a/source/isaaclab/changelog.d/pbarejko-debugging.rst b/source/isaaclab/changelog.d/pbarejko-debugging.rst new file mode 100644 index 000000000000..fe3d24743788 --- /dev/null +++ b/source/isaaclab/changelog.d/pbarejko-debugging.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Fixed a startup crash in :class:`~isaaclab.app.AppLauncher` when launching with a CUDA device. + Setting the current torch CUDA device used to happen before ``SimulationApp`` was created, which + imported ``torch`` (and transitively NumPy/OpenBLAS) prior to Kit's platform-info fork. On systems + where OpenBLAS's at-fork handlers were not yet safe, that fork could crash. The + ``torch.cuda.set_device`` call is now deferred until after ``SimulationApp`` starts. diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 513757e0808b..f9ef6db13b82 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -232,6 +232,7 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa # Exposed to train scripts self.device_id: int # device ID for GPU simulation (defaults to 0) self.device: str # resolved device string (e.g. "cuda:0" or "cpu") + self._deferred_cuda_device_id: int | None = None self.local_rank: int # local rank of GPUs in the current node self.global_rank: int # global rank for multi-node training @@ -240,6 +241,7 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa # Create SimulationApp, passing the resolved self._config to it for initialization self._create_app() + self._set_deferred_cuda_device() # Load IsaacSim extensions self._load_extensions() @@ -985,19 +987,26 @@ def _resolve_device_settings(self, launcher_args: dict): launcher_args["physics_gpu"] = self.device_id launcher_args["active_gpu"] = self.device_id - # Set the current CUDA device early so that physics backends (e.g. Newton/Warp) - # that allocate on the "current" device during initialization get the correct GPU. - # Without this, all ranks may default to cuda:0 for early allocations. + # Defer importing torch until after SimulationApp starts. Importing + # torch can import NumPy/OpenBLAS, whose at-fork handlers can crash + # Kit's platform-info fork during startup. if "cuda" in device: - import torch - - torch.cuda.set_device(self.device_id) + self._deferred_cuda_device_id = self.device_id # Store the resolved device string for downstream consumers (e.g. sim_launcher) self.device = device logger.info("Using device: %s", device) + def _set_deferred_cuda_device(self) -> None: + """Set the current torch CUDA device after Kit startup.""" + if self._deferred_cuda_device_id is None: + return + + import torch + + torch.cuda.set_device(self._deferred_cuda_device_id) + def _resolve_experience_file(self, launcher_args: dict): """Resolve experience file related settings.""" # Check if input keywords contain an 'experience' file setting diff --git a/source/isaaclab/isaaclab/physics/physics_manager_cfg.py b/source/isaaclab/isaaclab/physics/physics_manager_cfg.py index 0ff2348b591a..d8e68f0b41a2 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager_cfg.py +++ b/source/isaaclab/isaaclab/physics/physics_manager_cfg.py @@ -10,7 +10,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING, Any -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .physics_manager import PhysicsManager diff --git a/source/isaaclab/isaaclab/utils/__init__.py b/source/isaaclab/isaaclab/utils/__init__.py index fe57e45acd63..130765b79f89 100644 --- a/source/isaaclab/isaaclab/utils/__init__.py +++ b/source/isaaclab/isaaclab/utils/__init__.py @@ -5,8 +5,6 @@ """Sub-package containing utilities for common operations and helper functions.""" -from .configclass import configclass - import lazy_loader as lazy __getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__) diff --git a/source/isaaclab/test/utils/test_noise.py b/source/isaaclab/test/utils/test_noise.py index 176371d381f6..e9ab107c69f6 100644 --- a/source/isaaclab/test/utils/test_noise.py +++ b/source/isaaclab/test/utils/test_noise.py @@ -3,14 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" +"""Test noise utilities.""" import pytest import torch diff --git a/source/isaaclab/test/utils/test_wrench_composer.py b/source/isaaclab/test/utils/test_wrench_composer.py index cffa927c802b..b711aaab44a6 100644 --- a/source/isaaclab/test/utils/test_wrench_composer.py +++ b/source/isaaclab/test/utils/test_wrench_composer.py @@ -3,11 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - import numpy as np import pytest import torch diff --git a/source/isaaclab_newton/changelog.d/pbarejko-debugging.skip b/source/isaaclab_newton/changelog.d/pbarejko-debugging.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py index c8b0db0b3f4a..86014d13a885 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py @@ -9,7 +9,7 @@ from typing import Any, Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py index 85e4061ab911..6ff646aff57b 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING from isaaclab.physics import PhysicsCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .newton_collision_cfg import NewtonCollisionPipelineCfg diff --git a/source/isaaclab_physx/changelog.d/pbarejko-debugging.skip b/source/isaaclab_physx/changelog.d/pbarejko-debugging.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py index 068b6df87b38..8d4771372a9c 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py @@ -234,7 +234,16 @@ def pumping_visualizer(self): viz.pumps_app_update.return_value = True return viz - def test_first_call_with_visualizer_still_pumps(self, mock_sim, pumping_visualizer, mock_omni_kit_app): + @pytest.fixture() + def mock_sim_context(self, monkeypatch): + """Patch ``sim_utils`` without importing the real ``SimulationContext``.""" + sim_context = MagicMock() + monkeypatch.setattr(rtx_utils, "sim_utils", types.SimpleNamespace(SimulationContext=sim_context)) + return sim_context + + def test_first_call_with_visualizer_still_pumps( + self, mock_sim, mock_sim_context, pumping_visualizer, mock_omni_kit_app + ): """Regression: first call for a new sim must pump even with a visualizer. Without the fix (commit 2e8ace7), a visualizer returning @@ -246,31 +255,25 @@ def test_first_call_with_visualizer_still_pumps(self, mock_sim, pumping_visualiz mock_sim.visualizers = [pumping_visualizer] mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app + mock_sim_context.instance.return_value = mock_sim with ( - patch.object( - rtx_utils.sim_utils.SimulationContext, - "instance", - return_value=mock_sim, - ), patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False), ): rtx_utils.ensure_isaac_rtx_render_update() mock_app.update.assert_called_once() - def test_second_call_with_visualizer_skips_pump(self, mock_sim, pumping_visualizer, mock_omni_kit_app): + def test_second_call_with_visualizer_skips_pump( + self, mock_sim, mock_sim_context, pumping_visualizer, mock_omni_kit_app + ): """After the first call, a visualizer that pumps causes the skip.""" mock_sim.visualizers = [pumping_visualizer] mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app + mock_sim_context.instance.return_value = mock_sim with ( - patch.object( - rtx_utils.sim_utils.SimulationContext, - "instance", - return_value=mock_sim, - ), patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False), ): rtx_utils.ensure_isaac_rtx_render_update() @@ -282,31 +285,23 @@ def test_second_call_with_visualizer_skips_pump(self, mock_sim, pumping_visualiz mock_app.update.assert_not_called() - def test_no_sim_is_noop(self, mock_omni_kit_app): + def test_no_sim_is_noop(self, mock_sim_context, mock_omni_kit_app): """No-op when SimulationContext.instance() returns None.""" mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app + mock_sim_context.instance.return_value = None - with patch.object( - rtx_utils.sim_utils.SimulationContext, - "instance", - return_value=None, - ): - rtx_utils.ensure_isaac_rtx_render_update() + rtx_utils.ensure_isaac_rtx_render_update() mock_app.update.assert_not_called() - def test_dedup_same_step(self, mock_sim, mock_omni_kit_app): + def test_dedup_same_step(self, mock_sim, mock_sim_context, mock_omni_kit_app): """Second call in the same physics step is a no-op (dedup).""" mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app + mock_sim_context.instance.return_value = mock_sim with ( - patch.object( - rtx_utils.sim_utils.SimulationContext, - "instance", - return_value=mock_sim, - ), patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False), ): rtx_utils.ensure_isaac_rtx_render_update() @@ -317,17 +312,13 @@ def test_dedup_same_step(self, mock_sim, mock_omni_kit_app): mock_app.update.assert_not_called() - def test_not_rendering_skips(self, mock_sim, mock_omni_kit_app): + def test_not_rendering_skips(self, mock_sim, mock_sim_context, mock_omni_kit_app): """No ``app.update()`` when rendering is disabled.""" mock_sim.is_rendering = False mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app + mock_sim_context.instance.return_value = mock_sim - with patch.object( - rtx_utils.sim_utils.SimulationContext, - "instance", - return_value=mock_sim, - ): - rtx_utils.ensure_isaac_rtx_render_update() + rtx_utils.ensure_isaac_rtx_render_update() mock_app.update.assert_not_called() diff --git a/source/isaaclab_rl/changelog.d/antoiner-fix-rsl-rl-export-ci.skip b/source/isaaclab_rl/changelog.d/antoiner-fix-rsl-rl-export-ci.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_rl/test/export/test_rsl_rl_export_flow.py b/source/isaaclab_rl/test/export/test_rsl_rl_export_flow.py index 5606be484286..e850dc997a3e 100644 --- a/source/isaaclab_rl/test/export/test_rsl_rl_export_flow.py +++ b/source/isaaclab_rl/test/export/test_rsl_rl_export_flow.py @@ -5,21 +5,29 @@ """Export pipeline integration tests. -Each test calls ``export.py`` as a subprocess so that Isaac Sim's AppLauncher -is fully isolated per task and the export logic is not duplicated here. -The export artifacts land in the default checkpoint directory; only the -per-task export subdirectory is removed after each test. +Each test launches Isaac Sim once for a batch of tasks. This avoids the +per-task Kit startup churn while keeping each Kit process short enough to avoid +accumulating PhysX GPU allocations across the full export matrix. """ +import contextlib +import importlib.util import os import shutil import subprocess +import sys +from pathlib import Path import pytest # Root of the repository (three levels up from this file). -_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) -_EXPORT_SCRIPT = os.path.join("scripts", "reinforcement_learning", "leapp", "rsl_rl", "export.py") +_REPO_ROOT = Path(__file__).resolve().parents[4] +_EXPORT_SCRIPT = _REPO_ROOT / "scripts" / "reinforcement_learning" / "leapp" / "rsl_rl" / "export.py" +_EXPORT_MODULE_NAME = "_isaaclab_rsl_rl_leapp_export" +_THIS_SCRIPT = Path(__file__).resolve() +_EXPORT_BATCH_SIZE = 8 +_EXPORT_BATCH_TIMEOUT = 600 +_OUTPUT_TAIL_CHARS = 5000 # Tasks with confirmed pretrained checkpoints (Direct and no-checkpoint tasks excluded). @@ -94,54 +102,101 @@ def _export_dir(task_name: str) -> str: return os.path.join(_REPO_ROOT, ".pretrained_checkpoints", "rsl_rl", train_task, task_name) -@pytest.mark.parametrize("task_name", TASKS) -def test_export_flow(task_name): - """Run export.py for *task_name* and assert the expected artifacts are created.""" +def _task_batches(tasks: list[str]) -> list[list[str]]: + """Split export tasks into batches that share one Kit process.""" + return [tasks[index : index + _EXPORT_BATCH_SIZE] for index in range(0, len(tasks), _EXPORT_BATCH_SIZE)] + + +def _batch_id(task_names: list[str]) -> str: + """Return a compact pytest id for a task batch.""" + first = task_names[0].replace("Isaac-", "") + last = task_names[-1].replace("Isaac-", "") + return f"{first}__to__{last}" + + +def _ensure_text(output: str | bytes | None) -> str: + """Return subprocess output as text.""" + if output is None: + return "" + if isinstance(output, bytes): + return output.decode("utf-8", errors="replace") + return output + + +def _leapp_log_tail(export_dir: str) -> str: + """Return the tail of the LEAPP log when it exists.""" + log_txt_path = os.path.join(export_dir, "log.txt") + if not os.path.isfile(log_txt_path): + return "" + with open(log_txt_path) as f: + last_lines = f.readlines()[-50:] + return f"\n--- leapp log.txt (last 50 lines) ---\n{''.join(last_lines)}" + + +def _load_export_module(): + """Load the LEAPP RSL-RL export script as an importable module.""" + module = sys.modules.get(_EXPORT_MODULE_NAME) + if module is not None: + return module + + spec = importlib.util.spec_from_file_location(_EXPORT_MODULE_NAME, _EXPORT_SCRIPT) + if spec is None or spec.loader is None: + raise ImportError(f"Could not create module spec for {_EXPORT_SCRIPT}") + + module = importlib.util.module_from_spec(spec) + sys.modules[_EXPORT_MODULE_NAME] = module + spec.loader.exec_module(module) + return module + + +@contextlib.contextmanager +def _clean_hydra_argv(): + """Temporarily hide pytest arguments from Hydra config resolution.""" + original_argv = sys.argv + sys.argv = [sys.argv[0]] + try: + yield + finally: + sys.argv = original_argv + + +def _export_args(task_name: str): + """Build the export argument namespace for *task_name*.""" + export_module = _load_export_module() + args_cli, _ = export_module.parse_export_args( + [ + "--task", + task_name, + "--use_pretrained_checkpoint", + "--disable_graph_visualization", + "--headless", + ] + ) + return args_cli + + +def _run_export_task(task_name: str, simulation_app, sim_utils, get_settings_manager, resolve_task_config) -> None: + """Export one task inside an already running Isaac Sim process.""" export_dir = _export_dir(task_name) + export_module = _load_export_module() try: - result = subprocess.run( - [ - "./isaaclab.sh", - "-p", - _EXPORT_SCRIPT, - "--task", - task_name, - "--use_pretrained_checkpoint", - "--disable_graph_visualization", - "--headless", - ], - cwd=_REPO_ROOT, - capture_output=True, - text=True, - timeout=600, - ) + sim_utils.create_new_stage() + get_settings_manager().set_bool("/isaaclab/render/rtx_sensors", False) + + args_cli = _export_args(task_name) + try: + with _clean_hydra_argv(): + env_cfg, agent_cfg = resolve_task_config(task_name, args_cli.agent) + exported = export_module.export_rsl_rl_agent(args_cli, env_cfg, agent_cfg, simulation_app) + except Exception as exc: + if "actor_state_dict" in str(exc): + return + raise RuntimeError(f"export.py failed for {task_name}: {exc!r}{_leapp_log_tail(export_dir)}") from exc # Gracefully skip tasks whose checkpoint isn't published yet - if "pre-trained checkpoint is currently unavailable" in result.stdout: - pytest.skip(f"No pretrained checkpoint available for {task_name.replace('-Play', '')}") - - # Skip tasks whose checkpoint was saved with an older rsl_rl architecture - # that does not use the 'actor_state_dict' key expected by the current runner - if "actor_state_dict" in result.stderr: - pytest.skip( - f"{task_name} checkpoint uses an older network architecture incompatible with the current rsl_rl runner" - ) - - # Surface stdout/stderr on failure for easier debugging - if result.returncode != 0: - log_txt_path = os.path.join(export_dir, "log.txt") - leapp_tail = "" - if os.path.isfile(log_txt_path): - with open(log_txt_path) as f: - last_lines = f.readlines()[-50:] - leapp_tail = f"\n--- leapp log.txt (last 50 lines) ---\n{''.join(last_lines)}" - pytest.fail( - f"export.py exited with code {result.returncode}.\n" - f"--- stdout ---\n{result.stdout[-3000:]}\n" - f"--- stderr ---\n{result.stderr[-3000:]}" - f"{leapp_tail}" - ) + if not exported: + return assert os.path.isfile(os.path.join(export_dir, f"{task_name}.onnx")), "Missing .onnx export" assert os.path.isfile(os.path.join(export_dir, f"{task_name}.yaml")), "Missing .yaml export" @@ -149,3 +204,71 @@ def test_export_flow(task_name): finally: shutil.rmtree(export_dir, ignore_errors=True) + + +def _run_export_batch(task_names: list[str]) -> None: + """Run a batch of exports inside a single Isaac Sim process.""" + from isaaclab.app import AppLauncher + + app_launcher = AppLauncher(headless=True) + simulation_app = app_launcher.app + + import isaaclab.sim as sim_utils + from isaaclab.app.settings_manager import get_settings_manager + + from isaaclab_tasks.utils.hydra import resolve_task_config + + # This flag matches the environment wrapper tests and avoids random stalls + # when many environments are constructed sequentially in one Kit process. + get_settings_manager().set_bool("/physics/cooking/ujitsoCollisionCooking", False) + + try: + for task_name in task_names: + _run_export_task(task_name, simulation_app, sim_utils, get_settings_manager, resolve_task_config) + finally: + simulation_app.close() + + +def _export_batch_command(task_names: list[str]) -> list[str]: + """Build the subprocess command for an export batch.""" + return [sys.executable, str(_THIS_SCRIPT), "--export-flow-batch", *task_names] + + +def _run_export_batch_entrypoint() -> None: + """Run the helper subprocess entrypoint.""" + tasks = sys.argv[2:] + if not tasks: + raise ValueError("Expected at least one task for --export-flow-batch") + _run_export_batch(tasks) + + +@pytest.mark.parametrize("task_names", _task_batches(TASKS), ids=_batch_id) +def test_export_flow(task_names: list[str]): + """Run export.py for a task batch and assert the expected artifacts are created.""" + try: + result = subprocess.run( + _export_batch_command(task_names), + cwd=_REPO_ROOT, + capture_output=True, + text=True, + timeout=_EXPORT_BATCH_TIMEOUT, + ) + except subprocess.TimeoutExpired as exc: + stdout = _ensure_text(exc.stdout) + stderr = _ensure_text(exc.stderr) + pytest.fail( + f"export batch timed out after {_EXPORT_BATCH_TIMEOUT}s for {task_names}.\n" + f"--- stdout tail ---\n{stdout[-_OUTPUT_TAIL_CHARS:]}\n" + f"--- stderr tail ---\n{stderr[-_OUTPUT_TAIL_CHARS:]}" + ) + + if result.returncode != 0: + pytest.fail( + f"export batch exited with code {result.returncode} for {task_names}.\n" + f"--- stdout tail ---\n{result.stdout[-_OUTPUT_TAIL_CHARS:]}\n" + f"--- stderr tail ---\n{result.stderr[-_OUTPUT_TAIL_CHARS:]}" + ) + + +if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--export-flow-batch": + _run_export_batch_entrypoint() diff --git a/tools/conftest.py b/tools/conftest.py index 5b844442c0a8..15aaa2323647 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -341,6 +341,7 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci): sys.executable, "-m", "pytest", + "-s", "--no-header", f"--config-file={workspace_root}/pyproject.toml", f"--junitxml=tests/test-reports-{str(file_name)}.xml", From ef6c178ddf35f4c9a671fd873b61a8b68963f6f6 Mon Sep 17 00:00:00 2001 From: Pascal Roth <57946385+pascal-roth@users.noreply.github.com> Date: Fri, 15 May 2026 20:37:14 +0200 Subject: [PATCH 065/133] Fix skrl drone-ARL configs using STATES instead of OBSERVATIONS (#5613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Training/playing the drone-ARL skrl tasks (`Isaac-TrackPositionNoObstacles-ARL-Robot-1-*`, `Isaac-Navigation-3DObstacles-ARL-Robot-1-*`) crashed at `Runner(env, agent_cfg)` with `AttributeError: 'NoneType' object has no attribute 'shape'` in `LazyLinear.initialize_parameters`. - Root cause: the two drone-ARL skrl YAMLs declare `input: STATES` for the policy and value networks. In skrl 2.0 the model instantiator now resolves `STATES` against `state_space`, which is `None` for single-agent envs, so the generated `compute()` calls `self.mlp_container(None)` and the first `LazyLinear` raises on `input.shape[-1]`. - Switched both configs to `input: OBSERVATIONS`, matching every other single-agent skrl config in IsaacLab. The two multi-agent MAPPO configs that legitimately use `STATES` (`direct/shadow_hand_over`, `direct/cart_double_pendulum`) are unaffected. ## Files changed - `source/isaaclab_tasks/.../drone_arl/track_position_state_based/config/arl_robot_1/agents/skrl_ppo_cfg.yaml` - `source/isaaclab_tasks/.../drone_arl/navigation/config/arl_robot_1/agents/skrl_rough_ppo_cfg.yaml` - `source/isaaclab_tasks/changelog.d/proth-fix-skrl-drone-arl-states-input.rst` (patch fragment) ## Test plan - [x] Reproduced the exact stack trace (`` line 48 → `container.py:253` → `lazy.py:263` → `linear.py:323`) with `input: STATES` using a standalone skrl `shared_model` instantiation against a single-agent obs/action space. - [x] After the YAML change, the same standalone instantiation loads both edited YAMLs from disk and successfully runs `init_state_dict(role='policy')` and `init_state_dict(role='value')` on a `SharedModel`. - [x] `./isaaclab.sh -f` (pre-commit) passes locally. - [ ] Full end-to-end `train.py`/`play.py` smoke test against: - `Isaac-TrackPositionNoObstacles-ARL-Robot-1-v0` - `Isaac-TrackPositionNoObstacles-ARL-Robot-1-Play-v0 --use_pretrained_checkpoint` - `Isaac-Navigation-3DObstacles-ARL-Robot-1-v0` Co-authored-by: Kelly Guo --- .../proth-fix-skrl-drone-arl-states-input.rst | 10 ++++++++++ .../config/arl_robot_1/agents/skrl_rough_ppo_cfg.yaml | 4 ++-- .../config/arl_robot_1/agents/skrl_ppo_cfg.yaml | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/proth-fix-skrl-drone-arl-states-input.rst diff --git a/source/isaaclab_tasks/changelog.d/proth-fix-skrl-drone-arl-states-input.rst b/source/isaaclab_tasks/changelog.d/proth-fix-skrl-drone-arl-states-input.rst new file mode 100644 index 000000000000..99be9d9990cc --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/proth-fix-skrl-drone-arl-states-input.rst @@ -0,0 +1,10 @@ +Fixed +^^^^^ + +* Fixed ``AttributeError: 'NoneType' object has no attribute 'shape'`` raised + when instantiating skrl PPO models for the ``Isaac-TrackPositionNoObstacles-ARL-Robot-1-*`` + and ``Isaac-Navigation-3DObstacles-ARL-Robot-1-*`` tasks. The drone-ARL skrl + configs used ``input: STATES`` for both policy and value networks, which + skrl 2.0 resolves against ``state_space`` (``None`` for single-agent + environments). Updated the configs to use ``input: OBSERVATIONS`` to match + the rest of the single-agent skrl configs in IsaacLab. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/skrl_rough_ppo_cfg.yaml b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/skrl_rough_ppo_cfg.yaml index c1465d64bef5..a5519569a1e9 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/skrl_rough_ppo_cfg.yaml +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/skrl_rough_ppo_cfg.yaml @@ -19,7 +19,7 @@ models: initial_log_std: 0.0 network: - name: mlp - input: STATES + input: OBSERVATIONS layers: [256, 128, 64] activations: elu - name: gru @@ -33,7 +33,7 @@ models: clip_actions: False network: - name: mlp - input: STATES + input: OBSERVATIONS layers: [256, 128, 64] activations: elu - name: gru diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/skrl_ppo_cfg.yaml b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/skrl_ppo_cfg.yaml index 3a5779e0106a..1d4f876d7b7f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/skrl_ppo_cfg.yaml +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/skrl_ppo_cfg.yaml @@ -19,7 +19,7 @@ models: initial_log_std: 0.0 network: - name: mlp - input: STATES + input: OBSERVATIONS layers: [256, 128, 64] activations: elu - name: gru @@ -33,7 +33,7 @@ models: clip_actions: False network: - name: mlp - input: STATES + input: OBSERVATIONS layers: [256, 128, 64] activations: elu - name: gru From 4e625587d0bf49275ee5aa88232e5dc949cc02b6 Mon Sep 17 00:00:00 2001 From: rwiltz <165190220+rwiltz@users.noreply.github.com> Date: Fri, 15 May 2026 14:47:20 -0400 Subject: [PATCH 066/133] Add teleop replay agent for non-interactive CI runs (#5507) # Description Adds a permanent, decoupled CI entry-point for replaying captured teleop sessions against an Isaac Lab environment. Replaces the runtime patch the `teleop-cicd` pipeline currently applies to `scripts/environments/teleoperation/teleop_se3_agent.py` so the user-journey script is no longer mutated at CI time. Fixes # (issue) ## Type of change - 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: Andrei Aristarkhov Co-authored-by: Kelly Guo --- .../teleoperation/teleop_replay_agent.py | 464 ++++++++++++++++++ .../rwiltz-xcr-replay-agent.minor.rst | 60 +++ .../isaaclab_teleop/automation/__init__.py | 8 + .../isaaclab_teleop/automation/xcr_replay.py | 138 ++++++ 4 files changed, 670 insertions(+) create mode 100644 scripts/environments/teleoperation/teleop_replay_agent.py create mode 100644 source/isaaclab_teleop/changelog.d/rwiltz-xcr-replay-agent.minor.rst create mode 100644 source/isaaclab_teleop/isaaclab_teleop/automation/__init__.py create mode 100644 source/isaaclab_teleop/isaaclab_teleop/automation/xcr_replay.py diff --git a/scripts/environments/teleoperation/teleop_replay_agent.py b/scripts/environments/teleoperation/teleop_replay_agent.py new file mode 100644 index 000000000000..7d5d2cbf62f2 --- /dev/null +++ b/scripts/environments/teleoperation/teleop_replay_agent.py @@ -0,0 +1,464 @@ +# 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 + +"""CI/automation entry point for replaying captured teleop sessions. + +This is the non-interactive counterpart to ``teleop_se3_agent.py``. It builds +a teleop environment, attaches a teleop device, schedules a replay driver, +and pumps the simulation loop until the replay completes and the application +exits. The user-journey teleop script remains ``teleop_se3_agent.py``. + +The current implementation drives playback through Kit's OpenXR XCR backend +and the legacy native XR ``handtracking`` device. The script is structured so +that the replay-driver call site and device selection are the only pieces +that need to change when migrating to a different replay backend in the +future (e.g. an Isaac Teleop ``TeleopSession`` running in replay mode). +""" + +"""Launch Isaac Sim Simulator first.""" + +import argparse + +from isaaclab.app import AppLauncher + +parser = argparse.ArgumentParser( + description=( + "Replay a captured teleop session against an Isaac Lab environment. " + "CI/automation entry point; for interactive teleoperation see teleop_se3_agent.py." + ) +) +parser.add_argument("--task", type=str, required=True, help="Name of the task.") +parser.add_argument("--num_envs", type=int, default=1, help="Number of environments to simulate.") +parser.add_argument( + "--replay_file", + type=str, + required=True, + help="Absolute path to the recorded teleop session to replay.", +) +parser.add_argument( + "--replay_start_delay_s", + type=float, + default=0.0, + help="Seconds to wait after the environment is up before starting replay (default: 120.0).", +) +parser.add_argument( + "--num_success_steps", + type=int, + default=1, + help=( + "Number of consecutive steps the task success term must hold before declaring success and" + " resetting the env. Mirrors the equivalent flag in record_demos.py. (default: 10)" + ), +) +AppLauncher.add_app_launcher_args(parser) +args_cli = parser.parse_args() + +app_launcher_args = vars(args_cli) +app_launcher = AppLauncher(app_launcher_args) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + + +import asyncio +import logging +import time +from collections.abc import Callable + +import gymnasium as gym +import torch + +from isaaclab.devices import DeviceBase +from isaaclab.devices.openxr import remove_camera_configs +from isaaclab.devices.teleop_device_factory import create_teleop_device +from isaaclab.envs import ManagerBasedRLEnvCfg + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.utils import parse_env_cfg + +logger = logging.getLogger(__name__) + +_LEGACY_DEVICE_NAME = "handtracking" + +# Module-level set of pending replay-driver tasks. The asyncio event loop only +# keeps weak references to tasks, so a task that is not referenced elsewhere +# may be garbage-collected before it completes. The completion callback below +# discards the task again once it finishes. +_PENDING_REPLAY_TASKS: set[asyncio.Future] = set() + + +_RENDERER_SETTLE_FRAMES: int = 30 +"""Number of extra render frames pumped after the USD stage finishes loading. + +Kit's stage-load status flips to ``count_loading == 0`` as soon as every referenced +asset has been resolved, but the renderer pipeline (shader compilation, +articulation-view binding, material warm-up) typically needs a few more event-loop +ticks to converge. Thirty frames at the default Kit render cadence is ~0.5 s on +most machines and is deterministic per-machine -- unlike a wall-clock delay it +does not have to be tuned for hardware. +""" + +_DEFAULT_MAX_STAGE_LOAD_WAIT_S: float = 300.0 +"""Safety cap on the deterministic stage-load wait. + +Hit only when something is misconfigured (missing asset, slow Nucleus, etc.); a +warning is logged and the loop continues so CI does not hang silently on a +broken capture. +""" + + +def _wait_for_stage_load(max_wait_s: float = _DEFAULT_MAX_STAGE_LOAD_WAIT_S) -> None: + """Block until the USD stage finishes resolving every referenced asset. + + Polls :meth:`omni.usd.UsdContext.get_stage_loading_status`. The third element of + the returned tuple is the count of assets Kit still has pending; when it + reaches zero the stage is fully streamed in and the renderer pipeline is ready + to draw against it. After the count reaches zero this function pumps an + additional :data:`_RENDERER_SETTLE_FRAMES` ``simulation_app.update()`` calls so + shaders, materials, and articulation views finish warming up before the caller + begins consuming replay data or stepping the env. + + Unlike :attr:`args_cli.replay_start_delay_s`, which is wall-clock and has to be + tuned per-host, this wait is deterministic and self-adapting: it returns + immediately on a warm asset cache and waits exactly long enough on a cold one. + + Args: + max_wait_s: Upper bound on how long to spin on a non-zero loading count + before warning and returning. Acts as a safety net for misconfigured + scenes (missing assets, slow Nucleus); a successful run typically + completes well within this bound. + """ + try: + import omni.usd + except (ImportError, ModuleNotFoundError): + logger.warning("omni.usd not available; skipping deterministic stage-load wait") + return + + print("Waiting for USD stage to finish loading...") + start_s = time.monotonic() + last_progress_log_s = start_s + while simulation_app.is_running(): + context = omni.usd.get_context() + if context is None: + break + # get_stage_loading_status -> (message, count_loaded, count_loading) + _, _, count_loading = context.get_stage_loading_status() + if count_loading == 0: + break + elapsed_s = time.monotonic() - start_s + if elapsed_s >= max_wait_s: + logger.warning( + "Stage still reports %d assets pending after %.1fs; proceeding anyway. Replay may race the renderer.", + count_loading, + max_wait_s, + ) + break + if time.monotonic() - last_progress_log_s >= 5.0: + print(f" stage loading: {count_loading} assets pending (elapsed {elapsed_s:.1f}s)") + last_progress_log_s = time.monotonic() + simulation_app.update() + + elapsed_s = time.monotonic() - start_s + print(f"Stage load complete after {elapsed_s:.1f}s; settling renderer for {_RENDERER_SETTLE_FRAMES} frames...") + for _ in range(_RENDERER_SETTLE_FRAMES): + if not simulation_app.is_running(): + return + simulation_app.update() + + +def _prepare_env_cfg(task: str, num_envs: int, device: str) -> tuple[ManagerBasedRLEnvCfg, object | None]: + """Build and tweak an env config suitable for non-interactive replay. + + Mirrors the env-config mutations performed by ``record_demos.py``'s + :func:`create_environment_config`: + + * The ``success`` term is extracted and cleared from the env config so the + script can drive success detection (and the matching reset cycle) + explicitly via :func:`_process_success_condition`, gated by + ``--num_success_steps``. This matches record_demos.py's pattern of + manually counting consecutive success steps before resetting. + * Every other termination term -- including ``time_out`` and any + task-specific failure terms (e.g. ``object_dropping``, + ``object_too_far``) -- is left active. ``env.step`` then auto-invokes + ``_reset_idx`` for any env whose termination fires; the main loop + detects this via the returned ``terminated``/``truncated`` tensors + and completes the reset cycle (sim reinit + teleop device reset) + so Pink IK starts the next attempt with fresh articulation views. + + Returns: + Tuple ``(env_cfg, success_term)``. ``success_term`` is ``None`` when + the env doesn't define a ``success`` termination term. + """ + env_cfg = parse_env_cfg(task, device=device, num_envs=num_envs) + env_cfg.env_name = task.split(":")[-1] + if not isinstance(env_cfg, ManagerBasedRLEnvCfg): + raise ValueError( + "teleop_replay_agent only supports ManagerBasedRLEnv environments. " + f"Received environment config type: {type(env_cfg).__name__}" + ) + success_term: object | None = None + if hasattr(env_cfg.terminations, "success"): + success_term = env_cfg.terminations.success + env_cfg.terminations.success = None + else: + logger.warning( + "No success termination term was found in the environment;" + " success-driven resets will not fire during replay." + ) + env_cfg = remove_camera_configs(env_cfg) + env_cfg.sim.render.antialiasing_mode = "DLSS" + return env_cfg, success_term + + +def _create_replay_teleop_device( + env_cfg: ManagerBasedRLEnvCfg, task: str, callbacks: dict[str, Callable[[], None]] +) -> DeviceBase: + """Instantiate the teleop device used during replay. + + Today this returns the legacy native XR ``handtracking`` device because the + XCR backend replays through Kit's OpenXR runtime, which is the surface + that device consumes. When migrating to a ``TeleopSession``-driven replay + backend, swap this for an ``IsaacTeleopDevice`` configured in replay mode. + + Args: + env_cfg: The environment configuration. + task: Task identifier, used for diagnostic messages. + callbacks: Teleop-command callbacks (typically just ``"START"`` for + replay; see :func:`main`) registered on the device. The XCR + replay dispatches the recorded user's start gesture through + Kit's OpenXR message bus, which the legacy + :class:`~isaaclab.devices.openxr.OpenXRDevice` translates into + calls into this dictionary. + """ + if not hasattr(env_cfg, "teleop_devices") or _LEGACY_DEVICE_NAME not in env_cfg.teleop_devices.devices: + raise ValueError( + f"Task '{task}' does not expose a teleop device named '{_LEGACY_DEVICE_NAME}'. " + "Use a task whose env config defines that legacy device, " + "or update _create_replay_teleop_device to use a different backend." + ) + teleop_interface = create_teleop_device(_LEGACY_DEVICE_NAME, env_cfg.teleop_devices.devices, callbacks) + if teleop_interface is None: + raise RuntimeError(f"Failed to create '{_LEGACY_DEVICE_NAME}' teleop device for task '{task}'.") + return teleop_interface + + +def _on_replay_driver_done(future: asyncio.Future) -> None: + """Surface replay-driver failures so the CI process does not hang. + + When :func:`start_xcr_replay` raises before reaching ``post_quit`` (e.g. + :class:`FileNotFoundError`, an ``omni.kit`` import failure, or a Kit + runtime error) the exception sits silently on the discarded future and + Python only emits a ``Future exception was never retrieved`` warning on + GC. The main loop would then keep spinning forever because nothing ever + flips ``simulation_app.is_running()`` to ``False``. + + This callback retrieves the exception, logs it with traceback, and asks + Kit to quit so the host process exits cleanly. It also drops the task + from :data:`_PENDING_REPLAY_TASKS` now that it is done. + """ + _PENDING_REPLAY_TASKS.discard(future) + if future.cancelled(): + return + exc = future.exception() + if exc is None: + return + logger.error("XCR replay driver failed", exc_info=exc) + try: + import omni.kit.app + + omni.kit.app.get_app().post_quit() + except Exception: + logger.exception("Failed to post_quit after replay driver failure") + + +def _handle_reset(env: gym.Env, teleop_interface: DeviceBase) -> None: + """Run the full env+teleop reset cycle used by ``record_demos.py``. + + Mirrors :func:`scripts.tools.record_demos.handle_reset` (sans the + instruction-display update, which the headless replay agent doesn't + own). ``env.sim.reset()`` does the hard physics reinit that keeps Pink + IK seeded against fresh articulation views; see the initial-reset note + in :func:`main`. ``env.recorder_manager.reset()`` is a no-op when no + recorders are configured (the default for this script), but kept for + parity with record_demos.py so future recorder additions don't have to + re-derive the call sequence. + """ + print("Resetting environment...") + env.sim.reset() + env.recorder_manager.reset() + env.reset() + teleop_interface.reset() + + +def _process_success_condition( + env: gym.Env, + success_term: object | None, + success_step_count: int, + num_success_steps: int, +) -> tuple[int, bool]: + """Track consecutive success steps and decide whether to reset. + + Mirrors :func:`scripts.tools.record_demos.process_success_condition` + minus the recorder-export side effects, which this script does not own. + + Returns: + Tuple ``(updated_success_step_count, reset_due_to_success)``. + """ + if success_term is None: + return success_step_count, False + + if bool(success_term.func(env, **success_term.params)[0]): + success_step_count += 1 + if success_step_count >= num_success_steps: + print(f"Success condition met after {success_step_count} consecutive steps; resetting env.") + return success_step_count, True + else: + success_step_count = 0 + + return success_step_count, False + + +def _schedule_replay_driver(replay_file: str, start_delay_s: float) -> None: + """Schedule the replay driver coroutine on the running asyncio loop. + + Today this drives Kit's OpenXR XCR backend. To migrate to a different + replay backend (e.g. ``TeleopSession`` running in replay mode), replace + this call with the equivalent driver hook -- this is the only XCR-specific + site outside the device-creation helper above. + """ + from isaaclab_teleop.automation import XcrReplayConfig, start_xcr_replay + + future = asyncio.ensure_future( + start_xcr_replay(XcrReplayConfig(replay_file=replay_file, start_delay_s=start_delay_s)) + ) + _PENDING_REPLAY_TASKS.add(future) + future.add_done_callback(_on_replay_driver_done) + + +def main() -> None: + """Replay a captured teleop session against an Isaac Lab environment. + + Builds the env, attaches a replay teleop device, schedules the replay + driver as a background task, and runs the standard teleop step loop + until the application is closed (driver-issued ``post_quit``, Kit + shutdown, or operator interrupt). + + The loop deliberately does not call ``env.step()`` until the legacy + :class:`OpenXRDevice` dispatches a ``"START"`` callback. The XCR replay + restores the recorded user's start gesture through Kit's OpenXR message + bus, and the device routes that into the callback registered here -- + exactly the path ``record_demos.py`` uses to know when to start + recording. Until that ``"START"`` arrives, the OpenXR runtime is silent + and the device's :meth:`advance` would otherwise return a default zero + pose for both wrists, which stepping the env with would drive Pink IK + toward the world origin. + + Unlike :file:`record_demos.py`, the replay agent does **not** subscribe + to the ``"STOP"`` callback: Kit's ``teleop_command`` bus drains queued + events as a batch when the AR profile is enabled, so a recorded STOP + gesture fires within milliseconds of START and would gate the env-step + loop off again before Pink IK had time to converge. + + Resource cleanup is wrapped in a ``try/finally`` so that ``env.close()`` + always runs, even when device construction or any subsequent setup + raises -- otherwise the USD stage would leak across CI runs. + """ + env: gym.Env | None = None + try: + env_cfg, success_term = _prepare_env_cfg(args_cli.task, args_cli.num_envs, args_cli.device) + env = gym.make(args_cli.task, cfg=env_cfg).unwrapped + + # Single-element list so the closure can mutate it without ``nonlocal``. + teleop_active = [False] + + def _on_start() -> None: + if not teleop_active[0]: + teleop_active[0] = True + print("Teleop START received from XCR replay; forwarding actions to env.step().") + + # Intentionally only subscribe to START, not STOP. The XCR replay + # restores both the recorded user's start and stop gestures from the + # capture file, and Kit's ``teleop_command`` message bus appears to + # drain queued events as a batch when the AR profile is enabled -- + # so a STOP fires within milliseconds of START and would shut the env + # step loop off before Pink IK has had a chance to converge. For the + # replay agent's one-shot CI use case the only valid termination is + # the driver's ``post_quit`` (or a real exception in the loop). + callbacks: dict[str, Callable[[], None]] = {"START": _on_start} + + teleop_interface = _create_replay_teleop_device(env_cfg, args_cli.task, callbacks) + print(f"Using teleop device: {teleop_interface}") + + # Mirror the reset sequence used by ``record_demos.py``: ``sim.reset()`` + # does a hard physics reinit (re-binds articulation views, plays the + # timeline) that ``env.reset()`` alone does not perform. Pink IK reads + # ``data.joint_pos.torch`` every step to seed Pinocchio's configuration + # and to compute ``target = curr + delta``; if the articulation view is + # stale, every IK call produces zero-delta arm targets while the + # hand-finger path (which bypasses IK) keeps tracking. See PR #5507. + env.sim.reset() + env.reset() + teleop_interface.reset() + + # Deterministic warmup: block until omni.usd reports zero pending + # assets, then pump a fixed number of renderer-settle frames. This + # is independent of ``--replay_start_delay_s``; the wall-clock delay + # below covers the XCR-side OpenXR profile warm-up, while this wait + # ensures the stage is fully streamed in before the XCR replay + # injects its first recorded pose. + _wait_for_stage_load() + + print(f"Replay agent started; replay will begin in {args_cli.replay_start_delay_s:.1f} seconds.") + _schedule_replay_driver(args_cli.replay_file, args_cli.replay_start_delay_s) + + success_step_count = 0 + while simulation_app.is_running(): + try: + with torch.inference_mode(): + action = teleop_interface.advance() + if action is None or not teleop_active[0]: + env.sim.render() + continue + actions = action.repeat(env.num_envs, 1) + _, _, terminated, truncated, _ = env.step(actions) + + # Failure path: ``env.step`` already invoked ``_reset_idx`` + # for any env whose ``time_out`` or task-specific failure + # term fired (success was extracted up front so it does + # not show up here). We still need to refresh sim physics + # state and the teleop device so Pink IK starts the next + # attempt with fresh articulation views. + if bool(terminated.any().item()) or bool(truncated.any().item()): + print("Failure condition met (terminated/timed-out); resetting env.") + _handle_reset(env, teleop_interface) + success_step_count = 0 + continue + + # Success path: success_term was cleared from the env cfg + # so ``env.step`` does not auto-reset on it. Mirror + # record_demos.py and trigger a reset only after the + # success condition has held for ``num_success_steps`` + # consecutive steps. + success_step_count, reset_on_success = _process_success_condition( + env, success_term, success_step_count, args_cli.num_success_steps + ) + if reset_on_success: + _handle_reset(env, teleop_interface) + success_step_count = 0 + except Exception: + # ``logger.exception`` preserves the full traceback; bare + # ``logger.error`` would only log the message. + logger.exception("Error during simulation step") + break + finally: + if env is not None: + env.close() + print("Environment closed") + + +if __name__ == "__main__": + main() + simulation_app.update() + simulation_app.close() diff --git a/source/isaaclab_teleop/changelog.d/rwiltz-xcr-replay-agent.minor.rst b/source/isaaclab_teleop/changelog.d/rwiltz-xcr-replay-agent.minor.rst new file mode 100644 index 000000000000..828709832ac7 --- /dev/null +++ b/source/isaaclab_teleop/changelog.d/rwiltz-xcr-replay-agent.minor.rst @@ -0,0 +1,60 @@ +Added +^^^^^ + +* Added ``scripts/environments/teleoperation/teleop_replay_agent.py``, a + non-interactive entry point used by CI to replay captured teleop sessions + against an Isaac Lab environment, plus a small internal + ``isaaclab_teleop.automation`` subpackage backing it. Replaces the runtime + patch the ``teleop-cicd`` pipeline previously applied to + ``teleop_se3_agent.py``. + +Fixed +^^^^^ + +* Fixed ``teleop_replay_agent.py`` driving the robot toward the world origin + for the duration of ``--replay_start_delay_s``. The legacy + :class:`~isaaclab.devices.openxr.OpenXRDevice` returns a default zero pose + while the OpenXR runtime is silent, so calling ``env.step()`` during the + start-delay window fed the Pink IK garbage targets and corrupted the robot + pose long before real hand-tracking data flowed. The agent now registers + ``"START"`` / ``"STOP"`` callbacks on the device -- the same path + ``record_demos.py`` uses -- and only steps the env once the XCR replay + dispatches the recorded ``"start"`` message through Kit's OpenXR message + bus. +* Fixed ``teleop_replay_agent.py`` hanging the CI process when the XCR + replay driver coroutine raised before reaching ``post_quit``. The + previously discarded :class:`asyncio.Future` is now retained and a done + callback logs the failure with traceback and asks Kit to quit so the + host process exits cleanly. +* Fixed ``teleop_replay_agent.py`` leaking the USD stage when device + construction or environment setup raised. ``env.close()`` now runs from a + ``try/finally`` block so cleanup happens on every exit path. +* Fixed ``teleop_replay_agent.py`` producing a frozen-arms / hands-only + symptom during replay. Kit's ``teleop_command`` message bus drains + queued events as a batch when the AR profile is enabled, so the + recorded user's STOP gesture would fire within milliseconds of START + and gate ``env.step()`` off again before Pink IK had time to converge. + The replay agent now subscribes only to ``"START"``: replay is one-shot + and the only valid termination is the driver's ``post_quit``. +* Aligned ``teleop_replay_agent.py``'s pre-loop reset sequence with + ``record_demos.py`` -- ``env.sim.reset()`` then ``env.reset()`` then + ``teleop_interface.reset()`` -- so the hard physics reinit re-binds the + articulation tensor views that + :meth:`~isaaclab.controllers.pink_ik.PinkIKController.compute` reads + from each step. +* Cleared :attr:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.TerminationsCfg.success` + in the replay env config so a successful replay does not snap the robot + back to its initial pose mid-loop. + +Changed +^^^^^^^ + +* Added :paramref:`~isaaclab_teleop.automation.XcrReplayConfig.max_replay_duration_s` + (default: ``3600``) so the completion-poll loop in + :func:`~isaaclab_teleop.automation.start_xcr_replay` is bounded. If + Kit's :mod:`xcr_player` ever fails to clear its private playback + subscription, the coroutine now returns instead of spinning forever. +* Stored the :class:`omni.kit.xr.core.recorder._xr_xcr.XCRReplayAPI` + instance in a local variable inside + :func:`~isaaclab_teleop.automation.start_xcr_replay` so it stays alive + for the lifetime of the replay coroutine. diff --git a/source/isaaclab_teleop/isaaclab_teleop/automation/__init__.py b/source/isaaclab_teleop/isaaclab_teleop/automation/__init__.py new file mode 100644 index 000000000000..8619b26fb42c --- /dev/null +++ b/source/isaaclab_teleop/isaaclab_teleop/automation/__init__.py @@ -0,0 +1,8 @@ +# 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 .xcr_replay import XcrReplayConfig, start_xcr_replay + +__all__ = ["XcrReplayConfig", "start_xcr_replay"] diff --git a/source/isaaclab_teleop/isaaclab_teleop/automation/xcr_replay.py b/source/isaaclab_teleop/isaaclab_teleop/automation/xcr_replay.py new file mode 100644 index 000000000000..e68842521057 --- /dev/null +++ b/source/isaaclab_teleop/isaaclab_teleop/automation/xcr_replay.py @@ -0,0 +1,138 @@ +# 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 + +"""Internal XCR replay driver used by ``teleop_replay_agent.py``. + +Schedules a Kit ``omni.kit.xr.core`` XR Capture Replay against an already +running Kit application. This is a transitional implementation; the intended +long-term replacement drives playback through an Isaac Teleop +``TeleopSession`` rather than through Kit's OpenXR XCR backend. + +All Kit imports are deferred to :func:`start_xcr_replay` so importing this +module outside of a running Kit application is safe. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class XcrReplayConfig: + """Configuration for an XCR replay automation run. + + Args: + replay_file: Absolute path to the ``.bin`` XCR capture to replay. + profile_name: Name of the Kit XR profile to enable for replay. The + CI pipelines use ``"ar"``. + start_delay_s: Seconds to wait after the environment is up before + starting replay. Gives the simulation time to settle so initial + warm-up frames do not skew metrics. + quit_on_complete: When ``True``, call + :meth:`omni.kit.app.IApp.post_quit` once replay finishes so the + host CI process exits cleanly. + max_replay_duration_s: Upper bound on how long the coroutine will + wait for ``xcr_player`` to clear its playback subscription. If + replay never finishes (e.g. Kit-side bug, captured session + never emits a stop event), the coroutine returns after this + many seconds so CI does not hang indefinitely. + """ + + replay_file: str + profile_name: str = "ar" + start_delay_s: float = 120.0 + quit_on_complete: bool = True + max_replay_duration_s: float = 3600.0 + + +async def start_xcr_replay(cfg: XcrReplayConfig) -> None: + """Drive an XCR replay against the currently running Kit application. + + This coroutine is intended to be scheduled (e.g. via + :func:`asyncio.ensure_future`) from a host CI script after the teleop + environment has been created. It mirrors the original + ``xcr_perf_automation.run_xcr_replay`` flow used by the ``teleop-cicd`` + pipeline so captured CI metrics remain comparable across the patch + migration. + + Args: + cfg: Replay configuration. The replay file must exist on disk. + + Raises: + FileNotFoundError: If :attr:`XcrReplayConfig.replay_file` does not + exist when the coroutine starts. + """ + if not os.path.exists(cfg.replay_file): + raise FileNotFoundError(f"XCR replay file not found: {cfg.replay_file}") + + import carb.settings + import omni.kit.app + import omni.kit.xr.core.test_utils as test_utils + from omni.kit.xr.core import XRCore + from omni.kit.xr.core.recorder._xr_xcr import XCRReplayAPI + from omni.kit.xr.core.recorder.scripts import xcr_player + from omni.kit.xr.core.recorder.scripts.xcr_player import start_replay_if_enabled + + settings = carb.settings.get_settings() + + await omni.kit.app.get_app().next_update_async() + + settings.set("/xr/system/openxr/xcr/capture/enabled", False) + settings.set("/xr/system/openxr/xcr/replay/enabled", True) + settings.set("/xr/system/openxr/xcr/replay/replayFile", cfg.replay_file) + settings.set(f"/xr/profile/{cfg.profile_name}/system/display", "OpenXR") + + XRCore.get_singleton().get_profile(cfg.profile_name) + + # Construct the replay API so the runtime registers the replay backend + # before start_replay_if_enabled() is called. Bind to a local so the + # object stays alive for the lifetime of the coroutine in case any + # internal subscription is tied to the instance lifetime. + _replay_api = XCRReplayAPI() # noqa: F841 + + logger.info("XCR replay: waiting %.1f seconds before starting replay", cfg.start_delay_s) + await asyncio.sleep(cfg.start_delay_s) + logger.info("XCR replay: starting replay from %s", cfg.replay_file) + + start_replay_if_enabled() + + # Pump a couple of frames so the replay service is fully initialized + # before the AR profile is enabled. + await omni.kit.app.get_app().next_update_async() + await omni.kit.app.get_app().next_update_async() + + logger.info("XCR replay: enabling XR profile %s", cfg.profile_name) + async with test_utils.EnabledXRProfile(cfg.profile_name, 0): + logger.info("XCR replay: XR profile enabled, replay should be playing") + + # The xcr_player module clears its playback subscription when replay + # finishes; that is the public-ish signal we have for completion. + # Polling a private attribute is fragile (it may be renamed or + # removed in future Kit versions); the bounded wait below keeps a + # stuck poll from hanging the CI job if that ever happens. + poll_interval_s = 5.0 + elapsed_s = 0.0 + while xcr_player._xcr_playback_subscription is not None: + if elapsed_s >= cfg.max_replay_duration_s: + logger.warning( + "XCR replay: timed out after %.1fs waiting for playback to complete; aborting wait.", + cfg.max_replay_duration_s, + ) + break + logger.debug("XCR replay: waiting for playback subscription to clear") + await asyncio.sleep(poll_interval_s) + elapsed_s += poll_interval_s + + await omni.kit.app.get_app().next_update_async() + + if cfg.quit_on_complete: + omni.kit.app.get_app().post_quit() + + logger.info("XCR replay: finished") From 498585596850679a3f0e5419760c37cb1dddebb8 Mon Sep 17 00:00:00 2001 From: mingxueg Date: Sat, 16 May 2026 02:50:31 +0800 Subject: [PATCH 067/133] Fixed rlinf install docs (#5639) # Description Fixed rlinf install docs to run RLinf RL posting training ## 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 - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/source/experimental-features/bleeding-edge.rst | 5 ++++- .../reinforcement-learning/rl_existing_scripts.rst | 12 +++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/source/experimental-features/bleeding-edge.rst b/docs/source/experimental-features/bleeding-edge.rst index 860c08611d1e..176925f14b57 100644 --- a/docs/source/experimental-features/bleeding-edge.rst +++ b/docs/source/experimental-features/bleeding-edge.rst @@ -11,7 +11,7 @@ To address this, some major features will be released as Experimental Feature Br This way, the community can experiment with and contribute to the feature before it's fully integrated, reducing the likelihood of being derailed by unexpected and new errors. RL Post-Training for VLA Models ---------------------------------- +------------------------------- `RLinf `_ is a flexible and scalable open-source RL infrastructure designed for Embodied and Agentic AI. This integration enables **reinforcement learning fine-tuning of Vision-Language-Action @@ -83,6 +83,9 @@ From the Isaac Lab root directory: pip install -e .[base] --no-deps cd ../ + # Install flash-attn (must be built against the correct PyTorch) + pip install --no-build-isolation flash-attn==2.8.3 + Quick Start ~~~~~~~~~~~ diff --git a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst index 008ebf91c0d8..08612e4434a3 100644 --- a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst +++ b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst @@ -360,15 +360,17 @@ large VLA models that don't fit on a single GPU. .. code:: bash # Step 1: Install RLinf and its dependencies (from isaaclab_contrib) - pip install -e "source/isaaclab_contrib[rlinf]" + pip install -e "source/isaaclab_contrib[rlinf]" --ignore-requires-python - # Step 2: Clone and install Isaac-GR00T (for VLA model support) - cd scripts/reinforcement_learning/rlinf + # Step 2: Clone and install Isaac-GR00T (pinned version, for VLA model support) git clone https://github.com/NVIDIA/Isaac-GR00T.git - pip install -e Isaac-GR00T/.[base] --no-deps + cd Isaac-GR00T + git checkout 4af2b622892f7dcb5aae5a3fb70bcb02dc217b96 + pip install -e .[base] --no-deps + cd ../ # Step 3: Install flash-attn (must be built against the correct PyTorch) - pip install --no-build-isolation flash-attn==2.7.1.post4 + pip install --no-build-isolation flash-attn==2.8.3 - Training a VLA agent with RLinf: From 7015be2f0d5671963c76ca265814e554d79b82a0 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Fri, 15 May 2026 13:03:31 -0700 Subject: [PATCH 068/133] Refactor train/play and create uv run workflow without dedicated virtual environments (#5623) # Description This PR refactors the reinforcement learning train/play scripts into unified entry points while preserving the existing library folder structure and adding a lightweight `uv` workflow for fresh source checkouts. The main changes are: - Added unified RL entrypoints: - `scripts/reinforcement_learning/train.py --library ` - `scripts/reinforcement_learning/play.py --library ` - Added library-specific implementation files under the existing library folders, for example: - `scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py` - `scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py` - Kept the old per-library `train.py` and `play.py` scripts intact, with deprecation warnings and migration examples. - Added shared RL entrypoint utilities in `scripts/reinforcement_learning/common.py`. - Added direct Isaac Lab CLI commands: - `./isaaclab.sh train --library ...` - `./isaaclab.sh play --library ...` - Kept bare script aliases for `./isaaclab.sh -p train.py ...` and `./isaaclab.sh -p play.py ...`. - Added Python package entry points so installed environments can run: - `train --library ...` - `play --library ...` - Added a root source-checkout `pyproject.toml` project so a fresh clone can run kitless Newton training with: - `uv run train --library rsl_rl --task Isaac-Cartpole-Direct-v0 presets=newton_mjwarp --num_envs 4096` - Pinned the source-checkout `uv` environment to the same PyTorch family used by the Isaac Lab installer to avoid CUDA stack churn when users switch between `uv run` and `./isaaclab.sh`. - Updated docs, tests, tools, and pretrained checkpoint helpers to use the unified train/play entrypoints. - Fixed CLI Python discovery so `./isaaclab.sh` prefers an active or repo-local virtual environment before falling back to system Python. - Fixed `./isaaclab.sh --install` in uv-created virtual environments that do not include the `pip` module by using `uv pip` for venv-targeted pip operations. Motivation: The previous RL scripts duplicated substantial train/play setup logic across libraries. This made behavior harder to keep consistent and increased maintenance cost when updating shared functionality. The new structure keeps library-specific logic in each library folder while centralizing shared dispatch and common helpers. The `uv` workflow gives users a fast path from a fresh clone to kitless Newton training without manually creating an environment first. Isaac Sim / Kit workflows, including PhysX, continue to use the existing full installation path. Dependencies: No new required runtime dependencies are added to Isaac Lab packages. The root source-checkout `pyproject.toml` describes the local development environment used by `uv run`. Fixes # N/A ## Type of change - New feature (non-breaking change which adds functionality) - Documentation update ## Screenshots Not applicable. ## Validation Ran: - `uv lock` - `uv lock --check` - `uv run --frozen train --help` - `./isaaclab.sh --install` - `./isaaclab.sh -p -m pytest source/isaaclab/test/cli/test_install.py -q` - `./isaaclab.sh -p -m pytest source/isaaclab/test/cli/test_install.py::TestGetPipCommand source/isaaclab/test/cli/test_install.py::TestExtractPythonExe -q` - Help smoke tests for unified train/play entrypoints and `./isaaclab.sh -p train.py --help` / `./isaaclab.sh -p play.py --help` ## 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 - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Kelly Guo --- docs/source/setup/installation/index.rst | 1 + docs/source/setup/installation/uv_run.rst | 39 +++ pyproject.toml | 118 +++++++- scripts/reinforcement_learning/common.py | 281 ++++++++++++++++++ scripts/reinforcement_learning/play.py | 38 +++ .../reinforcement_learning/rl_games/play.py | 10 + .../rl_games/play_rl_games.py | 203 +++++++++++++ .../reinforcement_learning/rl_games/train.py | 10 + .../rl_games/train_rl_games.py | 197 ++++++++++++ scripts/reinforcement_learning/rlinf/play.py | 11 + .../rlinf/play_rlinf.py | 179 +++++++++++ scripts/reinforcement_learning/rlinf/train.py | 11 + .../rlinf/train_rlinf.py | 177 +++++++++++ scripts/reinforcement_learning/rsl_rl/play.py | 10 + .../rsl_rl/play_rsl_rl.py | 229 ++++++++++++++ .../reinforcement_learning/rsl_rl/train.py | 10 + .../rsl_rl/train_rsl_rl.py | 179 +++++++++++ scripts/reinforcement_learning/sb3/play.py | 10 + .../reinforcement_learning/sb3/play_sb3.py | 188 ++++++++++++ scripts/reinforcement_learning/sb3/train.py | 10 + .../reinforcement_learning/sb3/train_sb3.py | 176 +++++++++++ scripts/reinforcement_learning/skrl/play.py | 10 + .../reinforcement_learning/skrl/play_skrl.py | 229 ++++++++++++++ scripts/reinforcement_learning/skrl/train.py | 10 + .../reinforcement_learning/skrl/train_skrl.py | 180 +++++++++++ scripts/reinforcement_learning/train.py | 37 +++ .../changelog.d/mh-uv_train.minor.rst | 13 + source/isaaclab/isaaclab/cli/__init__.py | 33 +- source/isaaclab/isaaclab/cli/utils.py | 51 +++- source/isaaclab/setup.py | 7 + 30 files changed, 2641 insertions(+), 16 deletions(-) create mode 100644 docs/source/setup/installation/uv_run.rst create mode 100644 scripts/reinforcement_learning/common.py create mode 100644 scripts/reinforcement_learning/play.py create mode 100644 scripts/reinforcement_learning/rl_games/play_rl_games.py create mode 100644 scripts/reinforcement_learning/rl_games/train_rl_games.py create mode 100644 scripts/reinforcement_learning/rlinf/play_rlinf.py create mode 100644 scripts/reinforcement_learning/rlinf/train_rlinf.py create mode 100644 scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py create mode 100644 scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py create mode 100644 scripts/reinforcement_learning/sb3/play_sb3.py create mode 100644 scripts/reinforcement_learning/sb3/train_sb3.py create mode 100644 scripts/reinforcement_learning/skrl/play_skrl.py create mode 100644 scripts/reinforcement_learning/skrl/train_skrl.py create mode 100644 scripts/reinforcement_learning/train.py create mode 100644 source/isaaclab/changelog.d/mh-uv_train.minor.rst diff --git a/docs/source/setup/installation/index.rst b/docs/source/setup/installation/index.rst index c3c450fc530b..d681c05eadc6 100644 --- a/docs/source/setup/installation/index.rst +++ b/docs/source/setup/installation/index.rst @@ -237,3 +237,4 @@ Please follow the steps :doc:`asset_caching` to enable asset caching and speed u source_installation isaaclab_pip_installation asset_caching + uv run (experimental) diff --git a/docs/source/setup/installation/uv_run.rst b/docs/source/setup/installation/uv_run.rst new file mode 100644 index 000000000000..634296581660 --- /dev/null +++ b/docs/source/setup/installation/uv_run.rst @@ -0,0 +1,39 @@ +.. _uv-run-training: + +``uv run`` Training and Play (Experimental) +============================================ + +.. warning:: + + This feature is experimental and subject to change in future releases. + +Install ``uv`` if you do not have it already: + +.. code-block:: bash + + curl -LsSf https://astral.sh/uv/install.sh | sh + +Clone the repo and start training immediately — no virtual environment setup required: + +.. code-block:: bash + + git clone https://github.com/isaac-sim/IsaacLab.git + cd IsaacLab + + # Newton backend training without Isaac Sim + uv run train --rl_library rsl_rl \ + --task Isaac-Cartpole-Direct-v0 --headless presets=newton_mjwarp + + # Add OVRTX/OVPhysX extras only when the workflow needs them + uv run --extra ov --extra rtx train --rl_library rsl_rl \ + --task Isaac-Cartpole-Direct-v0 --headless presets=newton_mjwarp + +``uv`` resolves and manages the environment automatically on each invocation. Supported +libraries for ``--rl_library`` are: ``rsl_rl``, ``rl_games``, ``skrl``, ``sb3``, and ``rlinf``. + +Play / Evaluation +----------------- + +.. code-block:: bash + + uv run play --rl_library rsl_rl --task diff --git a/pyproject.toml b/pyproject.toml index 513ce684d93f..dd324e22ef97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,69 @@ # # SPDX-License-Identifier: BSD-3-Clause +[project] +name = "isaaclab-dev" +version = "0.1.0" +description = "Isaac Lab source checkout development environment." +requires-python = ">=3.12" +dependencies = [ + "isaaclab", + "isaaclab-assets", + "isaaclab-contrib", + "isaaclab-newton[all]", + "isaaclab-ov", + "isaaclab-ovphysx", + "isaaclab-physx[newton]", + "isaaclab-rl[rsl-rl]", + "isaaclab-tasks", + "torch==2.10.0", + "torchaudio==2.10.0", + "torchvision==0.25.0", +] + +[project.optional-dependencies] +assets = [ + "isaaclab-assets", +] +contrib = [ + "isaaclab-contrib", +] +mimic = [ + "isaaclab-mimic", +] +newton = [ + "isaaclab-newton[all]", + "isaaclab-physx[newton]", +] +ov = [ + "isaaclab-ovphysx[ovphysx]", +] +physx = [ + "isaaclab-physx", +] +rl = [ + "isaaclab-rl[rsl-rl]", +] +rl-all = [ + "isaaclab-rl[all]", +] +rtx = [ + "isaaclab-ov[ovrtx]", +] +tasks = [ + "isaaclab-assets", + "isaaclab-contrib", + "isaaclab-ov", + "isaaclab-ovphysx", + "isaaclab-tasks", +] +tasks-experimental = [ + "isaaclab-tasks-experimental", +] +visualizers = [ + "isaaclab-visualizers[all]", +] + [tool.ruff] line-length = 120 target-version = "py310" @@ -147,14 +210,61 @@ markers = [ url = "https://pypi.nvidia.com" explicit = false -# Some Isaac Sim dependencies (e.g. mujoco-usd-converter, tinyobjloader) have -# mismatched versions across pypi.nvidia.com and PyPI. unsafe-best-match lets uv -# resolve the correct version from any index, and prerelease=allow covers packages -# that only publish pre-release wheels (e.g. tinyobjloader==2.0.0rc13). +[[tool.uv.index]] +name = "pytorch-cu128" +url = "https://download.pytorch.org/whl/cu128" +explicit = true + +[[tool.uv.index]] +name = "pytorch-cu130" +url = "https://download.pytorch.org/whl/cu130" +explicit = true + +# Some NVIDIA-hosted dependencies have mismatched versions across pypi.nvidia.com +# and PyPI. unsafe-best-match lets uv resolve the correct version from any index, +# and prerelease=allow covers packages that only publish pre-release wheels. [tool.uv] index-strategy = "unsafe-best-match" prerelease = "allow" override-dependencies = ["numpy>=2"] +package = false + +[tool.uv.sources] +isaaclab = { path = "source/isaaclab", editable = true } +"isaaclab-assets" = { path = "source/isaaclab_assets", editable = true } +"isaaclab-contrib" = { path = "source/isaaclab_contrib", editable = true } +"isaaclab-experimental" = { path = "source/isaaclab_experimental", editable = true } +"isaaclab-mimic" = { path = "source/isaaclab_mimic", editable = true } +"isaaclab-newton" = { path = "source/isaaclab_newton", editable = true } +"isaaclab-ov" = { path = "source/isaaclab_ov", editable = true } +"isaaclab-ovphysx" = { path = "source/isaaclab_ovphysx", editable = true } +"isaaclab-physx" = { path = "source/isaaclab_physx", editable = true } +"isaaclab-rl" = { path = "source/isaaclab_rl", editable = true } +"isaaclab-tasks" = { path = "source/isaaclab_tasks", editable = true } +"isaaclab-tasks-experimental" = { path = "source/isaaclab_tasks_experimental", editable = true } +"isaaclab-teleop" = { path = "source/isaaclab_teleop", editable = true } +"isaaclab-visualizers" = { path = "source/isaaclab_visualizers", editable = true } +torch = [ + { index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" }, + { index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'AMD64'" }, + { index = "pytorch-cu128", marker = "sys_platform == 'win32'" }, + { index = "pytorch-cu130", marker = "sys_platform == 'linux' and platform_machine == 'aarch64'" }, + { index = "pytorch-cu130", marker = "sys_platform == 'linux' and platform_machine == 'arm64'" }, +] +torchaudio = [ + { index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" }, + { index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'AMD64'" }, + { index = "pytorch-cu128", marker = "sys_platform == 'win32'" }, + { index = "pytorch-cu130", marker = "sys_platform == 'linux' and platform_machine == 'aarch64'" }, + { index = "pytorch-cu130", marker = "sys_platform == 'linux' and platform_machine == 'arm64'" }, +] +torchvision = [ + { index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" }, + { index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'AMD64'" }, + { index = "pytorch-cu128", marker = "sys_platform == 'win32'" }, + { index = "pytorch-cu130", marker = "sys_platform == 'linux' and platform_machine == 'aarch64'" }, + { index = "pytorch-cu130", marker = "sys_platform == 'linux' and platform_machine == 'arm64'" }, +] [tool.uv.pip] index-strategy = "unsafe-best-match" diff --git a/scripts/reinforcement_learning/common.py b/scripts/reinforcement_learning/common.py new file mode 100644 index 000000000000..94eb22a9d774 --- /dev/null +++ b/scripts/reinforcement_learning/common.py @@ -0,0 +1,281 @@ +# 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 + +"""Common utilities for reinforcement learning entrypoints.""" + +from __future__ import annotations + +import argparse +import importlib.util +import logging +import os +import runpy +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import gymnasium as gym + +from isaaclab.envs import DirectMARLEnvCfg, ManagerBasedRLEnvCfg +from isaaclab.utils.dict import print_dict +from isaaclab.utils.io import dump_yaml + +from isaaclab_tasks.utils import add_launcher_args + + +def dispatch_library_entrypoint( + argv: list[str] | None, + entrypoints: dict[str, Path], + *, + action: str, + description: str, + library_help: str, + run_as_script: bool = False, +) -> int: + """Dispatch a unified entrypoint to a library-specific implementation. + + Args: + argv: Command-line arguments, excluding the script path. + entrypoints: Mapping from library name to implementation path. + action: Action name used to create a unique module name. + description: Top-level parser description. + library_help: Help text for the ``--rl_library`` argument. + run_as_script: Whether to execute the selected implementation as a script. + + Returns: + Process exit code. + """ + if argv is None: + argv = sys.argv[1:] + + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--rl_library", choices=sorted(entrypoints), required=True) + args_cli, library_args = parser.parse_known_args(argv) + + if args_cli.rl_library is None: + help_parser = argparse.ArgumentParser(description=description) + help_parser.add_argument("--rl_library", choices=sorted(entrypoints), required=True, help=library_help) + help_parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments forwarded to the selected library.") + help_parser.print_help() + return 0 if "-h" in argv or "--help" in argv else 2 + + module_path = entrypoints[args_cli.rl_library] + if run_as_script: + original_argv = sys.argv + original_path = list(sys.path) + try: + sys.argv = [str(module_path)] + library_args + sys.path.insert(0, str(module_path.parent)) + runpy.run_path(str(module_path), run_name="__main__") + finally: + sys.argv = original_argv + sys.path[:] = original_path + return 0 + + module = import_local_module(f"isaaclab_rl_{action}_{args_cli.rl_library}", module_path) + module.run(library_args) + return 0 + + +def add_common_train_args( + parser: argparse.ArgumentParser, + *, + agent_default: str | None, + agent_help: str, + include_agent: bool = True, + include_distributed: bool = True, +) -> None: + """Add common Isaac Lab reinforcement learning training arguments. + + Args: + parser: The parser to add arguments to. + agent_default: Default agent config entry point. + agent_help: Help text for the ``--agent`` argument. + include_agent: Whether to include the ``--agent`` argument. + include_distributed: Whether to include the ``--distributed`` argument. + """ + parser.add_argument("--video", action="store_true", default=False, help="Record videos during training.") + parser.add_argument("--video_length", type=int, default=200, help="Length of the recorded video (in steps).") + parser.add_argument( + "--video_interval", type=int, default=2000, help="Interval between video recordings (in steps)." + ) + parser.add_argument("--num_envs", type=int, default=None, help="Number of environments to simulate.") + parser.add_argument("--task", type=str, default=None, help="Name of the task.") + if include_agent: + parser.add_argument("--agent", type=str, default=agent_default, help=agent_help) + parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") + if include_distributed: + parser.add_argument( + "--distributed", action="store_true", default=False, help="Run training with multiple GPUs or nodes." + ) + parser.add_argument("--max_iterations", type=int, default=None, help="RL Policy training iterations.") + parser.add_argument("--export_io_descriptors", action="store_true", default=False, help="Export IO descriptors.") + parser.add_argument( + "--ray-proc-id", + "-rid", + type=int, + default=None, + help="Automatically configured by Ray integration, otherwise None.", + ) + + +def add_isaaclab_launcher_args(parser: argparse.ArgumentParser) -> None: + """Add Isaac Lab simulation launcher arguments to a parser. + + Args: + parser: The parser to add arguments to. + """ + add_launcher_args(parser) + + +def enable_cameras_for_video(args_cli: argparse.Namespace) -> None: + """Enable camera rendering when video recording is requested. + + Args: + args_cli: Parsed command-line arguments. + """ + if getattr(args_cli, "video", False): + args_cli.enable_cameras = True + + +def set_hydra_args(hydra_args: list[str]) -> None: + """Replace ``sys.argv`` with arguments intended for Hydra. + + Args: + hydra_args: Remaining command-line arguments not consumed by argparse. + """ + sys.argv = [sys.argv[0]] + hydra_args + + +def import_local_module(module_name: str, module_path: Path) -> ModuleType: + """Import a module from an explicit file path. + + Args: + module_name: Unique module name to use in ``sys.modules``. + module_path: Path to the Python file to import. + + Returns: + The imported module. + """ + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load module {module_name!r} from {module_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def apply_env_overrides(args_cli: argparse.Namespace, env_cfg: Any, *, apply_device: bool = True) -> None: + """Apply common environment overrides from command-line arguments. + + Args: + args_cli: Parsed command-line arguments. + env_cfg: Isaac Lab environment config. + apply_device: Whether to apply the ``--device`` override for non-distributed runs. + """ + if getattr(args_cli, "num_envs", None) is not None: + env_cfg.scene.num_envs = args_cli.num_envs + + if apply_device and not getattr(args_cli, "distributed", False): + device = getattr(args_cli, "device", None) + env_cfg.sim.device = device if device is not None else env_cfg.sim.device + + +def validate_distributed_device(args_cli: argparse.Namespace) -> None: + """Reject unsupported CPU distributed training configuration. + + Args: + args_cli: Parsed command-line arguments. + + Raises: + ValueError: If distributed training is requested with a CPU device. + """ + device = getattr(args_cli, "device", None) + if getattr(args_cli, "distributed", False) and device is not None and "cpu" in device: + raise ValueError( + "Distributed training is not supported when using CPU device. " + "Please use GPU device (e.g., --device cuda) for distributed training." + ) + + +def configure_io_descriptors(env_cfg: Any, args_cli: argparse.Namespace, logger: logging.Logger) -> None: + """Configure IO descriptor export on supported environment configs. + + Args: + env_cfg: Isaac Lab environment config. + args_cli: Parsed command-line arguments. + logger: Logger used for unsupported environment warnings. + """ + if isinstance(env_cfg, ManagerBasedRLEnvCfg): + env_cfg.export_io_descriptors = args_cli.export_io_descriptors + else: + logger.warning( + "IO descriptors are only supported for manager based RL environments. No IO descriptors will be exported." + ) + + +def create_isaaclab_env( + task: str, + env_cfg: Any, + args_cli: argparse.Namespace, + *, + convert_marl_to_single_agent: bool, +): + """Create the Isaac Lab Gymnasium environment. + + Args: + task: Task name to instantiate. + env_cfg: Isaac Lab environment config. + args_cli: Parsed command-line arguments. + convert_marl_to_single_agent: Whether to convert direct MARL environments to single-agent environments. + + Returns: + The created Gymnasium environment. + """ + env = gym.make(task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None) + if convert_marl_to_single_agent and isinstance(env.unwrapped.cfg, DirectMARLEnvCfg): + from isaaclab.envs import multi_agent_to_single_agent + + env = multi_agent_to_single_agent(env) + return env + + +def wrap_record_video(env, log_dir: str, args_cli: argparse.Namespace): + """Wrap an environment with video recording when requested. + + Args: + env: Gymnasium environment to wrap. + log_dir: Training log directory. + args_cli: Parsed command-line arguments. + + Returns: + The original or video-wrapped environment. + """ + if not args_cli.video: + return env + + video_kwargs = { + "video_folder": os.path.join(log_dir, "videos", "train"), + "step_trigger": lambda step: step % args_cli.video_interval == 0, + "video_length": args_cli.video_length, + "disable_logger": True, + } + print("[INFO] Recording videos during training.") + print_dict(video_kwargs, nesting=4) + return gym.wrappers.RecordVideo(env, **video_kwargs) + + +def dump_train_configs(log_dir: str, env_cfg: Any, agent_cfg: Any) -> None: + """Dump training configuration files under a run log directory. + + Args: + log_dir: Training log directory. + env_cfg: Isaac Lab environment config. + agent_cfg: Reinforcement learning agent config. + """ + dump_yaml(os.path.join(log_dir, "params", "env.yaml"), env_cfg) + dump_yaml(os.path.join(log_dir, "params", "agent.yaml"), agent_cfg) diff --git a/scripts/reinforcement_learning/play.py b/scripts/reinforcement_learning/play.py new file mode 100644 index 000000000000..1a12ff61d9dc --- /dev/null +++ b/scripts/reinforcement_learning/play.py @@ -0,0 +1,38 @@ +# 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 + +"""Unified play entrypoint for Isaac Lab reinforcement learning workflows.""" + +from __future__ import annotations + +from pathlib import Path + +from common import dispatch_library_entrypoint + +SCRIPT_DIR = Path(__file__).resolve().parent + +LIBRARY_ENTRYPOINTS = { + "rl_games": SCRIPT_DIR / "rl_games" / "play_rl_games.py", + "rlinf": SCRIPT_DIR / "rlinf" / "play_rlinf.py", + "rsl_rl": SCRIPT_DIR / "rsl_rl" / "play_rsl_rl.py", + "sb3": SCRIPT_DIR / "sb3" / "play_sb3.py", + "skrl": SCRIPT_DIR / "skrl" / "play_skrl.py", +} + + +def main(argv: list[str] | None = None) -> int: + """Run the selected reinforcement learning play library.""" + return dispatch_library_entrypoint( + argv, + LIBRARY_ENTRYPOINTS, + action="play", + description="Play an RL agent with a selected reinforcement learning library.", + library_help="Training library used by the checkpoint.", + run_as_script=True, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/reinforcement_learning/rl_games/play.py b/scripts/reinforcement_learning/rl_games/play.py index dd61d80da530..ec67df745f99 100644 --- a/scripts/reinforcement_learning/rl_games/play.py +++ b/scripts/reinforcement_learning/rl_games/play.py @@ -5,6 +5,16 @@ """Script to play a checkpoint if an RL agent from RL-Games.""" +import warnings + +warnings.warn( + "scripts/reinforcement_learning/rl_games/play.py is deprecated. Use " + "`./isaaclab.sh play --rl_library rl_games --task ` instead. " + "Example: `./isaaclab.sh play --rl_library rl_games --task Isaac-Cartpole-v0`.", + DeprecationWarning, + stacklevel=1, +) + import argparse import contextlib import math diff --git a/scripts/reinforcement_learning/rl_games/play_rl_games.py b/scripts/reinforcement_learning/rl_games/play_rl_games.py new file mode 100644 index 000000000000..c1b42d4dca0e --- /dev/null +++ b/scripts/reinforcement_learning/rl_games/play_rl_games.py @@ -0,0 +1,203 @@ +# 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 + +"""Script to play a checkpoint of an RL agent from RL-Games.""" + +import argparse +import contextlib +import math +import os +import random +import sys +import time + +import gymnasium as gym +import torch +from rl_games.common import env_configurations, vecenv +from rl_games.common.player import BasePlayer +from rl_games.torch_runner import Runner + +from isaaclab.envs import DirectMARLEnvCfg +from isaaclab.utils.assets import retrieve_file_path +from isaaclab.utils.dict import print_dict + +from isaaclab_rl.rl_games import RlGamesGpuEnv, RlGamesVecEnvWrapper +from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.utils import add_launcher_args, get_checkpoint_path, launch_simulation, resolve_task_config + +# PLACEHOLDER: Extension template (do not remove this comment) +with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + +# -- argparse ---------------------------------------------------------------- +parser = argparse.ArgumentParser(description="Play a checkpoint of an RL agent from RL-Games.") +parser.add_argument("--video", action="store_true", default=False, help="Record videos during play.") +parser.add_argument("--video_length", type=int, default=200, help="Length of the recorded video (in steps).") +parser.add_argument( + "--disable_fabric", action="store_true", default=False, help="Disable fabric and use USD I/O operations." +) +parser.add_argument("--num_envs", type=int, default=None, help="Number of environments to simulate.") +parser.add_argument("--task", type=str, default=None, help="Name of the task.") +parser.add_argument( + "--agent", type=str, default="rl_games_cfg_entry_point", help="Name of the RL agent configuration entry point." +) +parser.add_argument("--checkpoint", type=str, default=None, help="Path to model checkpoint.") +parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") +parser.add_argument( + "--use_pretrained_checkpoint", + action="store_true", + help="Use the pre-trained checkpoint from Nucleus.", +) +parser.add_argument( + "--use_last_checkpoint", + action="store_true", + help="When no checkpoint provided, use the last saved model. Otherwise use the best saved model.", +) +parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") +add_launcher_args(parser) +args_cli, hydra_args = parser.parse_known_args() + +if args_cli.video: + args_cli.enable_cameras = True + +sys.argv = [sys.argv[0]] + hydra_args + + +def main(): + """Play with RL-Games agent.""" + env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) + with launch_simulation(env_cfg, args_cli): + # grab task name for checkpoint path + task_name = args_cli.task.split(":")[-1] + train_task_name = task_name.replace("-Play", "") + + # override configurations with non-hydra CLI arguments + env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + + # randomly sample a seed if seed = -1 + if args_cli.seed == -1: + args_cli.seed = random.randint(0, 10000) + + agent_cfg["params"]["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg["params"]["seed"] + env_cfg.seed = agent_cfg["params"]["seed"] + + # specify directory for logging experiments + log_root_path = os.path.join("logs", "rl_games", agent_cfg["params"]["config"]["name"]) + log_root_path = os.path.abspath(log_root_path) + print(f"[INFO] Loading experiment from directory: {log_root_path}") + # find checkpoint + if args_cli.use_pretrained_checkpoint: + resume_path = get_published_pretrained_checkpoint("rl_games", train_task_name) + if not resume_path: + print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") + return + elif args_cli.checkpoint is None: + run_dir = agent_cfg["params"]["config"].get("full_experiment_name", ".*") + if args_cli.use_last_checkpoint: + checkpoint_file = ".*" + else: + checkpoint_file = f"{agent_cfg['params']['config']['name']}.pth" + resume_path = get_checkpoint_path(log_root_path, run_dir, checkpoint_file, other_dirs=["nn"]) + else: + resume_path = retrieve_file_path(args_cli.checkpoint) + log_dir = os.path.dirname(os.path.dirname(resume_path)) + + # set the log directory for the environment + env_cfg.log_dir = log_dir + + # wrap around environment for rl-games + rl_device = agent_cfg["params"]["config"]["device"] + clip_obs = agent_cfg["params"]["env"].get("clip_observations", math.inf) + clip_actions = agent_cfg["params"]["env"].get("clip_actions", math.inf) + obs_groups = agent_cfg["params"]["env"].get("obs_groups") + concate_obs_groups = agent_cfg["params"]["env"].get("concate_obs_groups", True) + + # create isaac environment + env = gym.make(args_cli.task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None) + + # convert to single-agent instance if required by the RL algorithm + if isinstance(env.unwrapped.cfg, DirectMARLEnvCfg): + from isaaclab.envs import multi_agent_to_single_agent + + env = multi_agent_to_single_agent(env) + + # wrap for video recording + if args_cli.video: + video_kwargs = { + "video_folder": os.path.join(log_root_path, log_dir, "videos", "play"), + "step_trigger": lambda step: step == 0, + "video_length": args_cli.video_length, + "disable_logger": True, + } + print("[INFO] Recording videos during play.") + print_dict(video_kwargs, nesting=4) + env = gym.wrappers.RecordVideo(env, **video_kwargs) + + # wrap around environment for rl-games + env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions, obs_groups, concate_obs_groups) + + # register the environment to rl-games registry + vecenv.register( + "IsaacRlgWrapper", + lambda config_name, num_actors, **kwargs: RlGamesGpuEnv(config_name, num_actors, **kwargs), + ) + env_configurations.register("rlgpu", {"vecenv_type": "IsaacRlgWrapper", "env_creator": lambda **kwargs: env}) + + # load previously trained model + agent_cfg["params"]["load_checkpoint"] = True + agent_cfg["params"]["load_path"] = resume_path + print(f"[INFO]: Loading model checkpoint from: {agent_cfg['params']['load_path']}") + + # set number of actors into agent config + agent_cfg["params"]["config"]["num_actors"] = env.unwrapped.num_envs + runner = Runner() + runner.load(agent_cfg) + agent: BasePlayer = runner.create_player() + agent.restore(resume_path) + agent.reset() + + dt = env.unwrapped.step_dt + + # reset environment + obs = env.reset() + if isinstance(obs, dict): + obs = obs["obs"] + timestep = 0 + _ = agent.get_batch_size(obs, 1) + if agent.is_rnn: + agent.init_rnn() + # simulate environment + try: + while True: + start_time = time.time() + with torch.inference_mode(): + obs = agent.obs_to_torch(obs) + actions = agent.get_action(obs, is_deterministic=agent.is_deterministic) + obs, _, dones, _ = env.step(actions) + + if len(dones) > 0: + if agent.is_rnn and agent.states is not None: + for s in agent.states: + s[:, dones, :] = 0.0 + if args_cli.video: + timestep += 1 + if timestep == args_cli.video_length: + break + + sleep_time = dt - (time.time() - start_time) + if args_cli.real_time and sleep_time > 0: + time.sleep(sleep_time) + + # close the simulator + env.close() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/scripts/reinforcement_learning/rl_games/train.py b/scripts/reinforcement_learning/rl_games/train.py index 697ca06660a3..983f967e3061 100644 --- a/scripts/reinforcement_learning/rl_games/train.py +++ b/scripts/reinforcement_learning/rl_games/train.py @@ -5,6 +5,16 @@ """Script to train RL agent with RL-Games.""" +import warnings + +warnings.warn( + "scripts/reinforcement_learning/rl_games/train.py is deprecated. Use " + "`./isaaclab.sh train --rl_library rl_games --task ` instead. " + "Example: `./isaaclab.sh train --rl_library rl_games --task Isaac-Cartpole-v0`.", + DeprecationWarning, + stacklevel=1, +) + import argparse import contextlib import logging diff --git a/scripts/reinforcement_learning/rl_games/train_rl_games.py b/scripts/reinforcement_learning/rl_games/train_rl_games.py new file mode 100644 index 000000000000..0cf210c27b2e --- /dev/null +++ b/scripts/reinforcement_learning/rl_games/train_rl_games.py @@ -0,0 +1,197 @@ +# 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 + +"""RL-Games training logic for the unified reinforcement learning entrypoint.""" + +from __future__ import annotations + +import argparse +import contextlib +import logging +import math +import os +import random +import time +from datetime import datetime +from distutils.util import strtobool + +from common import ( + add_common_train_args, + add_isaaclab_launcher_args, + apply_env_overrides, + configure_io_descriptors, + create_isaaclab_env, + dump_train_configs, + enable_cameras_for_video, + set_hydra_args, + validate_distributed_device, + wrap_record_video, +) + +import isaaclab_tasks # noqa: F401 + +logger = logging.getLogger(__name__) + +# PLACEHOLDER: Extension template (do not remove this comment) +with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + """Parse RL-Games training arguments.""" + parser = argparse.ArgumentParser(description="Train an RL agent with RL-Games.") + add_common_train_args( + parser, + agent_default="rl_games_cfg_entry_point", + agent_help="Name of the RL agent configuration entry point.", + ) + parser.add_argument("--checkpoint", type=str, default=None, help="Path to model checkpoint.") + parser.add_argument("--sigma", type=str, default=None, help="The policy's initial standard deviation.") + parser.add_argument("--wandb-project-name", type=str, default=None, help="the wandb's project name") + parser.add_argument("--wandb-entity", type=str, default=None, help="the entity (team) of wandb's project") + parser.add_argument("--wandb-name", type=str, default=None, help="the name of wandb's run") + parser.add_argument( + "--track", + type=lambda x: bool(strtobool(x)), + default=False, + nargs="?", + const=True, + help="if toggled, this experiment will be tracked with Weights and Biases", + ) + add_isaaclab_launcher_args(parser) + args_cli, hydra_args = parser.parse_known_args(argv) + enable_cameras_for_video(args_cli) + set_hydra_args(hydra_args) + return args_cli + + +def run(argv: list[str]) -> None: + """Train an RL-Games agent.""" + from rl_games.common import env_configurations, vecenv + from rl_games.common.algo_observer import IsaacAlgoObserver + from rl_games.torch_runner import Runner + + from isaaclab.envs import DirectMARLEnvCfg + from isaaclab.utils.assets import retrieve_file_path + + from isaaclab_rl.rl_games import MultiObserver, PbtAlgoObserver, RlGamesGpuEnv, RlGamesVecEnvWrapper + + from isaaclab_tasks.utils import launch_simulation, resolve_task_config + + args_cli = _parse_args(argv) + env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) + + with launch_simulation(env_cfg, args_cli): + apply_env_overrides(args_cli, env_cfg) + validate_distributed_device(args_cli) + + if args_cli.seed == -1: + args_cli.seed = random.randint(0, 10000) + + agent_cfg["params"]["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg["params"]["seed"] + agent_cfg["params"]["config"]["max_epochs"] = ( + args_cli.max_iterations + if args_cli.max_iterations is not None + else agent_cfg["params"]["config"]["max_epochs"] + ) + if args_cli.checkpoint is not None: + resume_path = retrieve_file_path(args_cli.checkpoint) + agent_cfg["params"]["load_checkpoint"] = True + agent_cfg["params"]["load_path"] = resume_path + print(f"[INFO]: Loading model checkpoint from: {agent_cfg['params']['load_path']}") + train_sigma = float(args_cli.sigma) if args_cli.sigma is not None else None + + if args_cli.distributed: + agent_cfg["params"]["seed"] += int(os.getenv("RANK", "0")) + agent_cfg["params"]["config"]["device"] = env_cfg.sim.device + agent_cfg["params"]["config"]["device_name"] = env_cfg.sim.device + agent_cfg["params"]["config"]["multi_gpu"] = True + + env_cfg.seed = agent_cfg["params"]["seed"] + + config_name = agent_cfg["params"]["config"]["name"] + log_root_path = os.path.join("logs", "rl_games", config_name) + if "pbt" in agent_cfg and agent_cfg["pbt"]["directory"] != ".": + log_root_path = os.path.join(agent_cfg["pbt"]["directory"], log_root_path) + else: + log_root_path = os.path.abspath(log_root_path) + + print(f"[INFO] Logging experiment in directory: {log_root_path}") + log_dir = agent_cfg["params"]["config"].get( + "full_experiment_name", datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ) + agent_cfg["params"]["config"]["train_dir"] = log_root_path + agent_cfg["params"]["config"]["full_experiment_name"] = log_dir + wandb_project = config_name if args_cli.wandb_project_name is None else args_cli.wandb_project_name + experiment_name = log_dir if args_cli.wandb_name is None else args_cli.wandb_name + + run_log_dir = os.path.join(log_root_path, log_dir) + dump_train_configs(run_log_dir, env_cfg, agent_cfg) + print(f"Exact experiment name requested from command line: {run_log_dir}") + + rl_device = agent_cfg["params"]["config"]["device"] + clip_obs = agent_cfg["params"]["env"].get("clip_observations", math.inf) + clip_actions = agent_cfg["params"]["env"].get("clip_actions", math.inf) + obs_groups = agent_cfg["params"]["env"].get("obs_groups") + concate_obs_groups = agent_cfg["params"]["env"].get("concate_obs_groups", True) + + configure_io_descriptors(env_cfg, args_cli, logger) + env_cfg.log_dir = run_log_dir + + env = create_isaaclab_env( + args_cli.task, + env_cfg, + args_cli, + convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), + ) + env = wrap_record_video(env, run_log_dir, args_cli) + + start_time = time.time() + env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions, obs_groups, concate_obs_groups) + + vecenv.register( + "IsaacRlgWrapper", + lambda config_name, num_actors, **kwargs: RlGamesGpuEnv(config_name, num_actors, **kwargs), + ) + env_configurations.register("rlgpu", {"vecenv_type": "IsaacRlgWrapper", "env_creator": lambda **kwargs: env}) + + agent_cfg["params"]["config"]["num_actors"] = env.unwrapped.num_envs + + if "pbt" in agent_cfg and agent_cfg["pbt"]["enabled"]: + observers = MultiObserver([IsaacAlgoObserver(), PbtAlgoObserver(agent_cfg, args_cli)]) + runner = Runner(observers) + else: + runner = Runner(IsaacAlgoObserver()) + + runner.load(agent_cfg) + runner.reset() + + global_rank = int(os.getenv("RANK", "0")) + if args_cli.track and global_rank == 0: + if args_cli.wandb_entity is None: + raise ValueError("Weights and Biases entity must be specified for tracking.") + import wandb + + wandb.init( + project=wandb_project, + entity=args_cli.wandb_entity, + name=experiment_name, + sync_tensorboard=True, + monitor_gym=True, + save_code=True, + ) + if not wandb.run.resumed: + wandb.config.update({"env_cfg": env_cfg.to_dict()}) + wandb.config.update({"agent_cfg": agent_cfg}) + + try: + if args_cli.checkpoint is not None: + runner.run({"train": True, "play": False, "sigma": train_sigma, "checkpoint": resume_path}) + else: + runner.run({"train": True, "play": False, "sigma": train_sigma}) + print(f"Training time: {round(time.time() - start_time, 2)} seconds") + env.close() + except KeyboardInterrupt: + pass diff --git a/scripts/reinforcement_learning/rlinf/play.py b/scripts/reinforcement_learning/rlinf/play.py index f63e02d3e1f2..3a57bfba1cf8 100644 --- a/scripts/reinforcement_learning/rlinf/play.py +++ b/scripts/reinforcement_learning/rlinf/play.py @@ -26,6 +26,17 @@ are too large to run on a single GPU without FSDP. """ +import warnings + +warnings.warn( + "scripts/reinforcement_learning/rlinf/play.py is deprecated. Use " + "`./isaaclab.sh play --rl_library rlinf --config_name ` instead. " + "Example: `./isaaclab.sh play --rl_library rlinf " + "--config_name isaaclab_ppo_gr00t_assemble_trocar`.", + DeprecationWarning, + stacklevel=1, +) + import argparse import logging import os diff --git a/scripts/reinforcement_learning/rlinf/play_rlinf.py b/scripts/reinforcement_learning/rlinf/play_rlinf.py new file mode 100644 index 000000000000..765902711e53 --- /dev/null +++ b/scripts/reinforcement_learning/rlinf/play_rlinf.py @@ -0,0 +1,179 @@ +# 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 + +"""Script to evaluate a trained RLinf agent. + +This script runs evaluation using RLinf's distributed infrastructure, +which is required for VLA model inference. + +Usage: + # Evaluate a trained checkpoint (config YAML in the same directory as play.py) + ./isaaclab.sh play --rl_library rlinf --config_name isaaclab_ppo_gr00t_assemble_trocar \\ + --model_path /path/to/checkpoint + + # Evaluate with config YAML in a custom directory + ./isaaclab.sh play --rl_library rlinf --config_path /path/to/config/dir \\ + --config_name isaaclab_ppo_gr00t_assemble_trocar --model_path /path/to/checkpoint + + # Evaluate with video recording + ./isaaclab.sh play --rl_library rlinf --config_name isaaclab_ppo_gr00t_assemble_trocar \\ + --model_path /path/to/checkpoint --video + +Note: + Evaluation requires the full RLinf infrastructure since VLA models + are too large to run on a single GPU without FSDP. +""" + +import argparse +import logging +import os +from datetime import datetime +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent.absolute() +# required for RLinf to register IsaacLab tasks and converters +os.environ.setdefault("RLINF_EXT_MODULE", "isaaclab_contrib.rl.rlinf.extension") + +# local imports +import cli_args # noqa: E402 # isort: skip + +# add argparse arguments +parser = argparse.ArgumentParser(description="Evaluate a trained RLinf agent.") +parser.add_argument("--num_envs", type=int, default=None, help="Number of environments to simulate.") +parser.add_argument("--task", type=str, default=None, help="Name of the task (overrides YAML config if set).") +parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment (overrides config if set)") +parser.add_argument( + "--model_path", type=str, default=None, help="Path to the model checkpoint (optional, can be set in config)." +) +parser.add_argument( + "--num_episodes", type=int, default=None, help="Number of evaluation episodes (overrides config if set)." +) +parser.add_argument("--video", action="store_true", default=False, help="Enable video recording.") +cli_args.add_rlinf_args(parser) +args_cli = parser.parse_args() + +# Resolve config path and name from CLI args +if not args_cli.config_name: + parser.error("--config_name is required (e.g. --config_name isaaclab_ppo_gr00t_assemble_trocar)") +config_dir = args_cli.config_path or str(SCRIPT_DIR) +config_name = args_cli.config_name +os.environ["RLINF_CONFIG_FILE"] = str(Path(config_dir) / f"{config_name}.yaml") + +# Add config dir to PYTHONPATH so that Ray rollout workers can resolve +# data_config_class references like "gr00t_config:IsaacLabDataConfig" +if config_dir not in os.environ.get("PYTHONPATH", ""): + os.environ["PYTHONPATH"] = config_dir + os.pathsep + os.environ.get("PYTHONPATH", "") + + +"""launch RLinf evaluation.""" +import rlinf # noqa: F401 +import torch.multiprocessing as mp # noqa: E402 +from hydra import compose, initialize_config_dir # noqa: E402 +from hydra.core.global_hydra import GlobalHydra # noqa: E402 +from omegaconf import open_dict # noqa: E402 +from rlinf.config import validate_cfg # noqa: E402 +from rlinf.runners.embodied_eval_runner import EmbodiedEvalRunner # noqa: E402 +from rlinf.scheduler import Cluster # noqa: E402 +from rlinf.utils.placement import HybridComponentPlacement # noqa: E402 +from rlinf.workers.env.env_worker import EnvWorker # noqa: E402 +from rlinf.workers.rollout.hf.huggingface_worker import MultiStepRolloutWorker # noqa: E402 + +logger = logging.getLogger(__name__) + +mp.set_start_method("spawn", force=True) + + +def main(): + """Launch RLinf evaluation.""" + print(f"[INFO] Using config: {config_name}") + print(f"[INFO] Config path: {config_dir}") + + # Initialize Hydra and load config + GlobalHydra.instance().clear() + initialize_config_dir(config_dir=config_dir, version_base="1.1") + cfg = compose(config_name=config_name) + + # Get task_id from config (eval task) + task_id = cfg.env.eval.init_params.id + print(f"[INFO] Task: {task_id}") + + # Setup logging directory + timestamp = datetime.now().strftime("%Y%m%d-%H:%M:%S") + log_dir = SCRIPT_DIR / "logs" / "rlinf" / "eval" / f"{timestamp}-{task_id.replace('/', '_')}" + log_dir.mkdir(parents=True, exist_ok=True) + print(f"[INFO] Logging to: {log_dir}") + + # Apply runtime overrides + with open_dict(cfg): + # Set evaluation mode + cfg.runner.only_eval = True + # Set logging + cfg.runner.logger.log_path = str(log_dir) + + # Override checkpoint if provided via CLI + if args_cli.model_path: + cfg.rollout.model.model_path = args_cli.model_path + + # Enable video saving if requested + if args_cli.video: + cfg.env.eval.video_cfg.save_video = True + cfg.env.eval.video_cfg.video_base_dir = str(log_dir / "videos") + + # Override task if provided via CLI + if args_cli.task: + cfg.env.eval.init_params.id = args_cli.task + cfg.env.train.init_params.id = args_cli.task + + # Apply CLI args + if args_cli.num_envs is not None: + cfg.env.eval.total_num_envs = args_cli.num_envs + if args_cli.seed is not None: + cfg.actor.seed = args_cli.seed + if args_cli.num_episodes is not None: + cfg.algorithm.eval_rollout_epoch = args_cli.num_episodes + + # Validate config + cfg = validate_cfg(cfg) + + # Print config summary + print("\n" + "=" * 60) + print("RLinf Evaluation Configuration") + print("=" * 60) + print(f" Task: {cfg.env.eval.init_params.id}") + print(f" Num envs: {cfg.env.eval.total_num_envs}") + print(f" Model: {cfg.rollout.model.model_path}") + print(f" Videos: {cfg.env.eval.video_cfg.save_video}") + if cfg.env.eval.video_cfg.save_video: + print(f" Video dir: {cfg.env.eval.video_cfg.video_base_dir}") + print(f" Log dir: {log_dir}") + print("=" * 60 + "\n") + + # Create cluster and workers + cluster = Cluster(cluster_cfg=cfg.cluster) + component_placement = HybridComponentPlacement(cfg, cluster) + + # Create rollout worker + rollout_placement = component_placement.get_strategy("rollout") + rollout_group = MultiStepRolloutWorker.create_group(cfg).launch( + cluster, name=cfg.rollout.group_name, placement_strategy=rollout_placement + ) + + # Create env worker + env_placement = component_placement.get_strategy("env") + env_group = EnvWorker.create_group(cfg).launch(cluster, name=cfg.env.group_name, placement_strategy=env_placement) + + # Run evaluation + runner = EmbodiedEvalRunner( + cfg=cfg, + rollout=rollout_group, + env=env_group, + ) + + runner.init_workers() + runner.run() + + +if __name__ == "__main__": + main() diff --git a/scripts/reinforcement_learning/rlinf/train.py b/scripts/reinforcement_learning/rlinf/train.py index e0e79ab2a89d..fb56244c747b 100644 --- a/scripts/reinforcement_learning/rlinf/train.py +++ b/scripts/reinforcement_learning/rlinf/train.py @@ -27,6 +27,17 @@ The model_path should point to a HuggingFace format checkpoint directory. """ +import warnings + +warnings.warn( + "scripts/reinforcement_learning/rlinf/train.py is deprecated. Use " + "`./isaaclab.sh train --rl_library rlinf --config_name ` instead. " + "Example: `./isaaclab.sh train --rl_library rlinf " + "--config_name isaaclab_ppo_gr00t_assemble_trocar`.", + DeprecationWarning, + stacklevel=1, +) + import argparse import logging import os diff --git a/scripts/reinforcement_learning/rlinf/train_rlinf.py b/scripts/reinforcement_learning/rlinf/train_rlinf.py new file mode 100644 index 000000000000..973765cb1bba --- /dev/null +++ b/scripts/reinforcement_learning/rlinf/train_rlinf.py @@ -0,0 +1,177 @@ +# 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 + +"""RLinf training logic for the unified reinforcement learning entrypoint.""" + +from __future__ import annotations + +import argparse +import logging +import os +from datetime import datetime +from pathlib import Path + +from common import import_local_module + +logger = logging.getLogger(__name__) + +RL_ROOT = Path(__file__).resolve().parents[1] +RLINF_DIR = RL_ROOT / "rlinf" +CLI_ARGS = import_local_module("isaaclab_rlinf_cli_args", RLINF_DIR / "cli_args.py") + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + """Parse RLinf training arguments.""" + parser = argparse.ArgumentParser(description="Train an RL agent with RLinf.") + parser.add_argument("--num_envs", type=int, default=None, help="Number of environments to simulate.") + parser.add_argument("--task", type=str, default=None, help="Name of the task.") + parser.add_argument( + "--seed", + type=int, + default=None, + help="Seed used for the environment (overrides config if set)", + ) + parser.add_argument("--max_epochs", type=int, default=None, help="RL Policy training iterations.") + parser.add_argument("--list_tasks", action="store_true", default=False, help="List all available tasks and exit.") + parser.add_argument("--model_path", type=str, default=None, help="Path to pretrained model checkpoint (required).") + CLI_ARGS.add_rlinf_args(parser) + args_cli = parser.parse_args(argv) + if not args_cli.list_tasks and not args_cli.config_name: + parser.error("--config_name is required (e.g. --config_name isaaclab_ppo_gr00t_assemble_trocar)") + return args_cli + + +def _list_tasks() -> None: + """List available RLinf tasks.""" + print("\n" + "=" * 60) + print("Available RLinf Tasks") + print("=" * 60) + + print("\n[RLinf Registered Tasks]") + try: + from rlinf.envs.isaaclab import REGISTER_ISAACLAB_ENVS + + for task_id in sorted(REGISTER_ISAACLAB_ENVS.keys()): + print(f" - {task_id}") + except ImportError: + print(" (Could not import RLinf registry)") + + print("\n" + "=" * 60) + + +def run(argv: list[str]) -> None: + """Launch RLinf training.""" + os.environ.setdefault("RLINF_EXT_MODULE", "isaaclab_contrib.rl.rlinf.extension") + args_cli = _parse_args(argv) + + if args_cli.list_tasks: + _list_tasks() + return + + config_dir = args_cli.config_path or str(RLINF_DIR) + config_name = args_cli.config_name + os.environ["RLINF_CONFIG_FILE"] = str(Path(config_dir) / f"{config_name}.yaml") + + if config_dir not in os.environ.get("PYTHONPATH", ""): + os.environ["PYTHONPATH"] = config_dir + os.pathsep + os.environ.get("PYTHONPATH", "") + + import rlinf # noqa: F401 + import torch.multiprocessing as mp + from hydra import compose, initialize_config_dir + from hydra.core.global_hydra import GlobalHydra + from omegaconf import open_dict + from rlinf.config import validate_cfg + from rlinf.runners.embodied_runner import EmbodiedRunner + from rlinf.scheduler import Cluster + from rlinf.utils.placement import HybridComponentPlacement + from rlinf.workers.env.env_worker import EnvWorker + from rlinf.workers.rollout.hf.huggingface_worker import MultiStepRolloutWorker + + mp.set_start_method("spawn", force=True) + + print(f"[INFO] Using config: {config_name}") + print(f"[INFO] Config path: {config_dir}") + + GlobalHydra.instance().clear() + initialize_config_dir(config_dir=config_dir, version_base="1.1") + cfg = compose(config_name=config_name) + + task_id = cfg.env.train.init_params.id + print(f"[INFO] Task: {task_id}") + + timestamp = datetime.now().strftime("%Y%m%d-%H:%M:%S") + log_dir = RLINF_DIR / "logs" / "rlinf" / f"{timestamp}-{task_id.replace('/', '_')}" + log_dir.mkdir(parents=True, exist_ok=True) + print(f"[INFO] Logging to: {log_dir}") + + with open_dict(cfg): + cfg.runner.logger.log_path = str(log_dir) + + if args_cli.task: + cfg.env.train.init_params.id = args_cli.task + cfg.env.eval.init_params.id = args_cli.task + + if args_cli.num_envs is not None: + cfg.env.train.total_num_envs = args_cli.num_envs + cfg.env.eval.total_num_envs = args_cli.num_envs + if args_cli.seed is not None: + cfg.actor.seed = args_cli.seed + if args_cli.max_epochs is not None: + cfg.runner.max_epochs = args_cli.max_epochs + if args_cli.model_path is not None: + cfg.actor.model.model_path = args_cli.model_path + cfg.rollout.model.model_path = args_cli.model_path + if args_cli.only_eval: + cfg.runner.only_eval = True + if args_cli.resume_dir: + cfg.runner.resume_dir = args_cli.resume_dir + + cfg = validate_cfg(cfg) + + print("\n" + "=" * 60) + print("RLinf Training Configuration") + print("=" * 60) + print(f" Task: {cfg.env.train.init_params.id}") + print(f" Num envs: {cfg.env.train.total_num_envs}") + print(f" Max epochs: {cfg.runner.max_epochs}") + print(f" Model: {cfg.actor.model.model_path}") + print(f" Algorithm: {cfg.algorithm.loss_type}") + print(f" Log dir: {log_dir}") + print("=" * 60 + "\n") + + cluster = Cluster(cluster_cfg=cfg.cluster) + component_placement = HybridComponentPlacement(cfg, cluster) + + actor_placement = component_placement.get_strategy("actor") + if cfg.algorithm.loss_type == "embodied_sac": + from rlinf.workers.actor.fsdp_sac_policy_worker import EmbodiedSACFSDPPolicy + + actor_worker_cls = EmbodiedSACFSDPPolicy + else: + from rlinf.workers.actor.fsdp_actor_worker import EmbodiedFSDPActor + + actor_worker_cls = EmbodiedFSDPActor + + actor_group = actor_worker_cls.create_group(cfg).launch( + cluster, name=cfg.actor.group_name, placement_strategy=actor_placement + ) + + rollout_placement = component_placement.get_strategy("rollout") + rollout_group = MultiStepRolloutWorker.create_group(cfg).launch( + cluster, name=cfg.rollout.group_name, placement_strategy=rollout_placement + ) + + env_placement = component_placement.get_strategy("env") + env_group = EnvWorker.create_group(cfg).launch(cluster, name=cfg.env.group_name, placement_strategy=env_placement) + + runner = EmbodiedRunner( + cfg=cfg, + actor=actor_group, + rollout=rollout_group, + env=env_group, + ) + + runner.init_workers() + runner.run() diff --git a/scripts/reinforcement_learning/rsl_rl/play.py b/scripts/reinforcement_learning/rsl_rl/play.py index 224ff1e5493c..a1e0ecf5c555 100644 --- a/scripts/reinforcement_learning/rsl_rl/play.py +++ b/scripts/reinforcement_learning/rsl_rl/play.py @@ -5,6 +5,16 @@ """Script to play a checkpoint if an RL agent from RSL-RL.""" +import warnings + +warnings.warn( + "scripts/reinforcement_learning/rsl_rl/play.py is deprecated. Use " + "`./isaaclab.sh play --rl_library rsl_rl --task ` instead. " + "Example: `./isaaclab.sh play --rl_library rsl_rl --task Isaac-Cartpole-v0`.", + DeprecationWarning, + stacklevel=1, +) + import argparse import contextlib import importlib.metadata as metadata diff --git a/scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py b/scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py new file mode 100644 index 000000000000..42ba11beda82 --- /dev/null +++ b/scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py @@ -0,0 +1,229 @@ +# 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 + +"""Script to play a checkpoint of an RL agent from RSL-RL.""" + +import argparse +import contextlib +import importlib.metadata as metadata +import os +import sys +import time + +import gymnasium as gym +import torch +from packaging import version +from rsl_rl.runners import DistillationRunner, OnPolicyRunner + +from isaaclab.envs import DirectMARLEnvCfg, DirectRLEnvCfg, ManagerBasedRLEnvCfg +from isaaclab.utils.assets import retrieve_file_path +from isaaclab.utils.dict import print_dict +from isaaclab.utils.string import list_intersection, string_to_callable + +from isaaclab_rl.rsl_rl import ( + RslRlBaseRunnerCfg, + RslRlVecEnvWrapper, + export_policy_as_jit, + export_policy_as_onnx, + handle_deprecated_rsl_rl_cfg, +) +from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.utils import add_launcher_args, get_checkpoint_path, launch_simulation +from isaaclab_tasks.utils.hydra import hydra_task_config + +# local imports +import cli_args # isort: skip + +# PLACEHOLDER: Extension template (do not remove this comment) +with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + +# -- argparse ---------------------------------------------------------------- +parser = argparse.ArgumentParser(description="Play a checkpoint of an RL agent from RSL-RL.") +parser.add_argument("--video", action="store_true", default=False, help="Record videos during play.") +parser.add_argument("--video_length", type=int, default=200, help="Length of the recorded video (in steps).") +parser.add_argument( + "--disable_fabric", action="store_true", default=False, help="Disable fabric and use USD I/O operations." +) +parser.add_argument("--num_envs", type=int, default=None, help="Number of environments to simulate.") +parser.add_argument("--task", type=str, default=None, help="Name of the task.") +parser.add_argument( + "--agent", type=str, default="rsl_rl_cfg_entry_point", help="Name of the RL agent configuration entry point." +) +parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") +parser.add_argument( + "--use_pretrained_checkpoint", + action="store_true", + help="Use the pre-trained checkpoint from Nucleus.", +) +parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") +parser.add_argument("--external_callback", default=None, help="Fully qualified path to an externally defined callback.") +cli_args.add_rsl_rl_args(parser) +add_launcher_args(parser) +args_cli, remaining_args = parser.parse_known_args() + +if args_cli.video: + args_cli.enable_cameras = True + + +# Call an external callback if requested. This gives opportunity to external code to register the environments +# The function is expected to return a list of arguments that were not consumed by the callback. +remaining_args_env_registration = None +if args_cli.external_callback: + external_callback_function = string_to_callable(args_cli.external_callback, separator=".") + remaining_args_env_registration = external_callback_function() + +# clear out sys.argv for Hydra +# The remaining arguments are the arguments that were not consumed by both this scripts +# argparser and (optionally) the external callback function. +remaining_args = list_intersection(remaining_args, remaining_args_env_registration) +sys.argv = [sys.argv[0]] + remaining_args + +# Check for installed RSL-RL version +installed_version = metadata.version("rsl-rl-lib") + + +@hydra_task_config(args_cli.task, args_cli.agent) +def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): + """Play with RSL-RL agent.""" + with launch_simulation(env_cfg, args_cli): + # grab task name for checkpoint path + task_name = args_cli.task.split(":")[-1] + train_task_name = task_name.replace("-Play", "") + + # override configurations with non-hydra CLI arguments + agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) + env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs + + # handle deprecated configurations + agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, installed_version) + + # set the environment seed + # note: certain randomizations occur in the environment initialization so we set the seed here + env_cfg.seed = agent_cfg.seed + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + + # specify directory for logging experiments + log_root_path = os.path.join("logs", "rsl_rl", agent_cfg.experiment_name) + log_root_path = os.path.abspath(log_root_path) + print(f"[INFO] Loading experiment from directory: {log_root_path}") + if args_cli.use_pretrained_checkpoint: + resume_path = get_published_pretrained_checkpoint("rsl_rl", train_task_name) + if not resume_path: + print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") + return + elif args_cli.checkpoint: + resume_path = retrieve_file_path(args_cli.checkpoint) + else: + resume_path = get_checkpoint_path(log_root_path, agent_cfg.load_run, agent_cfg.load_checkpoint) + + log_dir = os.path.dirname(resume_path) + + # set the log directory for the environment + env_cfg.log_dir = log_dir + + # create isaac environment + env = gym.make(args_cli.task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None) + + # convert to single-agent instance if required by the RL algorithm + if isinstance(env.unwrapped.cfg, DirectMARLEnvCfg): + from isaaclab.envs import multi_agent_to_single_agent + + env = multi_agent_to_single_agent(env) + + # wrap for video recording + if args_cli.video: + video_kwargs = { + "video_folder": os.path.join(log_dir, "videos", "play"), + "step_trigger": lambda step: step == 0, + "video_length": args_cli.video_length, + "disable_logger": True, + } + print("[INFO] Recording videos during play.") + print_dict(video_kwargs, nesting=4) + env = gym.wrappers.RecordVideo(env, **video_kwargs) + + # wrap around environment for rsl-rl + env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) + + print(f"[INFO]: Loading model checkpoint from: {resume_path}") + # load previously trained model + if agent_cfg.class_name == "OnPolicyRunner": + runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) + elif agent_cfg.class_name == "DistillationRunner": + runner = DistillationRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) + else: + raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}") + runner.load(resume_path) + + # obtain the trained policy for inference + policy = runner.get_inference_policy(device=env.unwrapped.device) + + # export the trained policy to JIT and ONNX formats + export_model_dir = os.path.join(os.path.dirname(resume_path), "exported") + + if version.parse(installed_version) >= version.parse("4.0.0"): + # use the new export functions for rsl-rl >= 4.0.0 + runner.export_policy_to_jit(path=export_model_dir, filename="policy.pt") + runner.export_policy_to_onnx(path=export_model_dir, filename="policy.onnx") + policy_nn = None # Not needed for rsl-rl >= 4.0.0 + else: + # extract the neural network for rsl-rl < 4.0.0 + if version.parse(installed_version) >= version.parse("2.3.0"): + policy_nn = runner.alg.policy + else: + policy_nn = runner.alg.actor_critic + + # extract the normalizer + if hasattr(policy_nn, "actor_obs_normalizer"): + normalizer = policy_nn.actor_obs_normalizer + elif hasattr(policy_nn, "student_obs_normalizer"): + normalizer = policy_nn.student_obs_normalizer + else: + normalizer = None + + # export to JIT and ONNX + export_policy_as_jit(policy_nn, normalizer=normalizer, path=export_model_dir, filename="policy.pt") + export_policy_as_onnx(policy_nn, normalizer=normalizer, path=export_model_dir, filename="policy.onnx") + + dt = env.unwrapped.step_dt + + # reset environment + obs = env.get_observations() + timestep = 0 + # simulate environment + try: + while True: + start_time = time.time() + # run everything in inference mode + with torch.inference_mode(): + # agent stepping + actions = policy(obs) + # env stepping + obs, _, dones, _ = env.step(actions) + # reset recurrent states for episodes that have terminated + if version.parse(installed_version) >= version.parse("4.0.0"): + policy.reset(dones) + else: + policy_nn.reset(dones) + if args_cli.video: + timestep += 1 + if timestep == args_cli.video_length: + break + + sleep_time = dt - (time.time() - start_time) + if args_cli.real_time and sleep_time > 0: + time.sleep(sleep_time) + + # close the simulator + env.close() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index b5f4fcaf0db5..dc3cbf6710d5 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -5,6 +5,16 @@ """Script to train RL agent with RSL-RL.""" +import warnings + +warnings.warn( + "scripts/reinforcement_learning/rsl_rl/train.py is deprecated. Use " + "`./isaaclab.sh train --rl_library rsl_rl --task ` instead. " + "Example: `./isaaclab.sh train --rl_library rsl_rl --task Isaac-Cartpole-v0`.", + DeprecationWarning, + stacklevel=1, +) + import argparse import contextlib import importlib.metadata as metadata diff --git a/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py new file mode 100644 index 000000000000..426fef016d9b --- /dev/null +++ b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py @@ -0,0 +1,179 @@ +# 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 + +"""RSL-RL training logic for the unified reinforcement learning entrypoint.""" + +from __future__ import annotations + +import argparse +import contextlib +import importlib.metadata as metadata +import logging +import os +import platform +import time +from datetime import datetime +from pathlib import Path + +from common import ( + add_common_train_args, + add_isaaclab_launcher_args, + apply_env_overrides, + configure_io_descriptors, + create_isaaclab_env, + dump_train_configs, + enable_cameras_for_video, + import_local_module, + set_hydra_args, + validate_distributed_device, + wrap_record_video, +) +from packaging import version + +import isaaclab_tasks # noqa: F401 + +logger = logging.getLogger(__name__) + +RSL_RL_VERSION = "5.0.1" +RL_ROOT = Path(__file__).resolve().parents[1] +CLI_ARGS = import_local_module("isaaclab_rsl_rl_cli_args", RL_ROOT / "rsl_rl" / "cli_args.py") + +# PLACEHOLDER: Extension template (do not remove this comment) +with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + +def _check_rsl_rl_version() -> str: + """Check that the installed RSL-RL version is supported.""" + installed_version = metadata.version("rsl-rl-lib") + if version.parse(installed_version) < version.parse(RSL_RL_VERSION): + if platform.system() == "Windows": + cmd = [r".\isaaclab.bat", "-p", "-m", "pip", "install", f"rsl-rl-lib=={RSL_RL_VERSION}"] + else: + cmd = ["./isaaclab.sh", "-p", "-m", "pip", "install", f"rsl-rl-lib=={RSL_RL_VERSION}"] + print( + f"Please install the correct version of RSL-RL.\nExisting version is: '{installed_version}'" + f" and required version is: '{RSL_RL_VERSION}'.\nTo install the correct version, run:" + f"\n\n\t{' '.join(cmd)}\n" + ) + raise SystemExit(1) + return installed_version + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + """Parse RSL-RL training arguments.""" + from isaaclab.utils.string import list_intersection, string_to_callable + + parser = argparse.ArgumentParser(description="Train an RL agent with RSL-RL.") + add_common_train_args( + parser, + agent_default="rsl_rl_cfg_entry_point", + agent_help="Name of the RL agent configuration entry point.", + ) + parser.add_argument( + "--external_callback", + default=None, + help="Fully qualified path to an externally defined callback.", + ) + CLI_ARGS.add_rsl_rl_args(parser) + add_isaaclab_launcher_args(parser) + args_cli, remaining_args = parser.parse_known_args(argv) + enable_cameras_for_video(args_cli) + + remaining_args_env_registration = None + if args_cli.external_callback: + external_callback_function = string_to_callable(args_cli.external_callback, separator=".") + remaining_args_env_registration = external_callback_function() + + set_hydra_args(list_intersection(remaining_args, remaining_args_env_registration)) + return args_cli + + +def run(argv: list[str]) -> None: + """Train an RSL-RL agent.""" + import torch + from rsl_rl.runners import DistillationRunner, OnPolicyRunner + + from isaaclab.envs import DirectMARLEnvCfg + + from isaaclab_rl.rsl_rl import RslRlVecEnvWrapper, handle_deprecated_rsl_rl_cfg + + from isaaclab_tasks.utils import get_checkpoint_path, launch_simulation, resolve_task_config + + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True + torch.backends.cudnn.deterministic = False + torch.backends.cudnn.benchmark = False + + args_cli = _parse_args(argv) + installed_version = _check_rsl_rl_version() + env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) + + with launch_simulation(env_cfg, args_cli): + agent_cfg = CLI_ARGS.update_rsl_rl_cfg(agent_cfg, args_cli) + apply_env_overrides(args_cli, env_cfg) + agent_cfg.max_iterations = ( + args_cli.max_iterations if args_cli.max_iterations is not None else agent_cfg.max_iterations + ) + + agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, installed_version) + + env_cfg.seed = agent_cfg.seed + validate_distributed_device(args_cli) + + if args_cli.distributed: + global_rank = int(os.getenv("RANK", "0")) + agent_cfg.device = env_cfg.sim.device + + seed = agent_cfg.seed + global_rank + env_cfg.seed = seed + agent_cfg.seed = seed + + log_root_path = os.path.abspath(os.path.join("logs", "rsl_rl", agent_cfg.experiment_name)) + print(f"[INFO] Logging experiment in directory: {log_root_path}") + log_dir = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + print(f"Exact experiment name requested from command line: {log_dir}") + if agent_cfg.run_name: + log_dir += f"_{agent_cfg.run_name}" + log_dir = os.path.join(log_root_path, log_dir) + + configure_io_descriptors(env_cfg, args_cli, logger) + env_cfg.log_dir = log_dir + + env = create_isaaclab_env( + args_cli.task, + env_cfg, + args_cli, + convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), + ) + + if agent_cfg.resume or agent_cfg.algorithm.class_name == "Distillation": + resume_path = get_checkpoint_path(log_root_path, agent_cfg.load_run, agent_cfg.load_checkpoint) + + env = wrap_record_video(env, log_dir, args_cli) + + start_time = time.time() + env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) + + if agent_cfg.class_name == "OnPolicyRunner": + runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=log_dir, device=agent_cfg.device) + elif agent_cfg.class_name == "DistillationRunner": + runner = DistillationRunner(env, agent_cfg.to_dict(), log_dir=log_dir, device=agent_cfg.device) + else: + raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}") + + runner.add_git_repo_to_log(__file__) + if agent_cfg.resume or agent_cfg.algorithm.class_name == "Distillation": + print(f"[INFO]: Loading model checkpoint from: {resume_path}") + runner.load(resume_path) + + dump_train_configs(log_dir, env_cfg, agent_cfg) + + try: + runner.learn(num_learning_iterations=agent_cfg.max_iterations, init_at_random_ep_len=True) + print(f"Training time: {round(time.time() - start_time, 2)} seconds") + env.close() + except KeyboardInterrupt: + pass diff --git a/scripts/reinforcement_learning/sb3/play.py b/scripts/reinforcement_learning/sb3/play.py index a1f8757a1e8c..edbe7183a242 100644 --- a/scripts/reinforcement_learning/sb3/play.py +++ b/scripts/reinforcement_learning/sb3/play.py @@ -5,6 +5,16 @@ """Script to play a checkpoint if an RL agent from Stable-Baselines3.""" +import warnings + +warnings.warn( + "scripts/reinforcement_learning/sb3/play.py is deprecated. Use " + "`./isaaclab.sh play --rl_library sb3 --task ` instead. " + "Example: `./isaaclab.sh play --rl_library sb3 --task Isaac-Cartpole-v0`.", + DeprecationWarning, + stacklevel=1, +) + import argparse import contextlib import os diff --git a/scripts/reinforcement_learning/sb3/play_sb3.py b/scripts/reinforcement_learning/sb3/play_sb3.py new file mode 100644 index 000000000000..e345c3c62880 --- /dev/null +++ b/scripts/reinforcement_learning/sb3/play_sb3.py @@ -0,0 +1,188 @@ +# 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 + +"""Script to play a checkpoint of an RL agent from Stable-Baselines3.""" + +import argparse +import contextlib +import os +import random +import sys +import time +from pathlib import Path + +import gymnasium as gym +import torch +from stable_baselines3 import PPO +from stable_baselines3.common.vec_env import VecNormalize + +from isaaclab.envs import DirectMARLEnvCfg +from isaaclab.utils.dict import print_dict + +from isaaclab_rl.sb3 import Sb3VecEnvWrapper, process_sb3_cfg +from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.utils import add_launcher_args, get_checkpoint_path, launch_simulation, resolve_task_config + +# PLACEHOLDER: Extension template (do not remove this comment) +with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + +# -- argparse ---------------------------------------------------------------- +parser = argparse.ArgumentParser(description="Play a checkpoint of an RL agent from Stable-Baselines3.") +parser.add_argument("--video", action="store_true", default=False, help="Record videos during play.") +parser.add_argument("--video_length", type=int, default=200, help="Length of the recorded video (in steps).") +parser.add_argument( + "--disable_fabric", action="store_true", default=False, help="Disable fabric and use USD I/O operations." +) +parser.add_argument("--num_envs", type=int, default=None, help="Number of environments to simulate.") +parser.add_argument("--task", type=str, default=None, help="Name of the task.") +parser.add_argument( + "--agent", type=str, default="sb3_cfg_entry_point", help="Name of the RL agent configuration entry point." +) +parser.add_argument("--checkpoint", type=str, default=None, help="Path to model checkpoint.") +parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") +parser.add_argument( + "--use_pretrained_checkpoint", + action="store_true", + help="Use the pre-trained checkpoint from Nucleus.", +) +parser.add_argument( + "--use_last_checkpoint", + action="store_true", + help="When no checkpoint provided, use the last saved model. Otherwise use the best saved model.", +) +parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") +parser.add_argument( + "--keep_all_info", + action="store_true", + default=False, + help="Use a slower SB3 wrapper but keep all the extra training info.", +) +add_launcher_args(parser) +args_cli, hydra_args = parser.parse_known_args() + +if args_cli.video: + args_cli.enable_cameras = True + +sys.argv = [sys.argv[0]] + hydra_args + + +def main(): + """Play with stable-baselines agent.""" + env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) + with launch_simulation(env_cfg, args_cli): + # grab task name for checkpoint path + task_name = args_cli.task.split(":")[-1] + train_task_name = task_name.replace("-Play", "") + # randomly sample a seed if seed = -1 + if args_cli.seed == -1: + args_cli.seed = random.randint(0, 10000) + + # override configurations with non-hydra CLI arguments + env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs + agent_cfg["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg["seed"] + env_cfg.seed = agent_cfg["seed"] + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + + # directory for logging into + log_root_path = os.path.join("logs", "sb3", train_task_name) + log_root_path = os.path.abspath(log_root_path) + # checkpoint and log_dir stuff + if args_cli.use_pretrained_checkpoint: + checkpoint_path = get_published_pretrained_checkpoint("sb3", train_task_name) + if not checkpoint_path: + print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") + return + elif args_cli.checkpoint is None: + if args_cli.use_last_checkpoint: + checkpoint = "model_.*.zip" + else: + checkpoint = "model.zip" + checkpoint_path = get_checkpoint_path(log_root_path, ".*", checkpoint, sort_alpha=False) + else: + checkpoint_path = args_cli.checkpoint + log_dir = os.path.dirname(checkpoint_path) + + # set the log directory for the environment + env_cfg.log_dir = log_dir + + # create isaac environment + env = gym.make(args_cli.task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None) + + # post-process agent configuration + agent_cfg = process_sb3_cfg(agent_cfg, env.unwrapped.num_envs) + + # convert to single-agent instance if required by the RL algorithm + if isinstance(env.unwrapped.cfg, DirectMARLEnvCfg): + from isaaclab.envs import multi_agent_to_single_agent + + env = multi_agent_to_single_agent(env) + + # wrap for video recording + if args_cli.video: + video_kwargs = { + "video_folder": os.path.join(log_dir, "videos", "play"), + "step_trigger": lambda step: step == 0, + "video_length": args_cli.video_length, + "disable_logger": True, + } + print("[INFO] Recording videos during play.") + print_dict(video_kwargs, nesting=4) + env = gym.wrappers.RecordVideo(env, **video_kwargs) + # wrap around environment for stable baselines + env = Sb3VecEnvWrapper(env, fast_variant=not args_cli.keep_all_info) + + vec_norm_path = checkpoint_path.replace("/model", "/model_vecnormalize").replace(".zip", ".pkl") + vec_norm_path = Path(vec_norm_path) + + # normalize environment (if needed) + if vec_norm_path.exists(): + print(f"Loading saved normalization: {vec_norm_path}") + env = VecNormalize.load(vec_norm_path, env) + env.training = False + env.norm_reward = False + elif "normalize_input" in agent_cfg: + env = VecNormalize( + env, + training=True, + norm_obs="normalize_input" in agent_cfg and agent_cfg.pop("normalize_input"), + clip_obs="clip_obs" in agent_cfg and agent_cfg.pop("clip_obs"), + ) + + # create agent from stable baselines + print(f"Loading checkpoint from: {checkpoint_path}") + agent = PPO.load(checkpoint_path, env, print_system_info=True) + + dt = env.unwrapped.step_dt + + # reset environment + obs = env.reset() + timestep = 0 + # simulate environment + try: + while True: + start_time = time.time() + with torch.inference_mode(): + actions, _ = agent.predict(obs, deterministic=True) + obs, _, _, _ = env.step(actions) + if args_cli.video: + timestep += 1 + if timestep == args_cli.video_length: + break + + sleep_time = dt - (time.time() - start_time) + if args_cli.real_time and sleep_time > 0: + time.sleep(sleep_time) + + # close the simulator + env.close() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/scripts/reinforcement_learning/sb3/train.py b/scripts/reinforcement_learning/sb3/train.py index 7bf757ef5483..6fcc3a9826a9 100644 --- a/scripts/reinforcement_learning/sb3/train.py +++ b/scripts/reinforcement_learning/sb3/train.py @@ -6,6 +6,16 @@ """Script to train RL agent with Stable Baselines3.""" +import warnings + +warnings.warn( + "scripts/reinforcement_learning/sb3/train.py is deprecated. Use " + "`./isaaclab.sh train --rl_library sb3 --task ` instead. " + "Example: `./isaaclab.sh train --rl_library sb3 --task Isaac-Cartpole-v0`.", + DeprecationWarning, + stacklevel=1, +) + import argparse import contextlib import logging diff --git a/scripts/reinforcement_learning/sb3/train_sb3.py b/scripts/reinforcement_learning/sb3/train_sb3.py new file mode 100644 index 000000000000..1f7a45c118b2 --- /dev/null +++ b/scripts/reinforcement_learning/sb3/train_sb3.py @@ -0,0 +1,176 @@ +# 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 + +"""Stable-Baselines3 training logic for the unified reinforcement learning entrypoint.""" + +from __future__ import annotations + +import argparse +import contextlib +import logging +import os +import random +import signal +import sys +import time +from datetime import datetime +from pathlib import Path + +from common import ( + add_common_train_args, + add_isaaclab_launcher_args, + apply_env_overrides, + configure_io_descriptors, + create_isaaclab_env, + dump_train_configs, + enable_cameras_for_video, + set_hydra_args, + wrap_record_video, +) + +import isaaclab_tasks # noqa: F401 + +logger = logging.getLogger(__name__) + +# PLACEHOLDER: Extension template (do not remove this comment) +with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + +def _cleanup_pbar(*args): + """Stop training and clean up rich progress bars on Ctrl+C.""" + import gc + + tqdm_objects = [obj for obj in gc.get_objects() if "tqdm" in type(obj).__name__] + for tqdm_object in tqdm_objects: + if "tqdm_rich" in type(tqdm_object).__name__: + tqdm_object.close() + raise KeyboardInterrupt + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + """Parse Stable-Baselines3 training arguments.""" + parser = argparse.ArgumentParser(description="Train an RL agent with Stable-Baselines3.") + add_common_train_args( + parser, + agent_default="sb3_cfg_entry_point", + agent_help="Name of the RL agent configuration entry point.", + include_distributed=False, + ) + parser.add_argument("--log_interval", type=int, default=100_000, help="Log data every n timesteps.") + parser.add_argument("--checkpoint", type=str, default=None, help="Continue the training from checkpoint.") + parser.add_argument( + "--keep_all_info", + action="store_true", + default=False, + help="Use a slower SB3 wrapper but keep all the extra training info.", + ) + add_isaaclab_launcher_args(parser) + args_cli, hydra_args = parser.parse_known_args(argv) + enable_cameras_for_video(args_cli) + set_hydra_args(hydra_args) + return args_cli + + +def run(argv: list[str]) -> None: + """Train a Stable-Baselines3 agent.""" + import numpy as np + from stable_baselines3 import PPO + from stable_baselines3.common.callbacks import CheckpointCallback, LogEveryNTimesteps + from stable_baselines3.common.vec_env import VecNormalize + + from isaaclab.envs import DirectMARLEnvCfg + + from isaaclab_rl.sb3 import Sb3VecEnvWrapper, process_sb3_cfg + + from isaaclab_tasks.utils import launch_simulation, resolve_task_config + + signal.signal(signal.SIGINT, _cleanup_pbar) + + args_cli = _parse_args(argv) + env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) + + with launch_simulation(env_cfg, args_cli): + if args_cli.seed == -1: + args_cli.seed = random.randint(0, 10000) + + apply_env_overrides(args_cli, env_cfg) + agent_cfg["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg["seed"] + if args_cli.max_iterations is not None: + agent_cfg["n_timesteps"] = args_cli.max_iterations * agent_cfg["n_steps"] * env_cfg.scene.num_envs + + env_cfg.seed = agent_cfg["seed"] + + run_info = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + log_root_path = os.path.abspath(os.path.join("logs", "sb3", args_cli.task)) + print(f"[INFO] Logging experiment in directory: {log_root_path}") + print(f"Exact experiment name requested from command line: {run_info}") + log_dir = os.path.join(log_root_path, run_info) + dump_train_configs(log_dir, env_cfg, agent_cfg) + + command = " ".join(sys.orig_argv) + (Path(log_dir) / "command.txt").write_text(command) + + agent_cfg = process_sb3_cfg(agent_cfg, env_cfg.scene.num_envs) + policy_arch = agent_cfg.pop("policy") + n_timesteps = agent_cfg.pop("n_timesteps") + + configure_io_descriptors(env_cfg, args_cli, logger) + env_cfg.log_dir = log_dir + + env = create_isaaclab_env( + args_cli.task, + env_cfg, + args_cli, + convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), + ) + env = wrap_record_video(env, log_dir, args_cli) + + start_time = time.time() + env = Sb3VecEnvWrapper(env, fast_variant=not args_cli.keep_all_info) + + norm_keys = {"normalize_input", "normalize_value", "clip_obs"} + norm_args = {} + for key in norm_keys: + if key in agent_cfg: + norm_args[key] = agent_cfg.pop(key) + + if norm_args and norm_args.get("normalize_input"): + print(f"Normalizing input, {norm_args=}") + env = VecNormalize( + env, + training=True, + norm_obs=norm_args["normalize_input"], + norm_reward=norm_args.get("normalize_value", False), + clip_obs=norm_args.get("clip_obs", 100.0), + gamma=agent_cfg["gamma"], + clip_reward=np.inf, + ) + + agent = PPO(policy_arch, env, verbose=1, tensorboard_log=log_dir, **agent_cfg) + if args_cli.checkpoint is not None: + agent = agent.load(args_cli.checkpoint, env, print_system_info=True) + + checkpoint_callback = CheckpointCallback(save_freq=1000, save_path=log_dir, name_prefix="model", verbose=2) + callbacks = [checkpoint_callback, LogEveryNTimesteps(n_steps=args_cli.log_interval)] + + with contextlib.suppress(KeyboardInterrupt): + agent.learn( + total_timesteps=n_timesteps, + callback=callbacks, + progress_bar=True, + log_interval=None, + ) + + agent.save(os.path.join(log_dir, "model")) + print("Saving to:") + print(os.path.join(log_dir, "model.zip")) + + if isinstance(env, VecNormalize): + print("Saving normalization") + env.save(os.path.join(log_dir, "model_vecnormalize.pkl")) + + print(f"Training time: {round(time.time() - start_time, 2)} seconds") + env.close() diff --git a/scripts/reinforcement_learning/skrl/play.py b/scripts/reinforcement_learning/skrl/play.py index 8663d0561941..528aae67e32a 100644 --- a/scripts/reinforcement_learning/skrl/play.py +++ b/scripts/reinforcement_learning/skrl/play.py @@ -10,6 +10,16 @@ a more user-friendly way. """ +import warnings + +warnings.warn( + "scripts/reinforcement_learning/skrl/play.py is deprecated. Use " + "`./isaaclab.sh play --rl_library skrl --task ` instead. " + "Example: `./isaaclab.sh play --rl_library skrl --task Isaac-Cartpole-v0`.", + DeprecationWarning, + stacklevel=1, +) + import argparse import contextlib import os diff --git a/scripts/reinforcement_learning/skrl/play_skrl.py b/scripts/reinforcement_learning/skrl/play_skrl.py new file mode 100644 index 000000000000..46f79599c13f --- /dev/null +++ b/scripts/reinforcement_learning/skrl/play_skrl.py @@ -0,0 +1,229 @@ +# 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 + +""" +Script to play a checkpoint of an RL agent from skrl. + +Visit the skrl documentation (https://skrl.readthedocs.io) to see the examples structured in +a more user-friendly way. +""" + +import argparse +import contextlib +import os +import random +import sys +import time + +import gymnasium as gym +import skrl +import torch +from packaging import version + +from isaaclab.envs import DirectMARLEnvCfg +from isaaclab.utils.dict import print_dict + +from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.utils import add_launcher_args, get_checkpoint_path, launch_simulation, resolve_task_config + +# PLACEHOLDER: Extension template (do not remove this comment) +with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + +SKRL_VERSION = "2.0.0" + +# -- argparse ---------------------------------------------------------------- +parser = argparse.ArgumentParser(description="Play a checkpoint of an RL agent from skrl.") +parser.add_argument("--video", action="store_true", default=False, help="Record videos during play.") +parser.add_argument("--video_length", type=int, default=200, help="Length of the recorded video (in steps).") +parser.add_argument( + "--disable_fabric", action="store_true", default=False, help="Disable fabric and use USD I/O operations." +) +parser.add_argument("--num_envs", type=int, default=None, help="Number of environments to simulate.") +parser.add_argument("--task", type=str, default=None, help="Name of the task.") +parser.add_argument( + "--agent", + type=str, + default=None, + help=( + "Name of the RL agent configuration entry point. Defaults to None, in which case the argument " + "--algorithm is used to determine the default agent configuration entry point." + ), +) +parser.add_argument("--checkpoint", type=str, default=None, help="Path to model checkpoint.") +parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") +parser.add_argument( + "--use_pretrained_checkpoint", + action="store_true", + help="Use the pre-trained checkpoint from Nucleus.", +) +parser.add_argument( + "--ml_framework", + type=str, + default="torch", + choices=["torch", "jax"], + help="The ML framework used for training the skrl agent.", +) +parser.add_argument( + "--algorithm", + type=str, + default="PPO", + choices=["AMP", "PPO", "IPPO", "MAPPO"], + help="The RL algorithm used for training the skrl agent.", +) +parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") +add_launcher_args(parser) +args_cli, hydra_args = parser.parse_known_args() + +if args_cli.video: + args_cli.enable_cameras = True + +sys.argv = [sys.argv[0]] + hydra_args + +# -- check skrl version ------------------------------------------------------ +if version.parse(skrl.__version__) < version.parse(SKRL_VERSION): + skrl.logger.error( + f"Unsupported skrl version: {skrl.__version__}. " + f"Install supported version using 'pip install skrl>={SKRL_VERSION}'" + ) + exit() + +# config shortcuts +if args_cli.agent is None: + algorithm = args_cli.algorithm.lower() + agent_cfg_entry_point = "skrl_cfg_entry_point" if algorithm in ["ppo"] else f"skrl_{algorithm}_cfg_entry_point" +else: + agent_cfg_entry_point = args_cli.agent + algorithm = agent_cfg_entry_point.split("_cfg")[0].split("skrl_")[-1].lower() + + +def main(): + """Play with skrl agent.""" + env_cfg, experiment_cfg = resolve_task_config(args_cli.task, agent_cfg_entry_point) + with launch_simulation(env_cfg, args_cli): + if args_cli.ml_framework.startswith("torch"): + from skrl.utils.runner.torch import Runner + elif args_cli.ml_framework.startswith("jax"): + from skrl.utils.runner.jax import Runner + + from isaaclab_rl.skrl import SkrlVecEnvWrapper + + # grab task name for checkpoint path + task_name = args_cli.task.split(":")[-1] + train_task_name = task_name.replace("-Play", "") + + # override configurations with non-hydra CLI arguments + env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + + # configure the ML framework into the global skrl variable + if args_cli.ml_framework.startswith("jax"): + skrl.config.jax.backend = "jax" if args_cli.ml_framework == "jax" else "numpy" + + # randomly sample a seed if seed = -1 + if args_cli.seed == -1: + args_cli.seed = random.randint(0, 10000) + + # set the agent and environment seed from command line + experiment_cfg["seed"] = args_cli.seed if args_cli.seed is not None else experiment_cfg["seed"] + env_cfg.seed = experiment_cfg["seed"] + + # specify directory for logging experiments (load checkpoint) + log_root_path = os.path.join("logs", "skrl", experiment_cfg["agent"]["experiment"]["directory"]) + log_root_path = os.path.abspath(log_root_path) + print(f"[INFO] Loading experiment from directory: {log_root_path}") + # get checkpoint path + if args_cli.use_pretrained_checkpoint: + resume_path = get_published_pretrained_checkpoint("skrl", train_task_name) + if not resume_path: + print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") + return + elif args_cli.checkpoint: + resume_path = os.path.abspath(args_cli.checkpoint) + else: + resume_path = get_checkpoint_path( + log_root_path, run_dir=f".*_{algorithm}_{args_cli.ml_framework}", other_dirs=["checkpoints"] + ) + log_dir = os.path.dirname(os.path.dirname(resume_path)) + + # set the log directory for the environment + env_cfg.log_dir = log_dir + + # create isaac environment + env = gym.make(args_cli.task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None) + + # convert to single-agent instance if required by the RL algorithm + if isinstance(env.unwrapped.cfg, DirectMARLEnvCfg) and algorithm in ["ppo"]: + from isaaclab.envs import multi_agent_to_single_agent + + env = multi_agent_to_single_agent(env) + + # get environment (step) dt for real-time evaluation + try: + dt = env.step_dt + except AttributeError: + dt = env.unwrapped.step_dt + + # wrap for video recording + if args_cli.video: + video_kwargs = { + "video_folder": os.path.join(log_dir, "videos", "play"), + "step_trigger": lambda step: step == 0, + "video_length": args_cli.video_length, + "disable_logger": True, + } + print("[INFO] Recording videos during play.") + print_dict(video_kwargs, nesting=4) + env = gym.wrappers.RecordVideo(env, **video_kwargs) + + # wrap around environment for skrl + env = SkrlVecEnvWrapper(env, ml_framework=args_cli.ml_framework) + + # configure and instantiate the skrl runner + experiment_cfg["trainer"]["close_environment_at_exit"] = False + experiment_cfg["agent"]["experiment"]["write_interval"] = 0 + experiment_cfg["agent"]["experiment"]["checkpoint_interval"] = 0 + runner = Runner(env, experiment_cfg) + + print(f"[INFO] Loading model checkpoint from: {resume_path}") + runner.agent.load(resume_path) + runner.agent.enable_training_mode(False, apply_to_models=True) + + # reset environment + obs, _ = env.reset() + states = env.state() + timestep = 0 + # simulate environment + try: + while True: + start_time = time.time() + + with torch.inference_mode(): + outputs = runner.agent.act(obs, states, timestep=0, timesteps=0) + if hasattr(env, "possible_agents"): + actions = {a: outputs[-1][a].get("mean_actions", outputs[0][a]) for a in env.possible_agents} + else: + actions = outputs[-1].get("mean_actions", outputs[0]) + obs, _, _, _, _ = env.step(actions) + states = env.state() + if args_cli.video: + timestep += 1 + if timestep == args_cli.video_length: + break + + sleep_time = dt - (time.time() - start_time) + if args_cli.real_time and sleep_time > 0: + time.sleep(sleep_time) + + # close the simulator + env.close() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/scripts/reinforcement_learning/skrl/train.py b/scripts/reinforcement_learning/skrl/train.py index 535403e5a105..866e36c7ecea 100644 --- a/scripts/reinforcement_learning/skrl/train.py +++ b/scripts/reinforcement_learning/skrl/train.py @@ -10,6 +10,16 @@ a more user-friendly way. """ +import warnings + +warnings.warn( + "scripts/reinforcement_learning/skrl/train.py is deprecated. Use " + "`./isaaclab.sh train --rl_library skrl --task ` instead. " + "Example: `./isaaclab.sh train --rl_library skrl --task Isaac-Cartpole-v0`.", + DeprecationWarning, + stacklevel=1, +) + import argparse import contextlib import logging diff --git a/scripts/reinforcement_learning/skrl/train_skrl.py b/scripts/reinforcement_learning/skrl/train_skrl.py new file mode 100644 index 000000000000..392564cc48fe --- /dev/null +++ b/scripts/reinforcement_learning/skrl/train_skrl.py @@ -0,0 +1,180 @@ +# 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 + +"""skrl training logic for the unified reinforcement learning entrypoint.""" + +from __future__ import annotations + +import argparse +import contextlib +import logging +import os +import random +import time +from datetime import datetime + +from common import ( + add_common_train_args, + add_isaaclab_launcher_args, + apply_env_overrides, + configure_io_descriptors, + create_isaaclab_env, + dump_train_configs, + enable_cameras_for_video, + set_hydra_args, + validate_distributed_device, + wrap_record_video, +) +from packaging import version + +import isaaclab_tasks # noqa: F401 + +logger = logging.getLogger(__name__) + +SKRL_VERSION = "2.0.0" + +# PLACEHOLDER: Extension template (do not remove this comment) +with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + """Parse skrl training arguments.""" + parser = argparse.ArgumentParser(description="Train an RL agent with skrl.") + add_common_train_args( + parser, + agent_default=None, + agent_help=( + "Name of the RL agent configuration entry point. Defaults to None, in which case the argument " + "--algorithm is used to determine the default agent configuration entry point." + ), + ) + parser.add_argument("--checkpoint", type=str, default=None, help="Path to model checkpoint to resume training.") + parser.add_argument( + "--ml_framework", + type=str, + default="torch", + choices=["torch", "jax"], + help="The ML framework used for training the skrl agent.", + ) + parser.add_argument( + "--algorithm", + type=str, + default="PPO", + choices=["AMP", "PPO", "IPPO", "MAPPO"], + help="The RL algorithm used for training the skrl agent.", + ) + add_isaaclab_launcher_args(parser) + args_cli, hydra_args = parser.parse_known_args(argv) + enable_cameras_for_video(args_cli) + set_hydra_args(hydra_args) + return args_cli + + +def _resolve_agent_entry_point(args_cli: argparse.Namespace) -> tuple[str, str]: + """Resolve the skrl agent entry point and algorithm from CLI arguments.""" + if args_cli.agent is None: + algorithm = args_cli.algorithm.lower() + agent_cfg_entry_point = "skrl_cfg_entry_point" if algorithm in ["ppo"] else f"skrl_{algorithm}_cfg_entry_point" + else: + agent_cfg_entry_point = args_cli.agent + algorithm = agent_cfg_entry_point.split("_cfg")[0].split("skrl_")[-1].lower() + return agent_cfg_entry_point, algorithm + + +def run(argv: list[str]) -> None: + """Train a skrl agent.""" + import skrl + + from isaaclab.envs import DirectMARLEnvCfg + from isaaclab.utils.assets import retrieve_file_path + + from isaaclab_rl.skrl import SkrlVecEnvWrapper + + from isaaclab_tasks.utils import launch_simulation, resolve_task_config + + args_cli = _parse_args(argv) + + if version.parse(skrl.__version__) < version.parse(SKRL_VERSION): + skrl.logger.error( + f"Unsupported skrl version: {skrl.__version__}. " + f"Install supported version using 'pip install skrl>={SKRL_VERSION}'" + ) + raise SystemExit(1) + + agent_cfg_entry_point, algorithm = _resolve_agent_entry_point(args_cli) + env_cfg, agent_cfg = resolve_task_config(args_cli.task, agent_cfg_entry_point) + + with launch_simulation(env_cfg, args_cli): + if args_cli.ml_framework.startswith("torch"): + from skrl.utils.runner.torch import Runner + elif args_cli.ml_framework.startswith("jax"): + from skrl.utils.runner.jax import Runner + + apply_env_overrides(args_cli, env_cfg) + validate_distributed_device(args_cli) + + if args_cli.distributed: + global_rank = int(os.getenv("RANK", "0")) + + if args_cli.max_iterations: + agent_cfg["trainer"]["timesteps"] = args_cli.max_iterations * agent_cfg["agent"]["rollouts"] + agent_cfg["trainer"]["close_environment_at_exit"] = False + + if args_cli.ml_framework.startswith("jax"): + skrl.config.jax.backend = "jax" if args_cli.ml_framework == "jax" else "numpy" + + if args_cli.seed == -1: + args_cli.seed = random.randint(0, 10000) + + agent_cfg["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg["seed"] + if args_cli.distributed: + agent_cfg["seed"] = agent_cfg["seed"] + global_rank + env_cfg.seed = agent_cfg["seed"] + + log_root_path = os.path.abspath(os.path.join("logs", "skrl", agent_cfg["agent"]["experiment"]["directory"])) + print(f"[INFO] Logging experiment in directory: {log_root_path}") + log_dir = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + f"_{algorithm}_{args_cli.ml_framework}" + print(f"Exact experiment name requested from command line: {log_dir}") + if agent_cfg["agent"]["experiment"]["experiment_name"]: + log_dir += f"_{agent_cfg['agent']['experiment']['experiment_name']}" + agent_cfg["agent"]["experiment"]["directory"] = log_root_path + agent_cfg["agent"]["experiment"]["experiment_name"] = log_dir + log_dir = os.path.join(log_root_path, log_dir) + + dump_train_configs(log_dir, env_cfg, agent_cfg) + + resume_path = retrieve_file_path(args_cli.checkpoint) if args_cli.checkpoint else None + + configure_io_descriptors(env_cfg, args_cli, logger) + env_cfg.log_dir = log_dir + + env = create_isaaclab_env( + args_cli.task, + env_cfg, + args_cli, + convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg) and algorithm in ["ppo"], + ) + env = wrap_record_video(env, log_dir, args_cli) + + start_time = time.time() + env = SkrlVecEnvWrapper(env, ml_framework=args_cli.ml_framework) + runner = Runner(env, agent_cfg) + + if resume_path: + print(f"[INFO] Loading model checkpoint from: {resume_path}") + runner.agent.load(resume_path) + + try: + runner.run() + print(f"Training time: {round(time.time() - start_time, 2)} seconds") + + total_timesteps = agent_cfg["trainer"]["timesteps"] + os.makedirs(os.path.join(log_dir, "checkpoints"), exist_ok=True) + runner.agent.write_checkpoint(timestep=total_timesteps, timesteps=total_timesteps) + print(f"[INFO] Saved final agent checkpoint to: {log_dir}/checkpoints") + env.close() + except KeyboardInterrupt: + pass diff --git a/scripts/reinforcement_learning/train.py b/scripts/reinforcement_learning/train.py new file mode 100644 index 000000000000..99313867c2a2 --- /dev/null +++ b/scripts/reinforcement_learning/train.py @@ -0,0 +1,37 @@ +# 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 + +"""Unified training entrypoint for Isaac Lab reinforcement learning workflows.""" + +from __future__ import annotations + +from pathlib import Path + +from common import dispatch_library_entrypoint + +SCRIPT_DIR = Path(__file__).resolve().parent + +LIBRARY_ENTRYPOINTS = { + "rl_games": SCRIPT_DIR / "rl_games" / "train_rl_games.py", + "rlinf": SCRIPT_DIR / "rlinf" / "train_rlinf.py", + "rsl_rl": SCRIPT_DIR / "rsl_rl" / "train_rsl_rl.py", + "sb3": SCRIPT_DIR / "sb3" / "train_sb3.py", + "skrl": SCRIPT_DIR / "skrl" / "train_skrl.py", +} + + +def main(argv: list[str] | None = None) -> int: + """Run the selected reinforcement learning training library.""" + return dispatch_library_entrypoint( + argv, + LIBRARY_ENTRYPOINTS, + action="train", + description="Train an RL agent with a selected reinforcement learning library.", + library_help="Training library to use.", + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/source/isaaclab/changelog.d/mh-uv_train.minor.rst b/source/isaaclab/changelog.d/mh-uv_train.minor.rst new file mode 100644 index 000000000000..15f4e48a780a --- /dev/null +++ b/source/isaaclab/changelog.d/mh-uv_train.minor.rst @@ -0,0 +1,13 @@ +Added +^^^^^ + +* Added unified ``train`` and ``play`` console-script entry points (``isaaclab.cli:train`` + and ``isaaclab.cli:play``) that dispatch to a library-specific implementation via + ``--rl_library``. Supported libraries are ``rsl_rl``, ``rl_games``, ``skrl``, ``sb3``, + and ``rlinf``. +* Added refactored per-library train/play scripts under + ``scripts/reinforcement_learning/`` with a shared ``common.dispatch_library_entrypoint`` + helper, replacing the previous standalone per-library scripts. +* Added experimental ``uv run`` workflow allowing ``uv run train`` and ``uv run play`` + directly from the repository root without manual environment setup. See + :ref:`uv-run-training` for usage. diff --git a/source/isaaclab/isaaclab/cli/__init__.py b/source/isaaclab/isaaclab/cli/__init__.py index 22cfa1e51725..ee04e558705b 100644 --- a/source/isaaclab/isaaclab/cli/__init__.py +++ b/source/isaaclab/isaaclab/cli/__init__.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause import argparse +import sys +from pathlib import Path from .commands.envs import command_setup_conda, command_setup_uv from .commands.format import command_format @@ -17,17 +19,46 @@ command_vscode_settings, ) from .utils import ( + ISAACLAB_ROOT, is_windows, run_python_command, ) +def train(args: list[str] | None = None) -> None: + """Run the unified reinforcement learning training script.""" + if args is None: + args = sys.argv[1:] + run_python_command(ISAACLAB_ROOT / "scripts" / "reinforcement_learning" / "train.py", args, check=True) + + +def play(args: list[str] | None = None) -> None: + """Run the unified reinforcement learning play script.""" + if args is None: + args = sys.argv[1:] + run_python_command(ISAACLAB_ROOT / "scripts" / "reinforcement_learning" / "play.py", args, check=True) + + def cli() -> None: """Parse CLI arguments and run the requested command.""" + if len(sys.argv) > 1 and sys.argv[1] == "train": + train(sys.argv[2:]) + return + if len(sys.argv) > 1 and sys.argv[1] == "play": + play(sys.argv[2:]) + return + + executable_name = Path(sys.argv[0]).name + default_prog = "isaaclab.bat" if is_windows() else "isaaclab.sh" parser = argparse.ArgumentParser( description="Isaac Lab CLI", - prog="isaaclab" + (".bat" if is_windows() else ".sh"), + prog=executable_name if executable_name != "__main__.py" else default_prog, formatter_class=argparse.RawTextHelpFormatter, + epilog=( + "commands:\n" + " train Run scripts/reinforcement_learning/train.py\n" + " play Run scripts/reinforcement_learning/play.py" + ), ) _submodules_str = ", ".join(sorted(VALID_ISAACLAB_SUBMODULES)) diff --git a/source/isaaclab/isaaclab/cli/utils.py b/source/isaaclab/isaaclab/cli/utils.py index 611a1c6e8101..c7de00d9656f 100644 --- a/source/isaaclab/isaaclab/cli/utils.py +++ b/source/isaaclab/isaaclab/cli/utils.py @@ -17,6 +17,12 @@ # Default path to look for Isaac Sim is _isaac_sim symlink. DEFAULT_ISAAC_SIM_PATH = ISAACLAB_ROOT / "_isaac_sim" +# Short script names supported by ``isaaclab -p``. +_PYTHON_SCRIPT_ALIASES = { + "train.py": ISAACLAB_ROOT / "scripts" / "reinforcement_learning" / "train.py", + "play.py": ISAACLAB_ROOT / "scripts" / "reinforcement_learning" / "play.py", +} + # ANSI colors. _ANSI_COLOR_RESET = "\033[0m" _ANSI_COLOR_INFO = "\033[36m" # cyan @@ -239,25 +245,41 @@ def run_command( sys.exit(130) +def _is_virtualenv_python(python_exe: str | Path) -> bool: + """Check whether a Python executable belongs to a virtual environment. + + Args: + python_exe: Python executable path. + + Returns: + True when the executable is inside a Python virtual environment. + """ + python_path = Path(python_exe) + return (python_path.parent.parent / "pyvenv.cfg").is_file() + + def get_pip_command(python_exe: str | None = None) -> list[str]: """Return the base pip command tokens for the current environment. When ``uv`` is available and a virtual environment is active, returns - ``["uv", "pip"]``. Otherwise returns ``[python_exe, "-m", "pip"]`` - so that the target interpreter's own pip is used (e.g. Isaac Sim's - bundled ``python.sh``). + ``["uv", "pip"]``. When the target Python belongs to a virtual + environment, ``UV_PYTHON`` is set so ``uv pip`` installs into that + environment even if the process itself is not activated. Otherwise returns + ``[python_exe, "-m", "pip"]`` so that the target interpreter's own pip is + used (e.g. Isaac Sim's bundled ``python.sh``). Args: python_exe: Python executable path. Resolved via :func:`extract_python_exe` when ``None``. """ - in_venv = bool(os.environ.get("VIRTUAL_ENV") or os.environ.get("CONDA_PREFIX") or (sys.prefix != sys.base_prefix)) - if shutil.which("uv") and in_venv: - return ["uv", "pip"] - if python_exe is None: python_exe = extract_python_exe() + in_venv = bool(os.environ.get("VIRTUAL_ENV") or os.environ.get("CONDA_PREFIX") or (sys.prefix != sys.base_prefix)) + if shutil.which("uv") and (in_venv or _is_virtualenv_python(python_exe)): + os.environ["UV_PYTHON"] = python_exe + return ["uv", "pip"] + return [python_exe, "-m", "pip"] @@ -300,17 +322,22 @@ def extract_python_exe() -> str: else: print_debug("extract_python_exe(): No CONDA_PREFIX found.") - # Try the default Isaac Lab uv venv (env_isaaclab/) in the repo root. + # Try the current interpreter when already inside a virtual environment. + if (not python_exe or not Path(python_exe).exists()) and sys.prefix != sys.base_prefix: + python_exe = Path(sys.executable) + print_debug(f"extract_python_exe(): Using active virtual environment python: {python_exe}") + + # Try repo-local virtual environments. if not python_exe or not Path(python_exe).exists(): - default_venv = ISAACLAB_ROOT / "env_isaaclab" - if default_venv.is_dir(): + for default_venv in (ISAACLAB_ROOT / "env_isaaclab", ISAACLAB_ROOT / ".venv"): if is_windows(): candidate = default_venv / "Scripts" / "python.exe" else: candidate = default_venv / "bin" / "python" if candidate.exists(): - print_debug(f"extract_python_exe(): Found default venv python: {candidate}") + print_debug(f"extract_python_exe(): Found repo-local venv python: {candidate}") python_exe = candidate + break # Try kit python. if not python_exe or not Path(python_exe).exists(): @@ -525,6 +552,8 @@ def run_python_command( if is_module: cmd.append("-m") + else: + script_or_module = _PYTHON_SCRIPT_ALIASES.get(str(script_or_module), script_or_module) cmd.append(str(script_or_module)) cmd.extend(args) diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index 1f3be503574e..48dc5eff70aa 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -133,6 +133,13 @@ python_requires=">=3.12", install_requires=INSTALL_REQUIRES, extras_require=EXTRAS_REQUIRE, + entry_points={ + "console_scripts": [ + "isaaclab=isaaclab.cli:cli", + "play=isaaclab.cli:play", + "train=isaaclab.cli:train", + ], + }, dependency_links=PYTORCH_INDEX_URL, packages=["isaaclab"], classifiers=[ From 18c6bf3be9e15e1bf13a683e58ae98244e92893b Mon Sep 17 00:00:00 2001 From: hujc Date: Fri, 15 May 2026 13:04:22 -0700 Subject: [PATCH 069/133] [Newton] Rename per-env labels in physics replication (depends on #5523) (#5433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Extends `_rename_builder_labels` in `isaaclab_newton.cloner.newton_replicate` so that every label-bearing column on the merged Newton `ModelBuilder` is rewritten to per-env USD paths after replication. Previously, only the built-in body/joint/shape/articulation columns were rewritten; tendon labels (and any other string-typed custom-attribute column) kept the source proto path on every replicated environment. ## Stack / dependencies - **Depends on #5523** (\"[Newton] Bump Newton pin to v1.2.0rc2\"). After #5523 lands, this PR rebases cleanly on develop. The Newton 1.2 release ([newton-physics/newton#2659](https://github.com/newton-physics/newton/pull/2659)) also includes the upstream tendon-scoping fix that obsoletes the IsaacLab-side \`_scope_custom_frequencies\` workaround a previous version of this PR carried — that workaround has been removed in favor of relying on the Newton bump in #5523. ## Why the rename is needed Newton's \`add_builder\` copies each proto's bodies, joints, shapes, articulations, etc. into the merged builder verbatim, and tags each row with a \`*_world\` integer column to track env identity. Labels (path strings) are copied as-is. So after cloning N environments from one proto, the merged builder has N copies of every row, all with the **same proto-path string label**, distinguished only by the integer \`*_world\` column. IsaacLab keys most of its data flow off **USD prim paths** (sensor binding, event-term scope, visualization, logging). It needs labels to be unique per-env paths so a body called \`/World/envs/env_3/Robot/Forearm\` is reachable by path lookup. The rename function is the bridge: it walks every label-bearing column post-replication and rewrites the source-root prefix to the per-env destination root using each row's \`*_world\` value. Until this PR, the rename only walked **5 built-in label arrays**. Tendon labels and any string-typed custom-attribute column were missed, so e.g. \`mujoco:tendon_label\` showed \`/World/envs/env_0/...\` for every env — surfaced on Shadow Hand fixed tendons. ## What this PR changes ### \`_rename_builder_labels\` extension * **Pass 1 (built-in label arrays)** — extended from 5 to 6 entity types: \`body\`, \`joint\`, \`shape\`, \`articulation\`, \`constraint_mimic\`, **\`equality_constraint\`** (the latter was missing — would have surfaced for any env using \`MjcEquality\` constraints, currently none). * **Pass 2 (string custom-attribute columns)** — new. Walks every registered custom attribute, finds string-typed columns whose frequency has a \`references=\"world\"\` companion column, and applies the same prefix rewrite. Any future solver-registered string column at such a frequency is handled automatically without changes here. * **Path-separator boundary** on the prefix match: \`startswith(src_path.rstrip(\"/\") + \"/\")\`. Prevents source paths that are string prefixes of one another (\`/Sources/protoA\` vs \`/Sources/protoAB\`) from cross-contaminating when both feed the same envs. * **Hard error on length mismatch**: raises \`ValueError\` if the parallel \`(labels, worlds)\` arrays differ in length, instead of silently truncating. By contract Newton's \`add_builder\` keeps them in lockstep. ### Tests New \`source/isaaclab_newton/test/cloner/test_rename_builder_labels.py\` with 10 cases covering: - Both passes with built-ins and \`mujoco:tendon_label\` rewrite correctly per world. - Cross-pass consistency: every renamed label lives under the per-env root. - Guards: non-path strings pass through untouched; rows whose world id is not in \`env_ids\` keep their original label. - \`test_sparse_env_ids\` — non-contiguous env ids \`[10, 20, 30]\`. - \`TestRenamePass2Generality\` — multiple coexisting custom frequencies, multiple string columns at one frequency, registered-but-empty string column. - \`TestRenameMultiSource::test_prefix_overlap_does_not_cross_contaminate\` — explicit regression for the \`/Sources/protoA\` vs \`/Sources/protoAB\` boundary fix; both sources feed the same envs so the world-id guard cannot mask the boundary bug. Fails without the fix; passes with it. ## Test plan - [x] All 10 unit tests pass. - [x] \`./isaaclab.sh -f\` clean (pre-commit hooks). - [x] Verified the boundary-prefix regression test fails when the boundary terminator is removed and passes when it's restored. - [x] Smoke (Shadow-Hand-Over MAPPO 4 envs / iter 1) shows tendon labels go from \`/World/envs/env_0/.../T_FFJ\` (every env) to \`/World/envs/env_/.../T_FFJ\` (per-env paths) after the rename. --------- Co-authored-by: Kelly Guo --- .../jichuanh-newton-replicate-tendon-fix.rst | 14 +++++ .../cloner/newton_replicate.py | 57 ++++++++++++----- .../test/cloner/test_rename_builder_labels.py | 63 +++++++++++++------ 3 files changed, 101 insertions(+), 33 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst diff --git a/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst b/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst new file mode 100644 index 000000000000..12a62ab4d414 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst @@ -0,0 +1,14 @@ +Fixed +^^^^^ + +* Fixed per-environment string identifiers (e.g. ``mujoco:tendon_label``) + keeping the source proto path after replication. + :func:`~isaaclab_newton.cloner.newton_replicate._rename_builder_labels` + now also walks string-typed custom-attribute columns whose frequency + declares a ``references="world"`` companion, rewriting their per-row + source-path prefix to the destination world root in the same pass that + handles built-in label arrays. Adds ``constraint_mimic`` and + ``equality_constraint`` to that built-in pass for completeness. The + prefix match uses a path-separator boundary so a source path that is a + string prefix of another (e.g. ``/Sources/protoA`` vs + ``/Sources/protoAB``) does not cross-contaminate during the rename. diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py index 46d4f967d51f..e2751e2274f4 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py @@ -118,6 +118,20 @@ def _build_newton_builder_from_mapping( return builder, stage_info, site_index_map +# Built-in label arrays that ``_rename_builder_labels`` rewrites in Pass 1. +# Each type ``t`` has a paired ``_label`` (or ``_key``) string column +# and a ``_world`` int column on Newton's ``ModelBuilder``. Exposed as a +# module-level constant so tests can import it instead of duplicating. +_BUILTIN_LABEL_TYPES: tuple[str, ...] = ( + "body", + "joint", + "shape", + "articulation", + "constraint_mimic", + "equality_constraint", +) + + def _rename_builder_labels( builder: ModelBuilder, sources: Sequence[str], @@ -127,14 +141,13 @@ def _rename_builder_labels( ) -> None: """Rename builder labels/keys from source roots to destination roots. - Walks both built-in label arrays (``body``, ``joint``, ``shape``, - ``articulation``, ``constraint_mimic``, ``equality_constraint``) and any + Walks both built-in label arrays (see :data:`_BUILTIN_LABEL_TYPES`) and any string-typed custom-attribute column whose frequency declares a sibling world column (``references="world"``). - The ``startswith(src_prefix)`` guard makes the rewrite a no-op for strings that - are not paths under the source, so non-path custom string columns are passed - through untouched and any future solver-registered string column is handled - automatically without changes here. + The boundary-safe match (exact source root, or source root followed by ``/``) + makes the rewrite a no-op for strings that are not paths under the source. + Non-path custom string columns are passed through untouched and any future + solver-registered string column is handled automatically without changes here. Args: builder: Newton model builder to update in-place. @@ -145,11 +158,9 @@ def _rename_builder_labels( """ # per-source, per-world renaming (strict prefix swap), compact style preserved for i, src_path in enumerate(sources): - # Boundary-terminated prefix prevents over-matching when one source path is a - # prefix of another (e.g. ``/Sources/protoA`` vs ``/Sources/protoAB``). - src_prefix = src_path.rstrip("/") + "/" - src_prefix_len = len(src_prefix) - 1 # slice index keeps the leading "/" in the suffix - swap = lambda name, new_root: new_root + name[src_prefix_len:] # noqa: E731 + # Canonicalize the source root (drop any trailing ``/``) so the + # boundary-safe match logic in ``_rename_pair`` is unambiguous. + src_root = src_path.rstrip("/") world_cols = torch.nonzero(mapping[i], as_tuple=True)[0].tolist() # Map Newton world IDs (sequential) to destination paths using env_ids world_roots = {int(env_ids[c]): destinations[i].format(int(env_ids[c])) for c in world_cols} @@ -158,15 +169,33 @@ def _rename_pair(values, worlds): if len(values) != len(worlds): raise ValueError(f"label/world column length mismatch: {len(values)} vs {len(worlds)}") for k in range(len(values)): + v = values[k] + if not isinstance(v, str): + continue world_id = int(worlds[k]) - if world_id in world_roots and isinstance(values[k], str) and values[k].startswith(src_prefix): - values[k] = swap(values[k], world_roots[world_id]) + if world_id not in world_roots: + continue + # Gate on an explicit prefix test before slicing. ``str.removeprefix`` + # is tempting but conflates "match with empty suffix" and "no match" + # (both return a string starting with "/"), so a label already + # rewritten in an earlier source-iteration would be re-prepended to + # the next iteration's dst root. + if not v.startswith(src_root): + continue + suffix = v[len(src_root) :] + # ``suffix == ""`` -> exact source-root match (rewrite to dst root). + # ``suffix[0] == "/"`` -> child path under source. + # otherwise -> boundary-bleed sibling like "/Sources/protoAB/x" + # when src_root is "/Sources/protoA" -> skip. + if suffix and not suffix.startswith("/"): + continue + values[k] = world_roots[world_id] + suffix # Pass 1: built-in label arrays. Each has a paired ``*_world`` int column. # Use ``is None`` (not ``or``) so an empty-but-defined ``*_label`` column # is recognized — falling through to ``*_key`` would over-match a # builder that legitimately exposes both attributes. - for t in ("body", "joint", "shape", "articulation", "constraint_mimic", "equality_constraint"): + for t in _BUILTIN_LABEL_TYPES: labels = getattr(builder, f"{t}_label", None) if labels is None: labels = getattr(builder, f"{t}_key", None) diff --git a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py index 2fe930f8b520..5ecf162fbcab 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -21,7 +21,7 @@ import newton import torch -from isaaclab_newton.cloner.newton_replicate import _rename_builder_labels +from isaaclab_newton.cloner.newton_replicate import _BUILTIN_LABEL_TYPES, _rename_builder_labels from newton.solvers import SolverMuJoCo _TENDON_FREQ = "mujoco:tendon" @@ -29,9 +29,6 @@ _DST = "/World/envs/env_{}" -# ─── helpers ───────────────────────────────────────────────────────────────── - - def _inject_builtins(builder: newton.ModelBuilder, types: tuple[str, ...], src_path: str, worlds: list[int]) -> None: """Append ``len(worlds)`` synthetic entries to each built-in ``*_label``/``*_world`` pair.""" for t in types: @@ -60,16 +57,11 @@ def _make_builder_with_entries(worlds: list[int]) -> newton.ModelBuilder: """Builder pre-populated with one row per world for every label class under test.""" b = newton.ModelBuilder() SolverMuJoCo.register_custom_attributes(b) - _inject_builtins( - b, ("body", "joint", "shape", "articulation", "constraint_mimic", "equality_constraint"), _SRC, worlds - ) + _inject_builtins(b, _BUILTIN_LABEL_TYPES, _SRC, worlds) _inject_tendon_strings(b, _SRC, worlds) return b -# ─── tests ─────────────────────────────────────────────────────────────────── - - class TestRenameBuilderLabels(unittest.TestCase): """Both passes rewrite to the same per-env destination pattern.""" @@ -81,12 +73,10 @@ def setUp(self): def _rename(self, builder): _rename_builder_labels(builder, [_SRC], [_DST], self.env_ids, self.mapping) - # Pass 1 --------------------------------------------------------------- - def test_builtin_labels_rewritten_per_world(self): b = _make_builder_with_entries(self.worlds) self._rename(b) - for t in ("body", "joint", "shape", "articulation", "constraint_mimic", "equality_constraint"): + for t in _BUILTIN_LABEL_TYPES: labels = getattr(b, f"{t}_label") worlds_arr = getattr(b, f"{t}_world") for k, w in enumerate(worlds_arr): @@ -96,8 +86,6 @@ def test_builtin_labels_rewritten_per_world(self): msg=f"{t}_label[{k}] not rewritten correctly", ) - # Pass 2 --------------------------------------------------------------- - def test_tendon_label_string_custom_attr_rewritten(self): b = _make_builder_with_entries(self.worlds) self._rename(b) @@ -106,14 +94,12 @@ def test_tendon_label_string_custom_attr_rewritten(self): for k, w in enumerate(worlds_arr): self.assertEqual(labels[k], f"{_DST.format(int(w))}/Tendon_{int(w)}") - # Cross-pass consistency ---------------------------------------------- - def test_all_renamed_labels_share_the_per_env_root(self): """Every label written by either pass must live under ``/World/envs/env_/``.""" b = _make_builder_with_entries(self.worlds) self._rename(b) per_world = {int(w): _DST.format(int(w)) + "/" for w in self.env_ids.tolist()} - for t in ("body", "joint", "shape", "articulation", "constraint_mimic", "equality_constraint"): + for t in _BUILTIN_LABEL_TYPES: for label, w in zip(getattr(b, f"{t}_label"), getattr(b, f"{t}_world")): self.assertTrue(label.startswith(per_world[int(w)]), msg=f"{t}: {label!r}") tendon_labels = b.custom_attributes["mujoco:tendon_label"].values @@ -121,7 +107,29 @@ def test_all_renamed_labels_share_the_per_env_root(self): for label, w in zip(tendon_labels, tendon_worlds): self.assertTrue(label.startswith(per_world[int(w)]), msg=f"tendon: {label!r}") - # Guards --------------------------------------------------------------- + def test_label_equal_to_source_root_is_rewritten(self): + """A label whose value is exactly ``src_path`` (no suffix) maps to the env root. + + Newton may tag a proto's own root prim with a label/key whose value equals the + proto's source path. Regression: an earlier ``startswith(src_prefix)`` form + (where ``src_prefix = src_path + "/"``) silently dropped this case. + """ + b = _make_builder_with_entries(self.worlds) + # Append an exact-root row to body_label (any builtin type would do). + b.body_label.append(_SRC) + b.body_world.append(self.worlds[0]) + self._rename(b) + self.assertEqual(b.body_label[-1], _DST.format(self.worlds[0])) + + def test_trailing_slash_on_source_path_is_canonicalized(self): + """``sources=["/Sources/protoA/"]`` (trailing /) must rewrite identically to no slash.""" + b = _make_builder_with_entries(self.worlds) + _rename_builder_labels(b, [f"{_SRC}/"], [_DST], self.env_ids, self.mapping) + for t in _BUILTIN_LABEL_TYPES: + labels = getattr(b, f"{t}_label") + worlds_arr = getattr(b, f"{t}_world") + for k, w in enumerate(worlds_arr): + self.assertEqual(labels[k], f"{_DST.format(int(w))}/{t}_{int(w)}") def test_non_path_string_left_untouched(self): """Strings that don't start with ``src_path`` must pass through unchanged.""" @@ -153,6 +161,23 @@ def test_sparse_env_ids(self): for k, w in enumerate(b.body_world): self.assertEqual(b.body_label[k], f"/World/envs/env_{int(w)}/body_{int(w)}") + def test_large_world_ids(self): + """Large/sparse ``env_ids`` round-trip — dispatch is by dict, not array index. + + ``world_roots`` is a dict keyed on the actual world id, so id magnitude + does not affect correctness or storage. Cap kept inside ``int32`` since + Newton's ``*_world`` columns are typed int32. + """ + worlds = [0, 1_000_000, 2_147_000_000] # last entry within int32 range + b = newton.ModelBuilder() + SolverMuJoCo.register_custom_attributes(b) + _inject_builtins(b, ("body",), _SRC, worlds) + env_ids = torch.tensor(worlds, dtype=torch.int32) + mapping = torch.ones(1, len(worlds), dtype=torch.bool) + _rename_builder_labels(b, [_SRC], [_DST], env_ids, mapping) + for k, w in enumerate(b.body_world): + self.assertEqual(b.body_label[k], f"/World/envs/env_{int(w)}/body_{int(w)}") + class TestRenamePass2Generality(unittest.TestCase): """Pass 2 must generalize across coexisting frequencies and multiple string columns.""" From 94c09672a9c8dfa38a9b28866df7498bc28cca62 Mon Sep 17 00:00:00 2001 From: "Ji Yuan \"Steven\" Feng NV" <168472921+stevfeng@users.noreply.github.com> Date: Fri, 15 May 2026 13:05:04 -0700 Subject: [PATCH 070/133] Updates URDF/MJCF importer to use the latest Isaac Sim importer (#5394) 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. In Isaac Sim, we have added more capabilities to handle joint presets, fixed joints, and other properties to the importers, so we can simplify the isaac lab importer workflow. 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 --------- Co-authored-by: Kelly Guo --- docs/source/how-to/import_new_asset.rst | 117 +++- .../stevfeng-fix-converter-usd-path.minor.rst | 39 ++ .../isaaclab/sim/converters/mjcf_converter.py | 47 +- .../sim/converters/mjcf_converter_cfg.py | 106 +++- .../isaaclab/sim/converters/urdf_converter.py | 506 +++--------------- .../sim/converters/urdf_converter_cfg.py | 71 ++- .../isaaclab/sim/converters/urdf_utils.py | 350 ------------ .../isaaclab/test/sim/test_mjcf_converter.py | 218 ++++++++ .../isaaclab/test/sim/test_urdf_converter.py | 58 +- 9 files changed, 654 insertions(+), 858 deletions(-) create mode 100644 source/isaaclab/changelog.d/stevfeng-fix-converter-usd-path.minor.rst delete mode 100644 source/isaaclab/isaaclab/sim/converters/urdf_utils.py diff --git a/docs/source/how-to/import_new_asset.rst b/docs/source/how-to/import_new_asset.rst index e241be44e96e..938d0720eb9e 100644 --- a/docs/source/how-to/import_new_asset.rst +++ b/docs/source/how-to/import_new_asset.rst @@ -46,11 +46,12 @@ is then passed to the :class:`~sim.converters.UrdfConverter` class. The URDF importer has various configuration parameters that can be set to control the behavior of the importer. The default values for the importer's configuration parameters are specified are in the :class:`~sim.converters.UrdfConverterCfg` class, and they are listed below. We made a few commonly modified settings to be available as command-line arguments when calling the ``convert_urdf.py``, and they are marked with ``*`` in the list. For a comprehensive list of the configuration parameters, please check the the documentation at `URDF importer`_. +Articulation and joint structure +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * :attr:`~sim.converters.UrdfConverterCfg.fix_base` * - Whether to fix the base of the robot. This depends on whether you have a floating-base or fixed-base robot. The command-line flag is ``--fix-base`` where when set, the importer will fix the base of the robot, otherwise it will default to floating-base. -* :attr:`~sim.converters.UrdfConverterCfg.root_link_name` - The link on which the PhysX articulation root is placed. - **Deprecated in URDF importer 3.0** — this option is ignored. * :attr:`~sim.converters.UrdfConverterCfg.merge_fixed_joints` * - Whether to merge the fixed joints. Usually, this should be set to ``True`` to reduce the asset complexity. The command-line flag is ``--merge-joints`` where when set, the importer will merge the fixed joints, otherwise it will default to not merging the fixed joints. @@ -65,10 +66,53 @@ The default values for the importer's configuration parameters are specified are We support two ways to set the gains: * :attr:`~sim.converters.UrdfConverterCfg.JointDriveCfg.PDGainsCfg` - To directly set the stiffness and damping. + Both ``stiffness`` and ``damping`` accept a single float (applied uniformly). * :attr:`~sim.converters.UrdfConverterCfg.JointDriveCfg.NaturalFrequencyGainsCfg` - To set the gains using the desired natural frequency response of the system. **Deprecated in URDF importer 3.0** — use ``PDGainsCfg`` instead. +Geometry, collisions, and materials +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* :attr:`~sim.converters.UrdfConverterCfg.collision_from_visuals` - Whether to create collision geometry + from visual geometry when no explicit ```` is defined for a link. Defaults to ``False``. +* :attr:`~sim.converters.UrdfConverterCfg.collision_type` - The collision shape simplification to apply. + One of ``"Convex Hull"`` (default), ``"Convex Decomposition"``, ``"Bounding Sphere"``, or ``"Bounding Cube"``. +* :attr:`~sim.converters.UrdfConverterCfg.self_collision` - Whether to activate self-collisions between + links of the articulation. Defaults to ``False``. +* :attr:`~sim.converters.UrdfConverterCfg.merge_mesh` - Whether to merge meshes where possible to optimize + the model. Defaults to ``False``. +* :attr:`~sim.converters.UrdfConverterCfg.link_density` - Default density in ``kg/m^3`` for links whose + ```` properties are missing. ``0.0`` (default) leaves densities unchanged. + +Asset resolution and output +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* :attr:`~sim.converters.UrdfConverterCfg.ros_package_paths` - List of ROS package name/path mappings used + to resolve ``package://`` URLs in the URDF. Each entry is a dict with keys ``name`` and ``path``. +* :attr:`~sim.converters.UrdfConverterCfg.robot_type` - Robot type applied by the USD robot schema. + Defaults to ``"Default"``. Must be one of: ``"Default"``, ``"End Effector"``, ``"Manipulator"``, + ``"Humanoid"``, ``"Wheeled"``, ``"Holonomic"``, ``"Quadruped"``, ``"Mobile Manipulators"``, ``"Aerial"``. +* :attr:`~sim.converters.UrdfConverterCfg.run_asset_transformer` - Run the asset transformer to convert + the flattened USD into a layered USD (interface USD + payloads). Defaults to ``True``. +* :attr:`~sim.converters.UrdfConverterCfg.run_multi_physics_conversion` - Also emit MuJoCo-compatible joint + attributes alongside PhysX. Defaults to ``True``. +* :attr:`~sim.converters.UrdfConverterCfg.debug_mode` - Write intermediate conversion artifacts next to the + output USD for inspection. Defaults to ``False``. + +Deprecated (no-op in URDF importer 3.0) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following options are retained for backwards compatibility but are ignored by the URDF importer 3.0. +A warning is logged when they are set. + +* :attr:`~sim.converters.UrdfConverterCfg.root_link_name` - The link on which the PhysX articulation root + was previously placed. +* :attr:`~sim.converters.UrdfConverterCfg.convert_mimic_joints_to_normal_joints` - Convert mimic joints to + normal joints during conversion. +* :attr:`~sim.converters.UrdfConverterCfg.replace_cylinders_with_capsules` - Replace cylinder shapes with + capsule shapes during conversion. + For more detailed information on the configuration parameters, please check the documentation for :class:`~sim.converters.UrdfConverterCfg`. Example Usage @@ -139,6 +183,14 @@ is derived automatically from the robot name in the URDF): * ``anymal.usda`` - This is the main asset file. +.. note:: + The URDF importer auto-deduplicates the per-robot subdirectory when it already exists. + If you re-run the converter against the same ``usd_dir`` with a changed configuration + (for example, flipping ``fix_base``), the importer writes to a new numbered folder + (``anymal_1/``, ``anymal_2/``, …) rather than overwriting the previous output. + :attr:`~sim.converters.UrdfConverter.usd_path` reflects whichever folder the importer + actually used. Delete stale subdirectories manually (or wipe ``usd_dir``) if you do not + want them to accumulate on disk. To run the script headless, you can add the ``--headless`` flag. This will not open the GUI and exit the script after the conversion is complete. @@ -169,22 +221,64 @@ parameters, please check the the documentation at `MJCF importer`_. .. note:: The MJCF importer was rewritten in Isaac Sim 5.0 to use the ``mujoco-usd-converter`` library. - Settings such as ``fix_base``, ``import_sites``, ``import_inertia_tensor``, and ``make_instanceable`` - are no longer needed — the converter now handles these automatically based on the MJCF file content. + Settings such as ``import_sites``, ``import_inertia_tensor``, and ``make_instanceable`` are no + longer needed — the converter now handles these automatically based on the MJCF file content. + +Geometry, collisions, and materials +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * :attr:`~sim.converters.MjcfConverterCfg.merge_mesh` * - Whether to merge meshes where possible to optimize the model. The command-line flag is ``--merge-mesh``. * :attr:`~sim.converters.MjcfConverterCfg.collision_from_visuals` * - Whether to generate collision geometry from visual geometries. The command-line flag is ``--collision-from-visuals``. -* :attr:`~sim.converters.MjcfConverterCfg.collision_type` * - Type of collision geometry to use - (e.g. ``"default"``, ``"Convex Hull"``, ``"Convex Decomposition"``). The command-line flag is - ``--collision-type``. +* :attr:`~sim.converters.MjcfConverterCfg.collision_type` * - The collision shape simplification to + apply. One of ``"Convex Hull"`` (default), ``"Convex Decomposition"``, ``"Bounding Sphere"``, or + ``"Bounding Cube"``. The command-line flag is ``--collision-type``. * :attr:`~sim.converters.MjcfConverterCfg.self_collision` * - Whether to activate self-collisions between links of the articulation. The command-line flag is ``--self-collision``. + +Articulation and physics +~~~~~~~~~~~~~~~~~~~~~~~~ + +* :attr:`~sim.converters.MjcfConverterCfg.fix_base` - Whether to add a fixed joint between the world + and the root rigid-body link. Defaults to ``False``. +* :attr:`~sim.converters.MjcfConverterCfg.link_density` - Default density in ``kg/m^3`` for links whose + ```` properties are missing in the MJCF. ``0.0`` (default) leaves densities unchanged. * :attr:`~sim.converters.MjcfConverterCfg.import_physics_scene` * - Import physics scene properties (gravity, time step, etc.) from the MJCF file. Defaults to ``False``. The command-line flag is ``--import-physics-scene``. +Actuator overrides +~~~~~~~~~~~~~~~~~~ + +MuJoCo models actuators as an affine transformation ``tau = gain @ control + bias``. The following +options override the values parsed from the MJCF on a per-actuator basis. Each defaults to ``None``, +which leaves the parsed values unchanged. + +* :attr:`~sim.converters.MjcfConverterCfg.override_gain_type` - The actuator gain type override (e.g. + ``"fixed"``). +* :attr:`~sim.converters.MjcfConverterCfg.override_bias_type` - The actuator bias type override (e.g. + ``"affine"``). +* :attr:`~sim.converters.MjcfConverterCfg.override_gain_prm` - The actuator gain parameter array override. + Example for position control: ``[kp, 0, 0, 0, 0, 0, 0, 0, 0, 0]``. +* :attr:`~sim.converters.MjcfConverterCfg.override_bias_prm` - The actuator bias parameter array override. + Example for position control: ``[0, -kp, -kd, 0, 0, 0, 0, 0, 0, 0]``. + +Asset resolution and output +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* :attr:`~sim.converters.MjcfConverterCfg.robot_type` - Robot type applied by the USD robot schema. + Defaults to ``"Default"``. Must be one of: ``"Default"``, ``"End Effector"``, ``"Manipulator"``, + ``"Humanoid"``, ``"Wheeled"``, ``"Holonomic"``, ``"Quadruped"``, ``"Mobile Manipulators"``, ``"Aerial"``. +* :attr:`~sim.converters.MjcfConverterCfg.run_asset_transformer` - Run the asset transformer to convert + the flattened USD into a layered USD (interface USD + payloads). Defaults to ``True``. +* :attr:`~sim.converters.MjcfConverterCfg.run_multi_physics_conversion` - Convert compatible MuJoCo + attributes to PhysX attributes (e.g. actuator gains). Defaults to ``True``. +* :attr:`~sim.converters.MjcfConverterCfg.debug_mode` - Write intermediate conversion artifacts next to + the output USD for inspection. Defaults to ``False``. + +For more detailed information on the configuration parameters, please check the documentation for :class:`~sim.converters.MjcfConverterCfg`. + Example Usage ~~~~~~~~~~~~~ @@ -234,6 +328,15 @@ Executing the above script will create the USD file inside the * ``h1.usd`` - This is the converted USD asset file. +.. note:: + The MJCF importer auto-deduplicates the per-robot subdirectory when it already exists, + matching the URDF importer's behavior. If you re-run the converter against the same + ``usd_dir`` with a changed configuration, the importer writes to a new numbered folder + (``h1_1/``, ``h1_2/``, …) rather than overwriting the previous output. + :attr:`~sim.converters.MjcfConverter.usd_path` reflects whichever folder the importer + actually used. Delete stale subdirectories manually (or wipe ``usd_dir``) if you do not + want them to accumulate on disk. + .. figure:: ../_static/tutorials/tutorial_convert_mjcf.jpg :align: center :figwidth: 100% diff --git a/source/isaaclab/changelog.d/stevfeng-fix-converter-usd-path.minor.rst b/source/isaaclab/changelog.d/stevfeng-fix-converter-usd-path.minor.rst new file mode 100644 index 000000000000..148af70ffc84 --- /dev/null +++ b/source/isaaclab/changelog.d/stevfeng-fix-converter-usd-path.minor.rst @@ -0,0 +1,39 @@ +Added +^^^^^ +* Added :attr:`~isaaclab.sim.converters.UrdfConverterCfg.ros_package_paths`, + :attr:`~isaaclab.sim.converters.UrdfConverterCfg.robot_type`, + :attr:`~isaaclab.sim.converters.UrdfConverterCfg.run_asset_transformer`, + :attr:`~isaaclab.sim.converters.UrdfConverterCfg.run_multi_physics_conversion`, and + :attr:`~isaaclab.sim.converters.UrdfConverterCfg.debug_mode` config fields that mirror the + new :class:`isaacsim.asset.importer.urdf.URDFImporterConfig` options. +* Extended :attr:`~isaaclab.sim.converters.UrdfConverterCfg.collision_type` to accept + ``"Bounding Sphere"`` and ``"Bounding Cube"`` in addition to the existing ``"Convex Hull"`` + and ``"Convex Decomposition"`` values. +* Added :attr:`~isaaclab.sim.converters.MjcfConverterCfg.fix_base`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.link_density`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.robot_type`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_gain_type`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_bias_type`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_gain_prm`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_bias_prm`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.run_asset_transformer`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.run_multi_physics_conversion`, and + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.debug_mode` config fields that mirror the + new :class:`isaacsim.asset.importer.mjcf.MJCFImporterConfig` options. + +Changed +^^^^^^^ +* Refactored :class:`~isaaclab.sim.converters.UrdfConverter` to delegate the full conversion + pipeline to :class:`isaacsim.asset.importer.urdf.URDFImporter` / + :class:`isaacsim.asset.importer.urdf.URDFImporterConfig`. The duplicated IsaacLab + implementations of ``_apply_fix_base``, ``_apply_link_density``, ``_apply_joint_drives``, + ``_set_drive_type_on_joints``, ``_set_target_type_on_joints``, ``_set_drive_gains_on_joints``, + and ``_fix_articulation_root_for_fixed_base`` have been removed and replaced with a thin + translation layer that maps :class:`~isaaclab.sim.converters.UrdfConverterCfg` onto the + Isaac Sim importer config. All behaviour is preserved. +* Updated :class:`~isaaclab.sim.converters.MjcfConverter` to forward the full set of + :class:`isaacsim.asset.importer.mjcf.MJCFImporterConfig` options to the Isaac Sim importer. + +Removed +^^^^^^^ +* Removed :func:`~isaaclab.sim.converters.urdf_utils.merge_fixed_joints` as it is now handled by the Isaac Sim URDF importer. diff --git a/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py b/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py index 2c8fe992a35a..226bcf3a5a31 100644 --- a/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py @@ -14,9 +14,12 @@ class MjcfConverter(AssetConverterBase): """Converter for a MJCF description file to a USD file. - This class wraps around the `isaacsim.asset.importer.mjcf`_ extension to provide a lazy implementation - for MJCF to USD conversion. It uses the :class:`MJCFImporter` class and :class:`MJCFImporterConfig` - dataclass from Isaac Sim to perform the conversion. + This class wraps around the `isaacsim.asset.importer.mjcf`_ extension to provide a lazy + implementation for MJCF to USD conversion. All conversion logic (USD schema application, + fix-base, density, actuator gains, self-collision, mesh merging, asset transformer + profile) is performed by :class:`~isaacsim.asset.importer.mjcf.MJCFImporter` — this class + only translates :class:`MjcfConverterCfg` into a flat + :class:`~isaacsim.asset.importer.mjcf.MJCFImporterConfig`. .. caution:: The current lazy conversion implementation does not automatically trigger USD generation if @@ -44,43 +47,41 @@ def __init__(self, cfg: MjcfConverterCfg): Args: cfg: The configuration instance for MJCF to USD conversion. """ - # The new MJCF importer outputs to: {usd_path}/{robot_name}/{robot_name}.usda - # Pre-adjust usd_file_name to match this output structure so that lazy conversion works correctly. + # The MJCF importer outputs to: {usd_path}/{robot_name}/{robot_name}.usda + # Pre-adjust `usd_file_name` to match this output structure so that lazy conversion works correctly. file_basename = os.path.splitext(os.path.basename(cfg.asset_path))[0] cfg.usd_file_name = os.path.join(file_basename, f"{file_basename}.usda") super().__init__(cfg=cfg) - """ - Implementation specific methods. - """ - def _convert_asset(self, cfg: MjcfConverterCfg): - """Calls underlying Isaac Sim MJCFImporter to convert MJCF to USD. + """Run the Isaac Sim MJCF importer pipeline. Args: cfg: The configuration instance for MJCF to USD conversion. """ - import shutil - from isaacsim.asset.importer.mjcf import MJCFImporter, MJCFImporterConfig - # Clean up existing output subdirectory so the importer writes fresh files. - # The MJCFImporter outputs to {usd_dir}/{robot_name}/{robot_name}.usda and may - # skip writing if the output already exists from a previous conversion. - file_basename = os.path.splitext(os.path.basename(cfg.asset_path))[0] - output_subdir = os.path.join(self.usd_dir, file_basename) - if os.path.exists(output_subdir): - shutil.rmtree(output_subdir) - import_config = MJCFImporterConfig( mjcf_path=cfg.asset_path, usd_path=self.usd_dir, + import_scene=cfg.import_physics_scene, merge_mesh=cfg.merge_mesh, collision_from_visuals=cfg.collision_from_visuals, collision_type=cfg.collision_type, allow_self_collision=cfg.self_collision, - import_scene=cfg.import_physics_scene, + robot_type=cfg.robot_type, + fix_base=cfg.fix_base, + link_density=cfg.link_density if cfg.link_density > 0.0 else None, + override_gain_type=cfg.override_gain_type, + override_bias_type=cfg.override_bias_type, + override_gain_prm=cfg.override_gain_prm, + override_bias_prm=cfg.override_bias_prm, + run_asset_transformer=cfg.run_asset_transformer, + run_multi_physics_conversion=cfg.run_multi_physics_conversion, + debug_mode=cfg.debug_mode, ) - importer = MJCFImporter(import_config) - importer.import_mjcf() + generated_usd_path = MJCFImporter(import_config).import_mjcf() + if generated_usd_path: + generated_usd_path = os.path.normpath(generated_usd_path) + self._usd_file_name = os.path.relpath(generated_usd_path, self.usd_dir) diff --git a/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py index a9dbe620c7da..d08cdf493906 100644 --- a/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py @@ -3,6 +3,10 @@ # # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + +from typing import Literal + from isaaclab.sim.converters.asset_converter_base_cfg import AssetConverterBaseCfg from isaaclab.utils import configclass @@ -11,15 +15,15 @@ class MjcfConverterCfg(AssetConverterBaseCfg): """The configuration class for MjcfConverter. - .. note:: - From Isaac Sim 5.0 onwards, the MJCF importer was rewritten to use the ``mujoco-usd-converter`` - library. Several settings from the old importer (``fix_base``, ``link_density``, - ``import_inertia_tensor``, ``import_sites``) are no longer available as they are handled - automatically by the converter based on the MJCF file content. + Maps to :class:`~isaacsim.asset.importer.mjcf.MJCFImporterConfig` from the Isaac Sim + MJCF importer. All post-import USD edits (fix-base, density override, actuator gain + overrides, self-collision, mesh merging, asset transformer profile) are performed by + the Isaac Sim importer — this config just forwards the user's choices. .. note:: - The :attr:`~AssetConverterBaseCfg.make_instanceable` setting from the base class is not - supported by the new MJCF importer and will be ignored. + From Isaac Sim 5.0 onwards, the MJCF importer was rewritten to use the + ``mujoco-usd-converter`` library. The :attr:`AssetConverterBaseCfg.make_instanceable` + setting from the base class is not supported by the new MJCF importer and is ignored. """ merge_mesh: bool = False @@ -28,10 +32,11 @@ class MjcfConverterCfg(AssetConverterBaseCfg): collision_from_visuals: bool = False """Generate collision geometry from visual geometries. Defaults to False.""" - collision_type: str = "Convex Hull" + collision_type: Literal["Convex Hull", "Convex Decomposition", "Bounding Sphere", "Bounding Cube"] = "Convex Hull" """Type of collision geometry to use. Defaults to ``"Convex Hull"``. - Supported values are ``"Convex Hull"``, and ``"Convex Decomposition"``. + Supported values match the ``collision_type`` field of + :class:`~isaacsim.asset.importer.mjcf.MJCFImporterConfig`. """ self_collision: bool = False @@ -39,3 +44,86 @@ class MjcfConverterCfg(AssetConverterBaseCfg): import_physics_scene: bool = False """Import the physics scene (time step per second, gravity, etc.) from the MJCF file. Defaults to False.""" + + fix_base: bool = False + """Add a fixed joint from the world to the root rigid-body link. Defaults to False. + + When enabled, :class:`~isaacsim.asset.importer.mjcf.MJCFImporter` inserts a ``FixedJoint`` + between the world and the articulation root and relocates ``ArticulationRootAPI`` onto the + appropriate ancestor prim so PhysX treats the articulation as fixed-base. + """ + + link_density: float = 0.0 + """Default density in ``kg/m^3`` for links whose ``"inertial"`` properties are missing. + Defaults to 0.0. + + A value of ``0.0`` leaves density unchanged. + """ + + robot_type: str = "Default" + """Robot type applied by the USD robot schema. Defaults to ``"Default"``. + + Supported types are: ``Default``, ``End Effector``, ``Manipulator``, ``Humanoid``, ``Wheeled``, + ``Holonomic``, ``Quadruped``, ``Mobile Manipulators``, ``Aerial``. + Forwarded to :class:`~isaacsim.asset.importer.mjcf.MJCFImporterConfig`. + """ + + override_gain_type: str | None = None + """MuJoCo actuator gain type override (e.g. ``"fixed"``). Defaults to ``None``. + + ``None`` leaves the value parsed from the MJCF file unchanged. See + :func:`isaacsim.asset.importer.utils.impl.asset_utils.apply_mjc_actuator_gains` for + the supported encodings. + """ + + override_bias_type: str | None = None + """MuJoCo actuator bias type override (e.g. ``"affine"``). Defaults to ``None``. + + ``None`` leaves the value parsed from the MJCF file unchanged. + """ + + override_gain_prm: list[float] | None = None + """MuJoCo actuator gain parameter array override. Defaults to ``None``. + + Mujoco models actuators using an affine transformation, which is a linear combination of the + gain parameters, control, and bias. + + The affine transformation is defined as: + tau = gain @ control + bias + + ``None`` leaves the value parsed from the MJCF file unchanged. Example for position + control: ``[kp, 0, 0, 0, 0, 0, 0, 0, 0, 0]``. + """ + + override_bias_prm: list[float] | None = None + """MuJoCo actuator bias parameter array override. Defaults to ``None``. + + ``None`` leaves the value parsed from the MJCF file unchanged. Example for position + control: ``[0, -kp, -kd, 0, 0, 0, 0, 0, 0, 0]``. + """ + + run_asset_transformer: bool = True + """Run the asset transformation profile to convert the flattened USD into a layered USD asset. Defaults to True. + + After running this profile, the USD asset will be a layered USD asset with the following structure: + - robot_name.usda (interface usd) + - payloads/base.usda (base usd with links, meshes, and materials) + - payloads/instances.usda (usd with visual and collision geometry) + - payloads/geometry.usd (binary usd with meshes) + - payloads/materials.usda (materials) + - payloads/Physics/physics.usda (neutral physics format) + - payloads/Physics/physX.usda (PhysX attributes) + - payloads/Physics/mujoco.usda (MuJoCo attributes) + + + """ + + run_multi_physics_conversion: bool = True + """Enable to convert compatible MuJoCo attributes to PhysX attributes, such as actuator gains. Defaults to True.""" + + debug_mode: bool = False + """Enable debug mode in the underlying MJCF importer. Defaults to False. + + When enabled, the importer writes intermediate conversion artifacts next to the output + USD for inspection instead of using a temporary scratch directory. + """ diff --git a/source/isaaclab/isaaclab/sim/converters/urdf_converter.py b/source/isaaclab/isaaclab/sim/converters/urdf_converter.py index a2c41483a46f..f30cb50c69a9 100644 --- a/source/isaaclab/isaaclab/sim/converters/urdf_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/urdf_converter.py @@ -5,15 +5,8 @@ from __future__ import annotations -import contextlib -import gc -import importlib -import math import os import pathlib -import re -import shutil -import tempfile import carb import omni.kit.app @@ -25,9 +18,15 @@ class UrdfConverter(AssetConverterBase): """Converter for a URDF description file to a USD file. - This class wraps around the `isaacsim.asset.importer.urdf`_ extension to provide a lazy implementation - for URDF to USD conversion. It stores the output USD file in an instanceable format since that is - what is typically used in all learning related applications. + This class wraps around the `isaacsim.asset.importer.urdf`_ extension to provide a lazy + implementation for URDF to USD conversion. It stores the output USD file in an instanceable + format since that is what is typically used in all learning related applications. + + The heavy lifting (URDF parsing, fixed-joint merging, fix-base insertion, joint-drive + configuration, density override, asset transformer profile) is delegated to Isaac Sim's + :class:`~isaacsim.asset.importer.urdf.URDFImporter` together with + :class:`~isaacsim.asset.importer.urdf.URDFImporterConfig`. IsaacLab only translates its + user-friendly :class:`UrdfConverterCfg` into the flat importer config. .. caution:: The current lazy conversion implementation does not automatically trigger USD generation if @@ -45,11 +44,6 @@ class UrdfConverter(AssetConverterBase): ``replace_cylinders_with_capsules`` are no longer natively supported by the importer and will emit warnings if enabled. - .. note:: - The ``merge_fixed_joints`` feature is implemented as a URDF XML pre-processing step that - runs *before* the USD conversion. It removes fixed joints from the URDF and merges the - child link's visual, collision, and inertial elements into the parent link. - .. _isaacsim.asset.importer.urdf: https://docs.isaacsim.omniverse.nvidia.com/latest/importer_exporter/ext_isaacsim_asset_importer_urdf.html """ @@ -67,140 +61,58 @@ def __init__(self, cfg: UrdfConverterCfg): if not manager.is_extension_enabled("isaacsim.asset.importer.urdf"): manager.set_extension_enabled_immediate("isaacsim.asset.importer.urdf", True) - # set `usd_file_name` to match the new importer's output path structure: + # set `usd_file_name` to match the importer's output path structure: # the importer generates `{usd_path}/{robot_name}/{robot_name}.usda` robot_name = pathlib.PurePath(cfg.asset_path).stem cfg.usd_file_name = os.path.join(robot_name, f"{robot_name}.usda") super().__init__(cfg=cfg) - """ - Implementation specific methods. - """ - def _convert_asset(self, cfg: UrdfConverterCfg): - """Calls the URDF importer 3.0 pipeline to convert URDF to USD. + """Run the Isaac Sim URDF importer pipeline. - This method replicates the ``URDFImporter.import_urdf()`` pipeline from the - ``isaacsim.asset.importer.urdf`` extension, inserting IsaacLab-specific post-processing - (fix base, joint drives, link density) on the intermediate stage before the asset - transformer restructures the output. + Translates :class:`UrdfConverterCfg` into a flat + :class:`~isaacsim.asset.importer.urdf.URDFImporterConfig` and invokes + :meth:`~isaacsim.asset.importer.urdf.URDFImporter.import_urdf`. The importer handles + fixed-joint merging, fix-base insertion, joint-drive configuration, link density + overrides, and the asset transformer profile internally. Args: cfg: The URDF conversion configuration. """ - from isaacsim.asset.importer.utils.impl import importer_utils, stage_utils - from pxr import Sdf - - from .urdf_utils import merge_fixed_joints + from isaacsim.asset.importer.urdf import URDFImporter, URDFImporterConfig # log warnings for features no longer supported by the URDF importer 3.0 self._warn_unsupported_features(cfg) - urdf_path = os.path.normpath(cfg.asset_path) - robot_name = os.path.basename(urdf_path).split(".")[0] - usd_path = os.path.normpath(self.usd_dir) - - # step 0: optionally pre-process the URDF to merge fixed joints - # The merged file is written next to the original so that relative mesh paths - # (e.g. ``meshes/link.stl``) continue to resolve correctly. If the source - # directory is read-only, a temp directory is used as a fallback (relative mesh - # paths may not resolve in that case). - merged_urdf_path: str | None = None - if cfg.merge_fixed_joints: - urdf_dir = os.path.dirname(urdf_path) - try: - fd, merged_urdf_path = tempfile.mkstemp(suffix=".urdf", prefix=".merged_", dir=urdf_dir) - os.close(fd) - except OSError: - carb.log_warn( - "UrdfConverter: Cannot write merged URDF next to the original (read-only directory)." - " Falling back to a temp directory — relative mesh paths may not resolve." - ) - merged_urdf_dir = tempfile.mkdtemp(prefix="isaaclab_urdf_merge_") - merged_urdf_path = os.path.join(merged_urdf_dir, os.path.basename(urdf_path)) - merge_fixed_joints(urdf_path, merged_urdf_path) - urdf_path = merged_urdf_path - - usdex_path = os.path.normpath(os.path.join(usd_path, "usdex")) - intermediate_path = os.path.normpath(os.path.join(usd_path, "temp", f"{robot_name}.usd")) - - # step 1: convert URDF to intermediate USD using urdf-usd-converter - urdf_usd_converter = importlib.import_module("urdf_usd_converter") - converter = urdf_usd_converter.Converter(layer_structure=False, scene=False) - asset: Sdf.AssetPath = converter.convert(urdf_path, usdex_path) - - # step 2: open the intermediate stage and run standard post-processing - stage = stage_utils.open_stage(asset.path) - if not stage: - raise ValueError(f"Failed to open intermediate stage at path: {asset.path}") - - importer_utils.remove_custom_scopes(stage) - importer_utils.add_rigid_body_schemas(stage) - importer_utils.add_joint_schemas(stage) - - # step 3: apply optional importer features - if cfg.collision_from_visuals: - importer_utils.collision_from_visuals(stage, cfg.collision_type) - - importer_utils.enable_self_collision(stage, cfg.self_collision) - - # step 4: IsaacLab-specific post-processing on the intermediate stage - if cfg.fix_base: - self._apply_fix_base(stage) - - if cfg.link_density > 0: - self._apply_link_density(stage, cfg.link_density) - - if cfg.joint_drive: - self._apply_joint_drives(stage, cfg) - - # step 5: save the intermediate stage - stage_utils.save_stage(stage, intermediate_path) - stage = None - gc.collect() - - # step 6: run the asset transformer to produce the final structured output - ext_manager = omni.kit.app.get_app().get_extension_manager() - ext_id = ext_manager.get_enabled_extension_id("isaacsim.asset.transformer.rules") - extension_path = ext_manager.get_extension_path(ext_id) - asset_structure_profile_json_path = os.path.normpath( - os.path.abspath(os.path.join(extension_path, "data", "isaacsim_structure.json")) + # translate nested `JointDriveCfg` into flat importer fields + drive_type, target_type, stiffness, damping = self._unpack_joint_drive(cfg.joint_drive) + + import_config = URDFImporterConfig( + urdf_path=os.path.normpath(cfg.asset_path), + usd_path=os.path.normpath(self.usd_dir), + merge_fixed_joints=cfg.merge_fixed_joints, + merge_mesh=cfg.merge_mesh, + collision_from_visuals=cfg.collision_from_visuals, + collision_type=cfg.collision_type, + allow_self_collision=cfg.self_collision, + ros_package_paths=list(cfg.ros_package_paths), + robot_type=cfg.robot_type, + fix_base=cfg.fix_base, + link_density=cfg.link_density if cfg.link_density > 0.0 else None, + joint_drive_type=drive_type, + joint_target_type=target_type, + override_joint_stiffness=stiffness, + override_joint_damping=damping, + run_asset_transformer=cfg.run_asset_transformer, + run_multi_physics_conversion=cfg.run_multi_physics_conversion, + debug_mode=cfg.debug_mode, ) - importer_utils.run_asset_transformer_profile( - input_stage_path=intermediate_path, - output_package_root=os.path.normpath(os.path.join(usd_path, robot_name)), - profile_json_path=asset_structure_profile_json_path, - ) - - # step 6b: fix ArticulationRootAPI placement for fixed-base articulations. - # After the asset transformer, ArticulationRootAPI ends up on the root rigid body. - # Having a FixedJoint on the same rigid body that has ArticulationRootAPI causes - # PhysX to treat the articulation as a floating-base + constraint (maximal coordinate - # tree) rather than a fixed-base reduced-coordinate articulation. - # Moving ArticulationRootAPI to the parent of the root rigid body resolves this. - if cfg.fix_base: - final_usd_path = os.path.join(usd_path, robot_name, f"{robot_name}.usda") - self._fix_articulation_root_for_fixed_base(final_usd_path) - - # step 7: clean up intermediate files - if os.path.exists(usdex_path): - shutil.rmtree(usdex_path) - temp_dir = os.path.dirname(intermediate_path) - if os.path.exists(temp_dir): - shutil.rmtree(temp_dir) - if merged_urdf_path is not None: - with contextlib.suppress(OSError): - os.remove(merged_urdf_path) - # if we used a fallback temp directory, clean that up too - merged_parent = os.path.dirname(merged_urdf_path) - if merged_parent.startswith(tempfile.gettempdir()) and os.path.isdir(merged_parent): - shutil.rmtree(merged_parent, ignore_errors=True) - - """ - Helper methods. - """ + generated_usd_path = URDFImporter(import_config).import_urdf() + if generated_usd_path: + generated_usd_path = os.path.normpath(generated_usd_path) + self._usd_file_name = os.path.relpath(generated_usd_path, self.usd_dir) @staticmethod def _warn_unsupported_features(cfg: UrdfConverterCfg): @@ -236,319 +148,27 @@ def _warn_unsupported_features(cfg: UrdfConverterCfg): ) @staticmethod - def _apply_fix_base(stage): - """Add a fixed joint from the world to the root link of the robot. - - Args: - stage: The USD stage to modify. - """ - from pxr import UsdPhysics - - default_prim = stage.GetDefaultPrim() - if not default_prim or not default_prim.IsValid(): - carb.log_warn("UrdfConverter: Cannot apply fix_base - no default prim found.") - return - - # find the root link: first child with `RigidBodyAPI` under the prim hierarchy - root_link = None - for prim in stage.Traverse(): - if prim.HasAPI(UsdPhysics.RigidBodyAPI): - root_link = prim - break - - if root_link is None: - carb.log_warn("UrdfConverter: Cannot apply fix_base - no rigid body link found.") - return - - # create a fixed joint connecting the world to the root link - default_prim_path = default_prim.GetPath() - joint_path = default_prim_path.AppendChild("fix_base_joint") - - fixed_joint = UsdPhysics.FixedJoint.Define(stage, joint_path) - # `body0` left empty => connected to the world frame - fixed_joint.CreateBody1Rel().SetTargets([root_link.GetPath()]) - - @staticmethod - def _fix_articulation_root_for_fixed_base(usd_path: str): - """Move ArticulationRootAPI from the root rigid body to its parent prim. - - After the asset transformer, ArticulationRootAPI ends up on the root rigid body. - When combined with a FixedJoint on that same body (``fix_base_joint``), PhysX treats - the articulation as a floating-base + external constraint (maximal coordinate tree) - rather than a proper fixed-base reduced-coordinate articulation. - - Moving ArticulationRootAPI to the parent of the root rigid body (a non-rigid Xform / - Scope ancestor) resolves this, matching the pattern used by ``schemas.py``'s - ``fix_root_link``. - - Changes are authored as **local opinions in the root layer** of the stage, which are - stronger than the variant-payload-sublayer opinions written by the asset transformer. - This means the root layer's ``delete apiSchemas`` overrides the ``prepend apiSchemas`` - in the deeper sublayers without modifying those files. - - Args: - usd_path: Absolute path to the final ``.usda`` file produced by the asset transformer. - """ - from pxr import Usd, UsdPhysics - - stage = Usd.Stage.Open(usd_path) - if not stage: - carb.log_warn( - f"UrdfConverter: Cannot open final stage at '{usd_path}'" - " for fix_base ArticulationRootAPI post-processing." - ) - return - - # Find the root rigid body that incorrectly has ArticulationRootAPI applied. - root_body_prim = None - for prim in stage.Traverse(): - if prim.HasAPI(UsdPhysics.ArticulationRootAPI) and prim.HasAPI(UsdPhysics.RigidBodyAPI): - root_body_prim = prim - break - - if root_body_prim is None: - # ArticulationRootAPI is already on a non-rigid ancestor (correct) or not present. - return - - parent_prim = root_body_prim.GetParent() - if not parent_prim or not parent_prim.IsValid(): - carb.log_warn("UrdfConverter: Root rigid body has no valid parent prim — skipping ArticulationRootAPI fix.") - return - - # Collect all articulation-related schema names applied to the root rigid body. - articulation_api_names = [ - name - for name in root_body_prim.GetAppliedSchemas() - if "ArticulationRoot" in name or name == "PhysxArticulationAPI" - ] - - # --- Apply ArticulationRootAPI schemas to the parent prim --- - # (edit target is the root layer by default; writes local opinions) - UsdPhysics.ArticulationRootAPI.Apply(parent_prim) - already_on_parent = set(parent_prim.GetAppliedSchemas()) - for name in articulation_api_names: - if name != "PhysicsArticulationRootAPI" and name not in already_on_parent: - parent_prim.AddAppliedSchema(name) - - # --- Copy USD articulation attributes to the parent prim --- - usd_art_api = UsdPhysics.ArticulationRootAPI(root_body_prim) - for attr_name in usd_art_api.GetSchemaAttributeNames(): - attr = root_body_prim.GetAttribute(attr_name) - val = attr.Get() if attr else None - if val is not None: - parent_attr = parent_prim.GetAttribute(attr_name) - if not parent_attr: - parent_attr = parent_prim.CreateAttribute(attr_name, attr.GetTypeName()) - parent_attr.Set(val) - - # --- Copy physxArticulation:* attributes to the parent prim --- - for attr in root_body_prim.GetAttributes(): - aname = attr.GetName() - if aname.startswith("physxArticulation:"): - val = attr.Get() - if val is not None: - parent_attr = parent_prim.GetAttribute(aname) - if not parent_attr: - parent_attr = parent_prim.CreateAttribute(aname, attr.GetTypeName()) - parent_attr.Set(val) - - # --- Remove ArticulationRootAPI schemas from the root rigid body --- - # Writing "delete" list-ops in the root layer overrides "prepend" in sublayers. - root_body_prim.RemoveAppliedSchema("PhysxArticulationAPI") - root_body_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) - for name in articulation_api_names: - if name not in ("PhysicsArticulationRootAPI", "PhysxArticulationAPI"): - root_body_prim.RemoveAppliedSchema(name) - - # Save only the root layer (sublayers produced by the asset transformer are untouched). - stage.GetRootLayer().Save() - - @staticmethod - def _apply_link_density(stage, density: float): - """Set default density on rigid body links that have no explicit mass. - - Args: - stage: The USD stage to modify. - density: The density value in kg/m^3. - """ - from pxr import UsdPhysics - - for prim in stage.Traverse(): - if not prim.HasAPI(UsdPhysics.MassAPI): - continue - mass_api = UsdPhysics.MassAPI(prim) - # only set density if mass is not explicitly specified (0.0 means auto-compute) - mass_attr = mass_api.GetMassAttr() - if mass_attr and mass_attr.HasValue() and mass_attr.Get() > 0.0: - continue - density_attr = mass_api.GetDensityAttr() - if not density_attr: - density_attr = mass_api.CreateDensityAttr() - density_attr.Set(density) - - def _apply_joint_drives(self, stage, cfg: UrdfConverterCfg): - """Set joint drive properties (type, target, gains) on USD joints. - - Args: - stage: The USD stage to modify. - cfg: The URDF converter configuration containing joint drive settings. - """ - from pxr import UsdPhysics - - # collect all joints with their metadata - joints: dict[str, tuple] = {} - for prim in stage.Traverse(): - if not (prim.IsA(UsdPhysics.RevoluteJoint) or prim.IsA(UsdPhysics.PrismaticJoint)): - continue - joint_name = prim.GetName() - is_revolute = prim.IsA(UsdPhysics.RevoluteJoint) - instance_name = "angular" if is_revolute else "linear" - joints[joint_name] = (prim, is_revolute, instance_name) - - if not joints: - return - - drive_cfg = cfg.joint_drive - - # apply drive type (force / acceleration) - self._set_drive_type_on_joints(joints, drive_cfg) - # apply target type (none / position / velocity) - self._set_target_type_on_joints(joints, drive_cfg) - # apply gains (stiffness / damping) - self._set_drive_gains_on_joints(joints, drive_cfg) - - # ------------------------------------------------------------------ - # Joint drive helpers - # ------------------------------------------------------------------ - - @staticmethod - def _set_drive_type_on_joints(joints: dict, drive_cfg: UrdfConverterCfg.JointDriveCfg): - """Set the drive type (force or acceleration) on joint prims. + def _unpack_joint_drive(joint_drive: UrdfConverterCfg.JointDriveCfg | None) -> tuple: + """Translate an IsaacLab :class:`UrdfConverterCfg.JointDriveCfg` into flat importer fields. Args: - joints: Mapping of joint name → (prim, is_revolute, instance_name). - drive_cfg: The joint drive configuration. - """ - from pxr import UsdPhysics - - def _apply(prim, instance_name: str, drive_type: str): - drive = UsdPhysics.DriveAPI.Get(prim, instance_name) - type_attr = drive.GetTypeAttr() - if not type_attr: - type_attr = drive.CreateTypeAttr() - type_attr.Set(drive_type) - - if isinstance(drive_cfg.drive_type, str): - for _name, (prim, _is_rev, inst) in joints.items(): - _apply(prim, inst, drive_cfg.drive_type) - elif isinstance(drive_cfg.drive_type, dict): - for pattern, drive_type in drive_cfg.drive_type.items(): - matches = [n for n in joints if re.search(pattern, n)] - if not matches: - raise ValueError( - f"Joint name pattern '{pattern}' in drive_type config matched no joints." - f" Available joints: {list(joints.keys())}" - ) - for name in matches: - prim, _, inst = joints[name] - _apply(prim, inst, drive_type) + joint_drive: The nested IsaacLab joint-drive configuration, or ``None``. - @staticmethod - def _set_target_type_on_joints(joints: dict, drive_cfg: UrdfConverterCfg.JointDriveCfg): - """Set the target type (none, position, velocity) on joint prims. - - For ``"none"``, both stiffness and damping are zeroed out. - - Args: - joints: Mapping of joint name → (prim, is_revolute, instance_name). - drive_cfg: The joint drive configuration. - """ - from pxr import UsdPhysics - - def _apply(prim, instance_name: str, target_type: str): - drive = UsdPhysics.DriveAPI.Get(prim, instance_name) - if target_type == "none": - drive.GetStiffnessAttr().Set(0.0) - drive.GetDampingAttr().Set(0.0) - - if isinstance(drive_cfg.target_type, str): - for _name, (prim, _is_rev, inst) in joints.items(): - _apply(prim, inst, drive_cfg.target_type) - elif isinstance(drive_cfg.target_type, dict): - for pattern, target_type in drive_cfg.target_type.items(): - matches = [n for n in joints if re.search(pattern, n)] - if not matches: - raise ValueError( - f"Joint name pattern '{pattern}' in target_type config matched no joints." - f" Available joints: {list(joints.keys())}" - ) - for name in matches: - prim, _, inst = joints[name] - _apply(prim, inst, target_type) - - @staticmethod - def _set_drive_gains_on_joints(joints: dict, drive_cfg: UrdfConverterCfg.JointDriveCfg): - """Set stiffness and damping on joint drive APIs. - - For revolute joints the user-facing values (Nm/rad) are converted to the USD - convention (Nm/deg) by multiplying by ``pi / 180``. - - Args: - joints: Mapping of joint name → (prim, is_revolute, instance_name). - drive_cfg: The joint drive configuration. + Returns: + Tuple ``(drive_type, target_type, stiffness, damping)`` suitable for + :class:`~isaacsim.asset.importer.urdf.URDFImporterConfig`. Entries are ``None`` when + the user did not request an override. """ - from pxr import UsdPhysics - - gains = drive_cfg.gains - if not isinstance(gains, UrdfConverterCfg.JointDriveCfg.PDGainsCfg): - return - - def _set_stiffness(prim, instance_name: str, is_revolute: bool, value: float): - drive = UsdPhysics.DriveAPI.Get(prim, instance_name) - usd_value = value * math.pi / 180.0 if is_revolute else value - stiffness_attr = drive.GetStiffnessAttr() - if not stiffness_attr: - stiffness_attr = drive.CreateStiffnessAttr() - stiffness_attr.Set(usd_value) - - def _set_damping(prim, instance_name: str, is_revolute: bool, value: float): - drive = UsdPhysics.DriveAPI.Get(prim, instance_name) - usd_value = value * math.pi / 180.0 if is_revolute else value - damping_attr = drive.GetDampingAttr() - if not damping_attr: - damping_attr = drive.CreateDampingAttr() - damping_attr.Set(usd_value) - - # --- stiffness --- - if isinstance(gains.stiffness, (float, int)): - for _name, (prim, is_rev, inst) in joints.items(): - _set_stiffness(prim, inst, is_rev, gains.stiffness) - elif isinstance(gains.stiffness, dict): - for pattern, stiffness in gains.stiffness.items(): - matches = [n for n in joints if re.search(pattern, n)] - if not matches: - raise ValueError( - f"Joint name pattern '{pattern}' in stiffness config matched no joints." - f" Available joints: {list(joints.keys())}" - ) - for name in matches: - prim, is_rev, inst = joints[name] - _set_stiffness(prim, inst, is_rev, stiffness) - - # --- damping --- - if gains.damping is None: - return - if isinstance(gains.damping, (float, int)): - for _name, (prim, is_rev, inst) in joints.items(): - _set_damping(prim, inst, is_rev, gains.damping) - elif isinstance(gains.damping, dict): - for pattern, damping in gains.damping.items(): - matches = [n for n in joints if re.search(pattern, n)] - if not matches: - raise ValueError( - f"Joint name pattern '{pattern}' in damping config matched no joints." - f" Available joints: {list(joints.keys())}" - ) - for name in matches: - prim, is_rev, inst = joints[name] - _set_damping(prim, inst, is_rev, damping) + if joint_drive is None: + return None, None, None, None + + gains = joint_drive.gains + if isinstance(gains, UrdfConverterCfg.JointDriveCfg.PDGainsCfg): + stiffness = gains.stiffness + damping = gains.damping + else: + # `NaturalFrequencyGainsCfg` is deprecated; leave gains unchanged. + stiffness = None + damping = None + + return joint_drive.drive_type, joint_drive.target_type, stiffness, damping diff --git a/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py index feb13f61ff20..d1ed21b75215 100644 --- a/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py @@ -14,7 +14,13 @@ @configclass class UrdfConverterCfg(AssetConverterBaseCfg): - """The configuration class for UrdfConverter.""" + """The configuration class for UrdfConverter. + + Maps to :class:`~isaacsim.asset.importer.urdf.URDFImporterConfig` from the Isaac Sim + URDF importer. IsaacLab exposes a user-friendly nested :class:`JointDriveCfg` that is + translated into the importer's flat ``joint_drive_type`` / ``joint_target_type`` / + ``override_joint_stiffness`` / ``override_joint_damping`` fields at conversion time. + """ @configclass class JointDriveCfg: @@ -102,11 +108,16 @@ class NaturalFrequencyGainsCfg: """The name of the root link. Defaults to None. If None, the root link will be set by PhysX. + + .. deprecated:: + This option is no longer supported by the URDF importer 3.0. A warning is logged if set. """ link_density: float = 0.0 """Default density in ``kg/m^3`` for links whose ``"inertial"`` properties are missing in the URDF. Defaults to 0.0. + + A value of ``0.0`` leaves density unchanged. """ merge_fixed_joints: bool = True @@ -115,7 +126,8 @@ class NaturalFrequencyGainsCfg: When enabled, a URDF XML pre-processing step removes all fixed joints and merges each child link's visual, collision, and inertial elements into the parent link before USD conversion. Downstream joints are re-parented with composed transforms. Chains of - consecutive fixed joints are handled automatically. + consecutive fixed joints are handled automatically. The pre-processing is performed by + :func:`isaacsim.asset.importer.urdf.impl.urdf_utils.merge_fixed_joints`. """ convert_mimic_joints_to_normal_joints: bool = False @@ -128,19 +140,23 @@ class NaturalFrequencyGainsCfg: joint_drive: JointDriveCfg | None = JointDriveCfg() """The joint drive settings. Defaults to :class:`JointDriveCfg`. - The parameter can be set to ``None`` for URDFs without joints. + The parameter can be set to ``None`` for URDFs without joints, in which case no joint drive + overrides are sent to the importer. """ - collision_from_visuals = False + collision_from_visuals: bool = False """Whether to create collision geometry from visual geometry. Defaults to False.""" - collision_type: Literal["Convex Hull", "Convex Decomposition"] = "Convex Hull" - """The collision shape simplification. Defaults to "convex_hull". + collision_type: Literal["Convex Hull", "Convex Decomposition", "Bounding Sphere", "Bounding Cube"] = "Convex Hull" + """The collision shape simplification. Defaults to ``"Convex Hull"``. - Supported values are: + Supported values match the ``collision_type`` field of + :class:`~isaacsim.asset.importer.urdf.URDFImporterConfig`: * ``"Convex Hull"``: The collision shape is simplified to a convex hull. * ``"Convex Decomposition"``: The collision shape is decomposed into smaller convex shapes for a closer fit. + * ``"Bounding Sphere"``: The collision shape is approximated by a bounding sphere. + * ``"Bounding Cube"``: The collision shape is approximated by a bounding cube. """ self_collision: bool = False @@ -155,3 +171,44 @@ class NaturalFrequencyGainsCfg: merge_mesh: bool = False """Merge meshes where possible to optimize the model. Defaults to False.""" + + ros_package_paths: list[dict[str, str]] = [] + """ROS package name/path mappings used to resolve ``package://`` URLs in the URDF. + + Each entry is a dictionary with keys ``name`` and ``path``. The list is forwarded directly + to :class:`~isaacsim.asset.importer.urdf.URDFImporterConfig`. + """ + + robot_type: str = "Default" + """Robot type applied by the USD robot schema. Defaults to ``"Default"``. + + Supported types are: ``Default``, ``End Effector``, ``Manipulator``, ``Humanoid``, ``Wheeled``, + ``Holonomic``, ``Quadruped``, ``Mobile Manipulators``, ``Aerial``. + Forwarded to :class:`~isaacsim.asset.importer.urdf.URDFImporterConfig`. + """ + + run_asset_transformer: bool = True + """Run the asset transformation profile to convert the flattened USD into a layered USD asset. Defaults to True. + + After running this profile, the USD asset will be a layered USD asset with the following structure: + - robot_name.usda (interface usd) + - payloads/base.usda (base usd with links, meshes, and materials) + - payloads/instances.usda (usd with visual and collision geometry) + - payloads/geometry.usd (binary usd with meshes) + - payloads/materials.usda (materials) + - payloads/Physics/physics.usda (neutral physics format) + - payloads/Physics/physX.usda (PhysX attributes) + - payloads/Physics/mujoco.usda (MuJoCo attributes) + """ + + run_multi_physics_conversion: bool = True + """Enable to generate compatible MuJoCo attributes from the URDF joint attributes alongside PhysX. + Defaults to True. + """ + + debug_mode: bool = False + """Enable debug mode in the underlying URDF importer. Defaults to False. + + When enabled, the importer writes intermediate conversion artifacts next to the output + USD for inspection instead of using a temporary scratch directory. + """ diff --git a/source/isaaclab/isaaclab/sim/converters/urdf_utils.py b/source/isaaclab/isaaclab/sim/converters/urdf_utils.py deleted file mode 100644 index dfef1ee01e52..000000000000 --- a/source/isaaclab/isaaclab/sim/converters/urdf_utils.py +++ /dev/null @@ -1,350 +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 pre-processing URDF files before USD conversion.""" - -from __future__ import annotations - -import math -import xml.etree.ElementTree as ET - -import numpy as np - - -def merge_fixed_joints(urdf_path: str, output_path: str) -> str: - """Pre-process a URDF file to merge links connected by fixed joints. - - For each fixed joint, the child link's ````, ````, and ```` - elements are merged into the parent link with proper transform composition. Any - downstream joints whose parent was the child link are re-parented to the surviving - parent link (with their origin transforms composed accordingly). - - Chains of consecutive fixed joints are handled by iterating until no fixed joints - remain. - - Args: - urdf_path: Path to the input URDF file. - output_path: Path to write the modified URDF file. - - Returns: - The *output_path* that was written to. - """ - tree = ET.parse(urdf_path) - root = tree.getroot() - - # iterate until no fixed joints remain (handles chains) - while True: - fixed_joints = [j for j in root.findall("joint") if j.get("type") == "fixed"] - if not fixed_joints: - break - - # process the first fixed joint found (order matters for chains) - joint = fixed_joints[0] - parent_link_name = joint.find("parent").get("link") - child_link_name = joint.find("child").get("link") - - T_joint = _parse_origin(joint.find("origin")) - - # locate the corresponding `` elements - parent_link_elem = _find_link(root, parent_link_name) - child_link_elem = _find_link(root, child_link_name) - - if parent_link_elem is None or child_link_elem is None: - # safety guard: drop the joint and continue - root.remove(joint) - continue - - # move `` elements from child to parent (with composed transforms) - for visual in child_link_elem.findall("visual"): - _compose_origin(visual, T_joint) - parent_link_elem.append(visual) - - # move `` elements from child to parent (with composed transforms) - for collision in child_link_elem.findall("collision"): - _compose_origin(collision, T_joint) - parent_link_elem.append(collision) - - # merge `` properties (mass, CoM, inertia tensor) - _merge_inertial(parent_link_elem, child_link_elem, T_joint) - - # re-parent any joints that reference the child link as their parent - for other_joint in root.findall("joint"): - if other_joint is joint: - continue - parent_elem = other_joint.find("parent") - if parent_elem is not None and parent_elem.get("link") == child_link_name: - parent_elem.set("link", parent_link_name) - # compose transforms: new_origin = T_joint @ T_other - T_other = _parse_origin(other_joint.find("origin")) - _set_origin(other_joint, T_joint @ T_other) - - # remove the fixed joint and the now-empty child link - root.remove(joint) - root.remove(child_link_elem) - - tree.write(output_path, xml_declaration=True, encoding="UTF-8") - return output_path - - -# --------------------------------------------------------------------------- -# Transform helpers -# --------------------------------------------------------------------------- - - -def _parse_origin(origin_elem: ET.Element | None) -> np.ndarray: - """Parse an ```` element into a 4x4 homogeneous transform matrix. - - Args: - origin_elem: The ```` XML element (may be ``None``). - - Returns: - A 4x4 numpy array representing the transform. - """ - if origin_elem is None: - return np.eye(4) - xyz = [float(v) for v in origin_elem.get("xyz", "0 0 0").split()] - rpy = [float(v) for v in origin_elem.get("rpy", "0 0 0").split()] - return _make_transform(xyz, rpy) - - -def _make_transform(xyz: list[float], rpy: list[float]) -> np.ndarray: - """Create a 4x4 homogeneous transform from *xyz* translation and *rpy* rotation. - - Args: - xyz: Translation ``[x, y, z]``. - rpy: Euler angles ``[roll, pitch, yaw]`` in radians (URDF convention: ``Rz @ Ry @ Rx``). - - Returns: - A 4x4 numpy array. - """ - T = np.eye(4) - T[:3, :3] = _rpy_to_rotation_matrix(rpy) - T[:3, 3] = xyz - return T - - -def _rpy_to_rotation_matrix(rpy: list[float]) -> np.ndarray: - """Convert roll-pitch-yaw to a 3x3 rotation matrix (``Rz @ Ry @ Rx``). - - Args: - rpy: Euler angles ``[roll, pitch, yaw]`` in radians. - - Returns: - A 3x3 rotation matrix. - """ - roll, pitch, yaw = rpy - cr, sr = math.cos(roll), math.sin(roll) - cp, sp = math.cos(pitch), math.sin(pitch) - cy, sy = math.cos(yaw), math.sin(yaw) - return np.array( - [ - [cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr], - [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr], - [-sp, cp * sr, cp * cr], - ] - ) - - -def _rotation_matrix_to_rpy(R: np.ndarray) -> tuple[float, float, float]: - """Convert a 3x3 rotation matrix to roll-pitch-yaw. - - Args: - R: A 3x3 rotation matrix. - - Returns: - Tuple ``(roll, pitch, yaw)`` in radians. - """ - sy = -R[2, 0] - if abs(sy) >= 1.0 - 1e-12: - # gimbal lock - pitch = math.copysign(math.pi / 2, sy) - roll = math.atan2(R[0, 1], R[0, 2]) - yaw = 0.0 - else: - pitch = math.asin(np.clip(sy, -1.0, 1.0)) - roll = math.atan2(R[2, 1], R[2, 2]) - yaw = math.atan2(R[1, 0], R[0, 0]) - return (roll, pitch, yaw) - - -def _set_origin(element: ET.Element, T: np.ndarray) -> None: - """Set or create the ```` sub-element of *element* from a 4x4 transform. - - Args: - element: The parent XML element (e.g. ````, ````). - T: The 4x4 homogeneous transform. - """ - xyz = T[:3, 3] - rpy = _rotation_matrix_to_rpy(T[:3, :3]) - - origin = element.find("origin") - if origin is None: - origin = ET.SubElement(element, "origin") - - origin.set("xyz", f"{_fmt(xyz[0])} {_fmt(xyz[1])} {_fmt(xyz[2])}") - origin.set("rpy", f"{_fmt(rpy[0])} {_fmt(rpy[1])} {_fmt(rpy[2])}") - - -def _compose_origin(element: ET.Element, T_parent: np.ndarray) -> None: - """Compose *element*'s ```` with *T_parent* (``T_parent @ T_element``). - - The composed transform replaces the element's existing origin. - - Args: - element: An XML element that may contain an ```` child. - T_parent: The parent transform to prepend. - """ - T_elem = _parse_origin(element.find("origin")) - _set_origin(element, T_parent @ T_elem) - - -def _find_link(root: ET.Element, name: str) -> ET.Element | None: - """Find a ```` element by its ``name`` attribute. - - Args: - root: The ```` root element. - name: Link name to search for. - - Returns: - The matching ```` element, or ``None``. - """ - for link in root.findall("link"): - if link.get("name") == name: - return link - return None - - -def _fmt(v: float) -> str: - """Format a float for URDF output, suppressing near-zero noise. - - Args: - v: The value to format. - - Returns: - A string representation. - """ - if abs(v) < 1e-12: - return "0" - return f"{v:.10g}" - - -# --------------------------------------------------------------------------- -# Inertial merge -# --------------------------------------------------------------------------- - - -def _parse_inertia_matrix(inertia_elem: ET.Element | None) -> np.ndarray: - """Parse an ```` element into a 3x3 symmetric inertia matrix. - - Args: - inertia_elem: The ```` XML element (may be ``None``). - - Returns: - A 3x3 numpy array. - """ - if inertia_elem is None: - return np.zeros((3, 3)) - ixx = float(inertia_elem.get("ixx", "0")) - ixy = float(inertia_elem.get("ixy", "0")) - ixz = float(inertia_elem.get("ixz", "0")) - iyy = float(inertia_elem.get("iyy", "0")) - iyz = float(inertia_elem.get("iyz", "0")) - izz = float(inertia_elem.get("izz", "0")) - return np.array( - [ - [ixx, ixy, ixz], - [ixy, iyy, iyz], - [ixz, iyz, izz], - ] - ) - - -def _merge_inertial(parent_link: ET.Element, child_link: ET.Element, T_joint: np.ndarray) -> None: - """Merge the child link's inertial properties into the parent link. - - Uses the parallel axis theorem to correctly combine mass, center of mass, and - inertia tensors when the two bodies are rigidly attached. - - Args: - parent_link: The parent ```` element that will absorb the child. - child_link: The child ```` element being merged. - T_joint: The 4x4 transform from parent link frame to child link frame. - """ - child_inertial = child_link.find("inertial") - if child_inertial is None: - return # nothing to merge - - child_mass_elem = child_inertial.find("mass") - child_mass = float(child_mass_elem.get("value", "0")) if child_mass_elem is not None else 0.0 - if child_mass == 0.0: - return # zero mass — nothing to merge - - # -- child inertial in parent link frame -- - T_child_inertial = _parse_origin(child_inertial.find("origin")) - T_child_in_parent = T_joint @ T_child_inertial - R_child = T_child_in_parent[:3, :3] - child_com_in_parent = T_child_in_parent[:3, 3] - - child_I_local = _parse_inertia_matrix(child_inertial.find("inertia")) - child_I_in_parent = R_child @ child_I_local @ R_child.T - - # -- parent inertial -- - parent_inertial = parent_link.find("inertial") - if parent_inertial is not None: - parent_mass_elem = parent_inertial.find("mass") - parent_mass = float(parent_mass_elem.get("value", "0")) if parent_mass_elem is not None else 0.0 - T_parent_inertial = _parse_origin(parent_inertial.find("origin")) - R_parent = T_parent_inertial[:3, :3] - parent_com = T_parent_inertial[:3, 3] - parent_I_local = _parse_inertia_matrix(parent_inertial.find("inertia")) - parent_I_in_link = R_parent @ parent_I_local @ R_parent.T - else: - parent_inertial = ET.SubElement(parent_link, "inertial") - parent_mass = 0.0 - parent_com = np.zeros(3) - parent_I_in_link = np.zeros((3, 3)) - - # -- combined mass and center of mass -- - total_mass = parent_mass + child_mass - if total_mass == 0.0: - return - combined_com = (parent_mass * parent_com + child_mass * child_com_in_parent) / total_mass - - # -- parallel axis theorem: shift each inertia tensor to the combined CoM -- - def _shift_inertia(I_at_com: np.ndarray, mass: float, com: np.ndarray, ref: np.ndarray) -> np.ndarray: - d = ref - com - return I_at_com + mass * (np.dot(d, d) * np.eye(3) - np.outer(d, d)) - - parent_I_shifted = ( - _shift_inertia(parent_I_in_link, parent_mass, parent_com, combined_com) if parent_mass > 0 else parent_I_in_link - ) - child_I_shifted = _shift_inertia(child_I_in_parent, child_mass, child_com_in_parent, combined_com) - - combined_I = parent_I_shifted + child_I_shifted - - # -- write back to parent -- - # origin: combined CoM with identity rotation (tensor is already in link frame) - origin = parent_inertial.find("origin") - if origin is None: - origin = ET.SubElement(parent_inertial, "origin") - origin.set("xyz", f"{_fmt(combined_com[0])} {_fmt(combined_com[1])} {_fmt(combined_com[2])}") - origin.set("rpy", "0 0 0") - - # mass - mass_elem = parent_inertial.find("mass") - if mass_elem is None: - mass_elem = ET.SubElement(parent_inertial, "mass") - mass_elem.set("value", f"{_fmt(total_mass)}") - - # inertia tensor - inertia_elem = parent_inertial.find("inertia") - if inertia_elem is None: - inertia_elem = ET.SubElement(parent_inertial, "inertia") - inertia_elem.set("ixx", _fmt(combined_I[0, 0])) - inertia_elem.set("ixy", _fmt(combined_I[0, 1])) - inertia_elem.set("ixz", _fmt(combined_I[0, 2])) - inertia_elem.set("iyy", _fmt(combined_I[1, 1])) - inertia_elem.set("iyz", _fmt(combined_I[1, 2])) - inertia_elem.set("izz", _fmt(combined_I[2, 2])) diff --git a/source/isaaclab/test/sim/test_mjcf_converter.py b/source/isaaclab/test/sim/test_mjcf_converter.py index 2e68b66a2fc0..881e8c4bf19a 100644 --- a/source/isaaclab/test/sim/test_mjcf_converter.py +++ b/source/isaaclab/test/sim/test_mjcf_converter.py @@ -47,6 +47,7 @@ def test_setup_teardown(): yield sim, config # Teardown: Cleanup simulation + sim._disable_app_control_on_stop_handle = True # prevent timeout sim.stop() sim.clear_instance() @@ -97,3 +98,220 @@ def test_create_prim_from_usd(test_setup_teardown): sim_utils.create_prim(prim_path, usd_path=mjcf_converter.usd_path) assert sim.stage.GetPrimAtPath(prim_path).IsValid() + + +@pytest.mark.isaacsim_ci +def test_self_collision(test_setup_teardown): + """Verify that ``self_collision=True`` enables self-collisions on the Newton articulation root. + + The Isaac Sim importer's ``enable_self_collision`` writes the ``newton:selfCollisionEnabled`` + attribute on prims tagged as articulation roots (``UsdPhysics.ArticulationRootAPI``, + ``PhysicsArticulationRootAPI``, or ``NewtonArticulationRootAPI``). + """ + sim, config = test_setup_teardown + test_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(test_dir, "output", "mjcf_self_collision") + os.makedirs(output_dir, exist_ok=True) + + config.self_collision = True + config.force_usd_conversion = True + config.usd_dir = output_dir + mjcf_converter = MjcfConverter(config) + + from pxr import Usd, UsdPhysics + + stage = Usd.Stage.Open(mjcf_converter.usd_path) + + articulation_roots = [ + prim + for prim in stage.Traverse() + if prim.HasAPI(UsdPhysics.ArticulationRootAPI) + or prim.HasAPI("PhysicsArticulationRootAPI") + or prim.HasAPI("NewtonArticulationRootAPI") + ] + assert articulation_roots, "Expected at least one articulation root in the converted USD" + + found_self_collision = False + for prim in articulation_roots: + sc_attr = prim.GetAttribute("newton:selfCollisionEnabled") + if sc_attr and sc_attr.HasValue() and sc_attr.Get(): + found_self_collision = True + break + + assert found_self_collision, "Expected ``newton:selfCollisionEnabled`` to be True on a Newton articulation root" + + +@pytest.mark.isaacsim_ci +def test_collision_from_visuals(test_setup_teardown): + """Verify that ``collision_from_visuals=True`` runs successfully and produces a spawnable USD.""" + sim, config = test_setup_teardown + test_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(test_dir, "output", "mjcf_collision_visuals") + os.makedirs(output_dir, exist_ok=True) + + config.collision_from_visuals = True + config.force_usd_conversion = True + config.usd_dir = output_dir + mjcf_converter = MjcfConverter(config) + + assert os.path.exists(mjcf_converter.usd_path), "USD file should exist after conversion" + + prim_path = "/World/Robot" + sim_utils.create_prim(prim_path, usd_path=mjcf_converter.usd_path) + assert sim.stage.GetPrimAtPath(prim_path).IsValid() + + +@pytest.mark.isaacsim_ci +def test_collision_type_convex_decomposition(test_setup_teardown): + """Verify that ``collision_type='Convex Decomposition'`` runs without error.""" + sim, config = test_setup_teardown + test_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(test_dir, "output", "mjcf_convex_decomp") + os.makedirs(output_dir, exist_ok=True) + + config.collision_from_visuals = True + config.collision_type = "Convex Decomposition" + config.force_usd_conversion = True + config.usd_dir = output_dir + mjcf_converter = MjcfConverter(config) + + assert os.path.exists(mjcf_converter.usd_path), "USD file should exist after conversion" + + prim_path = "/World/Robot" + sim_utils.create_prim(prim_path, usd_path=mjcf_converter.usd_path) + assert sim.stage.GetPrimAtPath(prim_path).IsValid() + + +@pytest.mark.isaacsim_ci +def test_link_density(test_setup_teardown): + """Verify that ``link_density`` applies density without errors. + + ``nv_ant.xml`` has explicit inertial data on most bodies, so density is only applied where + mass is unspecified. This test ensures the pipeline runs and the output is spawnable. + """ + sim, config = test_setup_teardown + test_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(test_dir, "output", "mjcf_link_density") + os.makedirs(output_dir, exist_ok=True) + + config.link_density = 500.0 + config.force_usd_conversion = True + config.usd_dir = output_dir + mjcf_converter = MjcfConverter(config) + + from pxr import Usd, UsdPhysics + + stage = Usd.Stage.Open(mjcf_converter.usd_path) + mass_prims = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.MassAPI)] + assert len(mass_prims) > 0, "Expected prims with MassAPI" + + prim_path = "/World/Robot" + sim_utils.create_prim(prim_path, usd_path=mjcf_converter.usd_path) + assert sim.stage.GetPrimAtPath(prim_path).IsValid() + + +@pytest.mark.isaacsim_ci +def test_merge_mesh(test_setup_teardown): + """Verify that ``merge_mesh=True`` runs successfully and still produces a spawnable USD.""" + sim, config = test_setup_teardown + test_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(test_dir, "output", "mjcf_merge_mesh") + os.makedirs(output_dir, exist_ok=True) + + config.merge_mesh = True + config.force_usd_conversion = True + config.usd_dir = output_dir + mjcf_converter = MjcfConverter(config) + + assert os.path.exists(mjcf_converter.usd_path), "USD file should exist after conversion" + + prim_path = "/World/Robot" + sim_utils.create_prim(prim_path, usd_path=mjcf_converter.usd_path) + assert sim.stage.GetPrimAtPath(prim_path).IsValid() + + +@pytest.mark.isaacsim_ci +def test_import_physics_scene(test_setup_teardown): + """Verify that ``import_physics_scene=True`` still produces a spawnable USD.""" + sim, config = test_setup_teardown + test_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(test_dir, "output", "mjcf_physics_scene") + os.makedirs(output_dir, exist_ok=True) + + config.import_physics_scene = True + config.force_usd_conversion = True + config.usd_dir = output_dir + mjcf_converter = MjcfConverter(config) + + assert os.path.exists(mjcf_converter.usd_path), "USD file should exist after conversion" + + +@pytest.mark.isaacsim_ci +def test_run_asset_transformer_disabled(test_setup_teardown): + """Verify that ``run_asset_transformer=False`` produces a flat USD that is still spawnable.""" + sim, config = test_setup_teardown + test_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(test_dir, "output", "mjcf_no_transformer") + os.makedirs(output_dir, exist_ok=True) + + config.run_asset_transformer = False + config.force_usd_conversion = True + config.usd_dir = output_dir + mjcf_converter = MjcfConverter(config) + + assert os.path.exists(mjcf_converter.usd_path), "USD file should exist after conversion" + + prim_path = "/World/Robot" + sim_utils.create_prim(prim_path, usd_path=mjcf_converter.usd_path) + assert sim.stage.GetPrimAtPath(prim_path).IsValid() + + +@pytest.mark.isaacsim_ci +def test_override_actuator_gains(test_setup_teardown): + """Verify that actuator gain overrides are written to ``MjcActuator`` prims. + + ``nv_ant.xml`` defines ``MjcActuator`` prims, so setting ``override_gain_type``, + ``override_bias_type``, ``override_gain_prm``, and ``override_bias_prm`` should update the + corresponding ``mjc:*`` attributes on every actuator. + """ + sim, config = test_setup_teardown + test_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(test_dir, "output", "mjcf_actuator_gains") + os.makedirs(output_dir, exist_ok=True) + + kp = 50.0 + kd = 5.0 + # canonical position-control encoding from the importer's ``apply_mjc_actuator_gains`` + config.override_gain_type = "fixed" + config.override_bias_type = "affine" + config.override_gain_prm = [kp, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + config.override_bias_prm = [0.0, -kp, -kd, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + config.force_usd_conversion = True + config.usd_dir = output_dir + mjcf_converter = MjcfConverter(config) + + from pxr import Usd + + stage = Usd.Stage.Open(mjcf_converter.usd_path) + + ant = stage.GetPrimAtPath("/ant") + ant.GetVariantSet("Physics").SetVariantSelection("mujoco") + actuator_prims = [p for p in stage.Traverse() if p.GetTypeName() == "MjcActuator"] + assert len(actuator_prims) > 0, "Expected MjcActuator prims in nv_ant.xml output" + + for prim in actuator_prims: + gain_type_attr = prim.GetAttribute("mjc:gainType") + bias_type_attr = prim.GetAttribute("mjc:biasType") + gain_prm_attr = prim.GetAttribute("mjc:gainPrm") + bias_prm_attr = prim.GetAttribute("mjc:biasPrm") + + assert gain_type_attr and gain_type_attr.HasValue() + assert bias_type_attr and bias_type_attr.HasValue() + assert gain_prm_attr and gain_prm_attr.HasValue() + assert bias_prm_attr and bias_prm_attr.HasValue() + + assert gain_type_attr.Get() == "fixed" + assert bias_type_attr.Get() == "affine" + assert abs(gain_prm_attr.Get()[0] - kp) < 1e-6 + assert abs(bias_prm_attr.Get()[1] - (-kp)) < 1e-6 + assert abs(bias_prm_attr.Get()[2] - (-kd)) < 1e-6 diff --git a/source/isaaclab/test/sim/test_urdf_converter.py b/source/isaaclab/test/sim/test_urdf_converter.py index 65c697029f97..7794546a21aa 100644 --- a/source/isaaclab/test/sim/test_urdf_converter.py +++ b/source/isaaclab/test/sim/test_urdf_converter.py @@ -25,7 +25,6 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.sim.converters import UrdfConverter, UrdfConverterCfg -from isaaclab.sim.converters.urdf_utils import merge_fixed_joints # Create a fixture for setup and teardown @@ -79,7 +78,13 @@ def test_no_change(sim_config): @pytest.mark.isaacsim_ci def test_config_change(sim_config): """Call conversion twice but change the config in the second call. This should generate a new USD file.""" + sim, config = sim_config + test_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(test_dir, "output", "urdf_config_change") + os.makedirs(output_dir, exist_ok=True) + + config.usd_dir = output_dir urdf_converter = UrdfConverter(config) time_usd_file_created = os.stat(urdf_converter.usd_path).st_mtime_ns @@ -87,7 +92,7 @@ def test_config_change(sim_config): new_config = config new_config.fix_base = not config.fix_base # define the usd directory - new_config.usd_dir = urdf_converter.usd_dir + new_config.usd_dir = output_dir # convert to usd but this time in the same directory as previous step new_urdf_converter = UrdfConverter(new_config) new_time_usd_file_created = os.stat(new_urdf_converter.usd_path).st_mtime_ns @@ -185,6 +190,8 @@ def test_merge_fixed_joints_xml(): extension_id = manager.get_enabled_extension_id("isaacsim.asset.importer.urdf") extension_path = manager.get_extension_path(extension_id) + from isaacsim.asset.importer.urdf.impl.urdf_utils import merge_fixed_joints + urdf_path = os.path.join(extension_path, "data", "urdf", "tests", "test_merge_joints.urdf") with tempfile.TemporaryDirectory(prefix="isaaclab_test_merge_") as tmpdir: @@ -359,7 +366,12 @@ def test_no_collision_from_visuals(sim_config): @pytest.mark.isaacsim_ci def test_self_collision(sim_config): - """Verify that self_collision=True enables self-collision on the articulation.""" + """Verify that ``self_collision=True`` enables self-collision on the Newton articulation root. + + The Isaac Sim importer's ``enable_self_collision`` writes the ``newton:selfCollisionEnabled`` + attribute on prims tagged as articulation roots (``UsdPhysics.ArticulationRootAPI``, + ``PhysicsArticulationRootAPI``, or ``NewtonArticulationRootAPI``). + """ sim, config = sim_config test_dir = os.path.dirname(os.path.abspath(__file__)) output_dir = os.path.join(test_dir, "output", "urdf_self_collision") @@ -370,21 +382,29 @@ def test_self_collision(sim_config): config.usd_dir = output_dir urdf_converter = UrdfConverter(config) - from pxr import PhysxSchema, Usd + from pxr import Usd, UsdPhysics stage = Usd.Stage.Open(urdf_converter.usd_path) - # find prim with PhysxArticulationAPI and check self-collision flag + articulation_roots = [ + prim + for prim in stage.Traverse() + if prim.HasAPI(UsdPhysics.ArticulationRootAPI) + or prim.HasAPI("PhysicsArticulationRootAPI") + or prim.HasAPI("NewtonArticulationRootAPI") + ] + assert articulation_roots, "Expected at least one articulation root in the converted USD" + found_self_collision = False - for prim in stage.Traverse(): - if prim.HasAPI(PhysxSchema.PhysxArticulationAPI): - physx_api = PhysxSchema.PhysxArticulationAPI(prim) - sc_attr = physx_api.GetEnabledSelfCollisionsAttr() - if sc_attr and sc_attr.HasValue() and sc_attr.Get(): - found_self_collision = True - break + for prim in articulation_roots: + print(prim.GetName()) + print(prim.GetAttribute("newton:selfCollisionEnabled")) + sc_attr = prim.GetAttribute("newton:selfCollisionEnabled") + if sc_attr and sc_attr.HasValue() and sc_attr.Get(): + found_self_collision = True + break - assert found_self_collision, "Expected self-collision to be enabled on the articulation" + assert found_self_collision, "Expected ``newton:selfCollisionEnabled`` to be True on a Newton articulation root" @pytest.mark.isaacsim_ci @@ -636,9 +656,9 @@ def test_usd_structure_has_joints_and_links(sim_config): def test_link_density(sim_config): """Verify that link_density applies density to rigid body links. - Note: The Franka Panda URDF has explicit mass on all links, so ``_apply_link_density`` - only sets density on links without explicit mass (mass == 0). This test verifies the - pipeline runs without errors when link_density is set. + Note: The Franka Panda URDF has explicit mass on all links, so the importer's + ``apply_link_density`` only sets density on links without explicit mass (mass == 0). + This test verifies the pipeline runs without errors when ``link_density`` is set. """ sim, config = sim_config test_dir = os.path.dirname(os.path.abspath(__file__)) @@ -665,8 +685,8 @@ def test_link_density(sim_config): @pytest.mark.isaacsim_ci -def test_collider_type_convex_decomposition(sim_config): - """Verify that collider_type='convex_decomposition' runs without error and produces valid output. +def test_collision_type_convex_decomposition(sim_config): + """Verify that ``collision_type='Convex Decomposition'`` runs without error and produces valid output. Note: MeshCollisionAPI is applied on the intermediate stage before the asset transformer. The transformer may not preserve these schemas in the final output, so this test @@ -678,7 +698,7 @@ def test_collider_type_convex_decomposition(sim_config): os.makedirs(output_dir, exist_ok=True) config.collision_from_visuals = True - config.collider_type = "convex_decomposition" + config.collision_type = "Convex Decomposition" config.force_usd_conversion = True config.usd_dir = output_dir urdf_converter = UrdfConverter(config) From 112f3c017f26d1f8c535bd37c38f2bf1776cf2f4 Mon Sep 17 00:00:00 2001 From: hujc Date: Fri, 15 May 2026 14:31:54 -0700 Subject: [PATCH 071/133] [PresetCLI] Add typed preset selection with task-aware help (#5587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds typed preset selection via Hydra-style tokens — `physics=NAME` / `renderer=NAME` / `presets=NAME[,...]` — that fold into the existing `presets=` Hydra-decorator flow. Makes `--task=X --help` list the actual `PresetCfg` variants present in that task's env_cfg, bucketed by typed target. Adopted in all 16 Hydra-using scripts (`rl_games/sb3/skrl/rsl_rl` train+play, `environments/*`, `benchmarks/*`, `sim2sim_transfer`, `leapp/rsl_rl/export`). ## API shape ```python parser = argparse.ArgumentParser(...) # ... script-specific args ... add_launcher_args(parser) args_cli, hydra_args = setup_preset_cli(parser) sys.argv = [sys.argv[0]] + hydra_args ``` `setup_preset_cli` returns `(args, hydra_argv)` without mutating `sys.argv`. It registers no argparse flags for preset selection — the typed selectors are recognized as Hydra-style tokens in the `parse_known_args` remainder and folded into `hydra_argv[0]` as a single `presets=` token. ## Grammar ``` python train.py --task=X physics=newton_mjwarp renderer=newton_renderer presets=albedo,depth ``` - `physics=NAME` — typed selector for `PhysicsCfg` variants - `renderer=NAME` — typed selector for `RendererCfg` variants - `presets=NAME[,NAME,...]` — broadcast: applied to every matching `PresetCfg` All three fold into one `presets=` token: `presets=newton_mjwarp,newton_renderer,albedo,depth`. The grammar matches Hydra's, so the same line can carry path-targeted overrides (`env.sim.dt=0.001`) that flow through untouched. ## Namespace contract No preset selector is registered with argparse, so the parsed `args` namespace gains no `physics` / `renderer` / `presets` attribute. AppLauncher's name-based forwarding (`set(_SIM_APP_CFG_TYPES) & set(vars(args))`, `app_launcher.py:681`) therefore cannot pick up a preset value and push it into `SimulationApp.config` — the historical `--renderer` → `config["renderer"]` → `None.lower()` crash class is structurally impossible. Two regression tests lock the contract. ## Help text layout `--task=Isaac-Cartpole-v0 --help` renders each selector with its available variants inline directly below it (bullets aligned with the description column): ``` preset selection: Select named PresetCfg alternatives via Hydra-style overrides (key=value, no leading dashes): physics=NAME (typed) selects a PhysicsCfg variant. Available: - newton_kamino - newton_mjwarp - physx renderer=NAME (typed) selects a RendererCfg variant. Available: (none) presets=NAME[,NAME,...] broadcast: applied to every matching PresetCfg. Available: (none) Hydra also accepts path-targeted overrides like env.sim.physics=NAME. ``` Typed variants appear only under their own typed selector. The `presets=` listing shows only DOMAIN-bucket variants (cfgs whose type doesn't subclass any typed target's base class). Without `--task`, each row shows just the selector + description and the section adds a `Pass --task=X` hint on its own paragraph. ## Test plan - [x] `pytest source/isaaclab_tasks/test/test_preset_cli.py` — 24 tests pass. Coverage: enum wiring, token folding/dedupe/passthrough, `_ArgvHelper` semantics, type-based bucketing, all four help-text branches (parametrized), no-`sys.argv`-mutation contract, namespace-clean contract, AppLauncher intersection contract, `hydra_args[0]` preserves the `presets=` token for benchmark telemetry. - [x] `pytest source/isaaclab_tasks/test/test_hydra.py` — 76 tests pass; legacy-alias `FutureWarning` behavior unchanged. - [x] `pre-commit` clean. - [x] Manual: `--task=Isaac-Cartpole-v0 --help` and `--task=Isaac-Cartpole-RGB-Camera-Direct-v0 --help` render correctly. - [x] Manual: `physics=newton_mjwarp renderer=newton_renderer presets=albedo` folds into one `presets=` token at `hydra_argv[0]`. - [x] Manual: unknown name → grouped error from resolver; legacy alias `newton` → `FutureWarning` and resolves to `newton_mjwarp`. --------- Co-authored-by: ooctipus Co-authored-by: Kelly Guo --- scripts/benchmarks/benchmark_non_rl.py | 11 +- scripts/benchmarks/benchmark_rlgames.py | 11 +- scripts/benchmarks/benchmark_rsl_rl.py | 12 +- scripts/benchmarks/benchmark_startup.py | 8 +- scripts/environments/random_agent.py | 15 +- scripts/environments/zero_agent.py | 15 +- .../leapp/rsl_rl/export.py | 11 +- .../reinforcement_learning/rl_games/play.py | 14 +- .../reinforcement_learning/rl_games/train.py | 13 +- scripts/reinforcement_learning/rsl_rl/play.py | 16 +- .../reinforcement_learning/rsl_rl/train.py | 16 +- scripts/reinforcement_learning/sb3/play.py | 14 +- scripts/reinforcement_learning/sb3/train.py | 13 +- scripts/reinforcement_learning/skrl/play.py | 14 +- scripts/reinforcement_learning/skrl/train.py | 13 +- scripts/sim2sim_transfer/rsl_rl_transfer.py | 10 +- .../jichuanh-preset-cli-basic.minor.rst | 19 + .../isaaclab_tasks/utils/__init__.pyi | 3 + .../isaaclab_tasks/utils/hydra.py | 21 +- .../isaaclab_tasks/utils/preset_cli.py | 352 +++++++++++ .../isaaclab_tasks/utils/preset_target.py | 128 ++++ source/isaaclab_tasks/test/test_preset_cli.py | 585 ++++++++++++++++++ 22 files changed, 1226 insertions(+), 88 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/jichuanh-preset-cli-basic.minor.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/utils/preset_target.py create mode 100644 source/isaaclab_tasks/test/test_preset_cli.py diff --git a/scripts/benchmarks/benchmark_non_rl.py b/scripts/benchmarks/benchmark_non_rl.py index 4a4ffc700974..2a887f26c52e 100644 --- a/scripts/benchmarks/benchmark_non_rl.py +++ b/scripts/benchmarks/benchmark_non_rl.py @@ -14,6 +14,8 @@ from isaaclab.app import AppLauncher +from isaaclab_tasks.utils import fold_preset_tokens, setup_preset_cli + # add argparse arguments parser = argparse.ArgumentParser(description="Train an RL agent with RL-Games.") parser.add_argument("--video", action="store_true", default=False, help="Record videos during training.") @@ -46,15 +48,12 @@ # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) -# parse the arguments -args_cli, hydra_args = parser.parse_known_args() -# always enable cameras to record video +args_cli, hydra_args = setup_preset_cli(parser) +hydra_args = fold_preset_tokens(hydra_args) +sys.argv = [sys.argv[0]] + hydra_args if args_cli.video: args_cli.enable_cameras = True -# clear out sys.argv for Hydra -sys.argv = [sys.argv[0]] + hydra_args - sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor diff --git a/scripts/benchmarks/benchmark_rlgames.py b/scripts/benchmarks/benchmark_rlgames.py index 52257f722651..3b3be9fd796c 100644 --- a/scripts/benchmarks/benchmark_rlgames.py +++ b/scripts/benchmarks/benchmark_rlgames.py @@ -14,6 +14,8 @@ from isaaclab.app import AppLauncher +from isaaclab_tasks.utils import fold_preset_tokens, setup_preset_cli + from scripts.benchmarks.early_stop import ( RlGamesEarlyStopObserver, add_success_cli_args, @@ -63,15 +65,12 @@ # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) -# parse the arguments -args_cli, hydra_args = parser.parse_known_args() -# always enable cameras to record video +args_cli, hydra_args = setup_preset_cli(parser) +hydra_args = fold_preset_tokens(hydra_args) +sys.argv = [sys.argv[0]] + hydra_args if args_cli.video: args_cli.enable_cameras = True -# clear out sys.argv for Hydra -sys.argv = [sys.argv[0]] + hydra_args - imports_time_begin = time.perf_counter_ns() import math diff --git a/scripts/benchmarks/benchmark_rsl_rl.py b/scripts/benchmarks/benchmark_rsl_rl.py index 2afb1f74833b..436eac8a74ec 100644 --- a/scripts/benchmarks/benchmark_rsl_rl.py +++ b/scripts/benchmarks/benchmark_rsl_rl.py @@ -14,6 +14,8 @@ from isaaclab.app import AppLauncher +from isaaclab_tasks.utils import fold_preset_tokens, setup_preset_cli + from scripts.benchmarks.early_stop import ( RslRlEarlyStopWrapper, add_success_cli_args, @@ -68,16 +70,12 @@ cli_args.add_rsl_rl_args(parser) # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) -# to ensure kit args don't break the benchmark arg parsing -args_cli, hydra_args = parser.parse_known_args() - -# always enable cameras to record video +args_cli, hydra_args = setup_preset_cli(parser) +hydra_args = fold_preset_tokens(hydra_args) +sys.argv = [sys.argv[0]] + hydra_args if args_cli.video: args_cli.enable_cameras = True -# clear out sys.argv for Hydra -sys.argv = [sys.argv[0]] + hydra_args - imports_time_begin = time.perf_counter_ns() import importlib.metadata as metadata diff --git a/scripts/benchmarks/benchmark_startup.py b/scripts/benchmarks/benchmark_startup.py index e47f6e8c066d..93d92257ca11 100644 --- a/scripts/benchmarks/benchmark_startup.py +++ b/scripts/benchmarks/benchmark_startup.py @@ -19,6 +19,8 @@ from isaaclab.app import AppLauncher +from isaaclab_tasks.utils import fold_preset_tokens, setup_preset_cli + # -- CLI arguments ----------------------------------------------------------- parser = argparse.ArgumentParser(description="Profile IsaacLab startup phases.") @@ -57,10 +59,8 @@ # append AppLauncher cli args (provides --device, --headless, etc.) AppLauncher.add_app_launcher_args(parser) -# parse the arguments -args_cli, hydra_args = parser.parse_known_args() - -# clear out sys.argv for Hydra +args_cli, hydra_args = setup_preset_cli(parser) +hydra_args = fold_preset_tokens(hydra_args) sys.argv = [sys.argv[0]] + hydra_args sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) diff --git a/scripts/environments/random_agent.py b/scripts/environments/random_agent.py index bd6671f00969..904fccc75df4 100644 --- a/scripts/environments/random_agent.py +++ b/scripts/environments/random_agent.py @@ -12,7 +12,13 @@ import torch import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils import add_launcher_args, launch_simulation, resolve_task_config +from isaaclab_tasks.utils import ( + add_launcher_args, + fold_preset_tokens, + launch_simulation, + resolve_task_config, + setup_preset_cli, +) # add argparse arguments parser = argparse.ArgumentParser(description="Random agent for Isaac Lab environments.") @@ -23,11 +29,8 @@ parser.add_argument("--task", type=str, default=None, help="Name of the task.") # append AppLauncher cli args add_launcher_args(parser) -# parse the arguments -args_cli, hydra_args = parser.parse_known_args() - -# pass remaining args to Hydra -sys.argv = [sys.argv[0]] + hydra_args +args_cli, hydra_args = setup_preset_cli(parser) +sys.argv = [sys.argv[0]] + fold_preset_tokens(hydra_args) # PLACEHOLDER: Extension template (do not remove this comment) diff --git a/scripts/environments/zero_agent.py b/scripts/environments/zero_agent.py index 3979a11d7fa8..41c8c4c251e4 100644 --- a/scripts/environments/zero_agent.py +++ b/scripts/environments/zero_agent.py @@ -12,7 +12,13 @@ import torch import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils import add_launcher_args, launch_simulation, resolve_task_config +from isaaclab_tasks.utils import ( + add_launcher_args, + fold_preset_tokens, + launch_simulation, + resolve_task_config, + setup_preset_cli, +) # add argparse arguments parser = argparse.ArgumentParser(description="Zero agent for Isaac Lab environments.") @@ -23,11 +29,8 @@ parser.add_argument("--task", type=str, default=None, help="Name of the task.") # append AppLauncher cli args add_launcher_args(parser) -# parse the arguments -args_cli, hydra_args = parser.parse_known_args() - -# pass remaining args to Hydra -sys.argv = [sys.argv[0]] + hydra_args +args_cli, hydra_args = setup_preset_cli(parser) +sys.argv = [sys.argv[0]] + fold_preset_tokens(hydra_args) # PLACEHOLDER: Extension template (do not remove this comment) MAX_STEPS = 100 diff --git a/scripts/reinforcement_learning/leapp/rsl_rl/export.py b/scripts/reinforcement_learning/leapp/rsl_rl/export.py index 1aa000069da9..86cf5042ca29 100644 --- a/scripts/reinforcement_learning/leapp/rsl_rl/export.py +++ b/scripts/reinforcement_learning/leapp/rsl_rl/export.py @@ -30,6 +30,8 @@ from isaaclab.app import AppLauncher +from isaaclab_tasks.utils import fold_preset_tokens, setup_preset_cli + _RSL_RL_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "rsl_rl" if str(_RSL_RL_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_RSL_RL_SCRIPTS_DIR)) @@ -110,7 +112,10 @@ def create_arg_parser() -> argparse.ArgumentParser: def parse_export_args(argv: list[str] | None = None) -> tuple[argparse.Namespace, list[str]]: """Parse export arguments and return remaining Hydra overrides.""" parser = create_arg_parser() - args_cli, hydra_args = parser.parse_known_args(argv) + # setup_preset_cli attaches the preset-selection help group then parses; + # remainder still carries typed selectors (physics=/renderer=/presets=) + # verbatim for run_export_with_hydra to fold before invoking Hydra. + args_cli, hydra_args = setup_preset_cli(parser, argv) args_cli.headless = True return args_cli, hydra_args @@ -344,7 +349,9 @@ def run_export_with_hydra(args_cli: argparse.Namespace, hydra_args: list[str], s _load_runtime_dependencies() original_argv = sys.argv - sys.argv = [sys.argv[0]] + hydra_args + # Fold typed preset selectors into a single ``presets=`` token before + # Hydra reads sys.argv; remainder still carries plain Hydra path overrides. + sys.argv = [sys.argv[0]] + fold_preset_tokens(hydra_args) exported = False try: diff --git a/scripts/reinforcement_learning/rl_games/play.py b/scripts/reinforcement_learning/rl_games/play.py index ec67df745f99..eb77a86f4d5d 100644 --- a/scripts/reinforcement_learning/rl_games/play.py +++ b/scripts/reinforcement_learning/rl_games/play.py @@ -37,7 +37,14 @@ from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils import add_launcher_args, get_checkpoint_path, launch_simulation, resolve_task_config +from isaaclab_tasks.utils import ( + add_launcher_args, + fold_preset_tokens, + get_checkpoint_path, + launch_simulation, + resolve_task_config, + setup_preset_cli, +) # PLACEHOLDER: Extension template (do not remove this comment) with contextlib.suppress(ImportError): @@ -69,13 +76,12 @@ ) parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") add_launcher_args(parser) -args_cli, hydra_args = parser.parse_known_args() +args_cli, hydra_args = setup_preset_cli(parser) +sys.argv = [sys.argv[0]] + fold_preset_tokens(hydra_args) if args_cli.video: args_cli.enable_cameras = True -sys.argv = [sys.argv[0]] + hydra_args - def main(): """Play with RL-Games agent.""" diff --git a/scripts/reinforcement_learning/rl_games/train.py b/scripts/reinforcement_learning/rl_games/train.py index 983f967e3061..4cc6b8fbe461 100644 --- a/scripts/reinforcement_learning/rl_games/train.py +++ b/scripts/reinforcement_learning/rl_games/train.py @@ -39,7 +39,13 @@ from isaaclab_rl.rl_games import MultiObserver, PbtAlgoObserver, RlGamesGpuEnv, RlGamesVecEnvWrapper import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils import add_launcher_args, launch_simulation, resolve_task_config +from isaaclab_tasks.utils import ( + add_launcher_args, + fold_preset_tokens, + launch_simulation, + resolve_task_config, + setup_preset_cli, +) logger = logging.getLogger(__name__) @@ -80,13 +86,12 @@ "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) add_launcher_args(parser) -args_cli, hydra_args = parser.parse_known_args() +args_cli, hydra_args = setup_preset_cli(parser) +sys.argv = [sys.argv[0]] + fold_preset_tokens(hydra_args) if args_cli.video: args_cli.enable_cameras = True -sys.argv = [sys.argv[0]] + hydra_args - def main(): """Train with RL-Games agent.""" diff --git a/scripts/reinforcement_learning/rsl_rl/play.py b/scripts/reinforcement_learning/rsl_rl/play.py index a1e0ecf5c555..fbb6c5a81e12 100644 --- a/scripts/reinforcement_learning/rsl_rl/play.py +++ b/scripts/reinforcement_learning/rsl_rl/play.py @@ -42,7 +42,13 @@ from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils import add_launcher_args, get_checkpoint_path, launch_simulation +from isaaclab_tasks.utils import ( + add_launcher_args, + fold_preset_tokens, + get_checkpoint_path, + launch_simulation, + setup_preset_cli, +) from isaaclab_tasks.utils.hydra import hydra_task_config # local imports @@ -74,7 +80,7 @@ parser.add_argument("--external_callback", default=None, help="Fully qualified path to an externally defined callback.") cli_args.add_rsl_rl_args(parser) add_launcher_args(parser) -args_cli, remaining_args = parser.parse_known_args() +args_cli, remaining_args = setup_preset_cli(parser) if args_cli.video: args_cli.enable_cameras = True @@ -89,9 +95,11 @@ # clear out sys.argv for Hydra # The remaining arguments are the arguments that were not consumed by both this scripts -# argparser and (optionally) the external callback function. +# argparser and (optionally) the external callback function. Both sides of this +# intersection are pre-fold (the callback reads the user's original sys.argv), so +# preset tokens like ``physics=NAME`` compare correctly here. Fold runs after. remaining_args = list_intersection(remaining_args, remaining_args_env_registration) -sys.argv = [sys.argv[0]] + remaining_args +sys.argv = [sys.argv[0]] + fold_preset_tokens(remaining_args) # Check for installed RSL-RL version installed_version = metadata.version("rsl-rl-lib") diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index dc3cbf6710d5..01a7b5d69638 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -38,7 +38,13 @@ from isaaclab_rl.rsl_rl import RslRlBaseRunnerCfg, RslRlVecEnvWrapper, handle_deprecated_rsl_rl_cfg import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils import add_launcher_args, get_checkpoint_path, launch_simulation +from isaaclab_tasks.utils import ( + add_launcher_args, + fold_preset_tokens, + get_checkpoint_path, + launch_simulation, + setup_preset_cli, +) from isaaclab_tasks.utils.hydra import hydra_task_config # local imports @@ -79,7 +85,7 @@ parser.add_argument("--external_callback", default=None, help="Fully qualified path to an externally defined callback.") cli_args.add_rsl_rl_args(parser) add_launcher_args(parser) -args_cli, remaining_args = parser.parse_known_args() +args_cli, remaining_args = setup_preset_cli(parser) if args_cli.video: args_cli.enable_cameras = True @@ -94,9 +100,11 @@ # clear out sys.argv for Hydra # The remaining arguments are the arguments that were not consumed by both this scripts -# argparser and (optionally) the external callback function. +# argparser and (optionally) the external callback function. Both sides of this +# intersection are pre-fold (the callback reads the user's original sys.argv), so +# preset tokens like ``physics=NAME`` compare correctly here. Fold runs after. remaining_args = list_intersection(remaining_args, remaining_args_env_registration) -sys.argv = [sys.argv[0]] + remaining_args +sys.argv = [sys.argv[0]] + fold_preset_tokens(remaining_args) # -- check RSL-RL version ---------------------------------------------------- installed_version = metadata.version("rsl-rl-lib") diff --git a/scripts/reinforcement_learning/sb3/play.py b/scripts/reinforcement_learning/sb3/play.py index edbe7183a242..bb26180f886d 100644 --- a/scripts/reinforcement_learning/sb3/play.py +++ b/scripts/reinforcement_learning/sb3/play.py @@ -35,7 +35,14 @@ from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils import add_launcher_args, get_checkpoint_path, launch_simulation, resolve_task_config +from isaaclab_tasks.utils import ( + add_launcher_args, + fold_preset_tokens, + get_checkpoint_path, + launch_simulation, + resolve_task_config, + setup_preset_cli, +) # PLACEHOLDER: Extension template (do not remove this comment) with contextlib.suppress(ImportError): @@ -73,13 +80,12 @@ help="Use a slower SB3 wrapper but keep all the extra training info.", ) add_launcher_args(parser) -args_cli, hydra_args = parser.parse_known_args() +args_cli, hydra_args = setup_preset_cli(parser) +sys.argv = [sys.argv[0]] + fold_preset_tokens(hydra_args) if args_cli.video: args_cli.enable_cameras = True -sys.argv = [sys.argv[0]] + hydra_args - def main(): """Play with stable-baselines agent.""" diff --git a/scripts/reinforcement_learning/sb3/train.py b/scripts/reinforcement_learning/sb3/train.py index 6fcc3a9826a9..e7c9f5df5039 100644 --- a/scripts/reinforcement_learning/sb3/train.py +++ b/scripts/reinforcement_learning/sb3/train.py @@ -40,7 +40,13 @@ from isaaclab_rl.sb3 import Sb3VecEnvWrapper, process_sb3_cfg import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils import add_launcher_args, launch_simulation, resolve_task_config +from isaaclab_tasks.utils import ( + add_launcher_args, + fold_preset_tokens, + launch_simulation, + resolve_task_config, + setup_preset_cli, +) logger = logging.getLogger(__name__) @@ -73,13 +79,12 @@ "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) add_launcher_args(parser) -args_cli, hydra_args = parser.parse_known_args() +args_cli, hydra_args = setup_preset_cli(parser) +sys.argv = [sys.argv[0]] + fold_preset_tokens(hydra_args) if args_cli.video: args_cli.enable_cameras = True -sys.argv = [sys.argv[0]] + hydra_args - def cleanup_pbar(*args): """ diff --git a/scripts/reinforcement_learning/skrl/play.py b/scripts/reinforcement_learning/skrl/play.py index 528aae67e32a..bc3bf86af3d3 100644 --- a/scripts/reinforcement_learning/skrl/play.py +++ b/scripts/reinforcement_learning/skrl/play.py @@ -38,7 +38,14 @@ from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils import add_launcher_args, get_checkpoint_path, launch_simulation, resolve_task_config +from isaaclab_tasks.utils import ( + add_launcher_args, + fold_preset_tokens, + get_checkpoint_path, + launch_simulation, + resolve_task_config, + setup_preset_cli, +) # PLACEHOLDER: Extension template (do not remove this comment) with contextlib.suppress(ImportError): @@ -87,13 +94,12 @@ ) parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") add_launcher_args(parser) -args_cli, hydra_args = parser.parse_known_args() +args_cli, hydra_args = setup_preset_cli(parser) +sys.argv = [sys.argv[0]] + fold_preset_tokens(hydra_args) if args_cli.video: args_cli.enable_cameras = True -sys.argv = [sys.argv[0]] + hydra_args - # -- check skrl version ------------------------------------------------------ if version.parse(skrl.__version__) < version.parse(SKRL_VERSION): skrl.logger.error( diff --git a/scripts/reinforcement_learning/skrl/train.py b/scripts/reinforcement_learning/skrl/train.py index 866e36c7ecea..26f0f03a30ac 100644 --- a/scripts/reinforcement_learning/skrl/train.py +++ b/scripts/reinforcement_learning/skrl/train.py @@ -41,7 +41,13 @@ from isaaclab_rl.skrl import SkrlVecEnvWrapper import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils import add_launcher_args, launch_simulation, resolve_task_config +from isaaclab_tasks.utils import ( + add_launcher_args, + fold_preset_tokens, + launch_simulation, + resolve_task_config, + setup_preset_cli, +) logger = logging.getLogger(__name__) @@ -92,13 +98,12 @@ "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) add_launcher_args(parser) -args_cli, hydra_args = parser.parse_known_args() +args_cli, hydra_args = setup_preset_cli(parser) +sys.argv = [sys.argv[0]] + fold_preset_tokens(hydra_args) if args_cli.video: args_cli.enable_cameras = True -sys.argv = [sys.argv[0]] + hydra_args - # -- check skrl version ------------------------------------------------------ if version.parse(skrl.__version__) < version.parse(SKRL_VERSION): skrl.logger.error( diff --git a/scripts/sim2sim_transfer/rsl_rl_transfer.py b/scripts/sim2sim_transfer/rsl_rl_transfer.py index 4de3c42b7a8c..bc5d3a52182b 100644 --- a/scripts/sim2sim_transfer/rsl_rl_transfer.py +++ b/scripts/sim2sim_transfer/rsl_rl_transfer.py @@ -13,6 +13,8 @@ from isaaclab.app import AppLauncher +from isaaclab_tasks.utils import fold_preset_tokens, setup_preset_cli + # local imports sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) from scripts.reinforcement_learning.rsl_rl import cli_args # isort: skip @@ -42,15 +44,11 @@ cli_args.add_rsl_rl_args(parser) # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) -# parse the arguments -args_cli, hydra_args = parser.parse_known_args() -# always enable cameras to record video +args_cli, hydra_args = setup_preset_cli(parser) +sys.argv = [sys.argv[0]] + fold_preset_tokens(hydra_args) if args_cli.video: args_cli.enable_cameras = True -# clear out sys.argv for Hydra -sys.argv = [sys.argv[0]] + hydra_args - # launch omniverse app app_launcher = AppLauncher(args_cli) simulation_app = app_launcher.app diff --git a/source/isaaclab_tasks/changelog.d/jichuanh-preset-cli-basic.minor.rst b/source/isaaclab_tasks/changelog.d/jichuanh-preset-cli-basic.minor.rst new file mode 100644 index 000000000000..1215d4a24299 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/jichuanh-preset-cli-basic.minor.rst @@ -0,0 +1,19 @@ +Added +^^^^^ + +* Added :class:`isaaclab_tasks.utils.preset_target.PresetTarget` -- closed enum + of typed preset categories (``PHYSICS``, ``RENDERER``, ``DOMAIN``). +* Added :func:`isaaclab_tasks.utils.preset_cli.setup_preset_cli` -- a typed + selection layer over the ``presets=`` Hydra-decorator preset flow. + Recognizes three Hydra-style tokens (``physics=NAME``, ``renderer=NAME``, + ``presets=NAME[,...]``) and folds them into the existing token. When + ``--task=X`` is given alongside ``--help``, lists the + :class:`~isaaclab_tasks.utils.hydra.PresetCfg` variants present in the + task's env_cfg, bucketed by typed target. + +Changed +^^^^^^^ + +* Changed :mod:`isaaclab_tasks.utils.hydra` to source legacy preset aliases + from :meth:`~isaaclab_tasks.utils.preset_target.PresetTarget.all_legacy_aliases` + instead of a local literal dict. diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.pyi index 6a0ba23b9429..5dcabafeeb54 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.pyi +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.pyi @@ -16,9 +16,12 @@ __all__ = [ "add_launcher_args", "launch_simulation", "compute_kit_requirements", + "setup_preset_cli", + "fold_preset_tokens", ] from .hydra import PresetCfg, preset, hydra_task_config, resolve_task_config, resolve_presets from .importer import import_packages from .parse_cfg import get_checkpoint_path, load_cfg_from_registry, parse_env_cfg +from .preset_cli import fold_preset_tokens, setup_preset_cli from .sim_launcher import add_launcher_args, launch_simulation, compute_kit_requirements diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py index 320632705852..8e00b112f058 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py @@ -36,14 +36,9 @@ from isaaclab.envs.utils.spaces import replace_env_cfg_spaces_with_strings, replace_strings_with_env_cfg_spaces from isaaclab.utils import configclass, replace_slices_with_strings, replace_strings_with_slices -_LITERAL_MAP = {"true": True, "false": False, "none": None, "null": None} +from .preset_target import PresetTarget -# Map of deprecated preset name -> current name. Newton-backend solver presets are -# now prefixed with ``newton_`` so they group together in autocomplete and read -# distinctly from backend/package/visualizer names that also use the word -# ``newton``. Aliases keep legacy CLI invocations and ``PresetCfg`` field accesses -# working with a :class:`FutureWarning`; they will be removed in a future release. -_LEGACY_PRESET_ALIASES = {"newton": "newton_mjwarp", "kamino": "newton_kamino"} +_LITERAL_MAP = {"true": True, "false": False, "none": None, "null": None} def _user_stacklevel() -> int: @@ -83,7 +78,7 @@ def _normalize_preset_name(name: str, known_names: set[str]) -> str: * ``name`` is itself a real field in ``known_names`` (a user-defined preset legitimately reusing the deprecated spelling shadows the alias). """ - replacement = _LEGACY_PRESET_ALIASES.get(name) + replacement = PresetTarget.all_legacy_aliases().get(name) if replacement is None or replacement not in known_names or name in known_names: return name warnings.warn( @@ -124,7 +119,7 @@ def __getattr__(self, name: str): real field on the subclass, so a user redefining the deprecated name shadows the alias. """ - replacement = _LEGACY_PRESET_ALIASES.get(name) + replacement = PresetTarget.all_legacy_aliases().get(name) fields = getattr(type(self), "__dataclass_fields__", {}) if replacement is not None and replacement in fields and name not in fields: warnings.warn( @@ -407,9 +402,9 @@ def _format_unknown_presets_error(unknown: set[str], name_to_paths: dict[str, li fingerprint_to_names.setdefault(key, []).append(name) lines = [f"Unknown preset(s): {', '.join(sorted(unknown))}"] - deprecated_hits = sorted(name for name in unknown if name in _LEGACY_PRESET_ALIASES) + deprecated_hits = sorted(name for name in unknown if name in PresetTarget.all_legacy_aliases()) for legacy in deprecated_hits: - replacement = _LEGACY_PRESET_ALIASES[legacy] + replacement = PresetTarget.all_legacy_aliases()[legacy] lines.append(f" '{legacy}' was renamed to '{replacement}'; this task does not declare '{replacement}' either.") lines += [ "", @@ -583,8 +578,8 @@ def _path_reachable(sec: str, path: str) -> bool: if name not in presets[sec][path]: avail = list(presets[sec][path].keys()) hint = "" - if name in _LEGACY_PRESET_ALIASES: - replacement = _LEGACY_PRESET_ALIASES[name] + if name in PresetTarget.all_legacy_aliases(): + replacement = PresetTarget.all_legacy_aliases()[name] hint = f" '{name}' was renamed to '{replacement}'; this path does not declare '{replacement}' either." raise ValueError(f"Unknown preset '{name}' for {sec}.{path}. Available: {avail}.{hint}") full_path = f"{sec}.{path}" if path else sec diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py b/source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py new file mode 100644 index 000000000000..42fe5f4f16e3 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py @@ -0,0 +1,352 @@ +# 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 + +"""Typed-preset selection via Hydra-style CLI tokens. + +Recognizes three ``key=value`` tokens (no leading dashes) on ``sys.argv``: + +* ``physics=NAME`` -- typed selector for ``PhysicsCfg`` variants. +* ``renderer=NAME`` -- typed selector for ``RendererCfg`` variants. +* ``presets=NAME[,NAME,...]`` -- broadcast applied to every matching ``PresetCfg``. + +Two responsibilities, split across two functions: + +* :func:`setup_preset_cli` -- register the preset-selection help description on + the parser and run ``parse_known_args``. Returns the raw pre-fold remainder. +* :func:`fold_preset_tokens` -- rewrite typed selectors and any free-form + ``presets=...`` tokens into a single ``presets=`` token that hydra's + :func:`~isaaclab_tasks.utils.hydra.resolve_presets` consumes. The resolver, + alias rewriting, and unknown-name errors are unchanged. + +Splitting the fold out lets callers intersect the pre-fold remainder with +external sources (e.g. ``rsl_rl`` scripts' ``--external_callback`` hook, which +reads the user's unmutated ``sys.argv`` and returns pre-fold tokens) in the +same vocabulary. The fold runs exactly once, at the caller's final +``sys.argv`` assignment. + +No argparse arguments are registered for the typed selectors -- discoverability +lives in the ``argument_group`` description, so the parsed Namespace gains no +preset attributes and cannot shadow :class:`~isaaclab.app.AppLauncher` +SimulationApp config keys (``renderer`` notably). + +Typical script setup:: + + parser = argparse.ArgumentParser(...) + # ... script-specific args ... + add_launcher_args(parser) + args_cli, remaining = setup_preset_cli(parser) + sys.argv = [sys.argv[0]] + fold_preset_tokens(remaining) + +Scripts that need to intersect the remainder with external-callback output do +the intersection first (both sides pre-fold, vocabulary matches), then fold:: + + args_cli, remaining = setup_preset_cli(parser) + if args_cli.external_callback: + cb_remainder = external_callback_function() + remaining = list_intersection(remaining, cb_remainder) + sys.argv = [sys.argv[0]] + fold_preset_tokens(remaining) + +``setup_preset_cli`` does NOT add AppLauncher flags itself -- callers add them +explicitly via :func:`isaaclab_tasks.utils.add_launcher_args` before calling. +""" + +from __future__ import annotations + +import argparse +import sys + +from .preset_target import PresetTarget + +# ============================================================================ +# Public entry point +# ============================================================================ + + +def setup_preset_cli( + parser: argparse.ArgumentParser, argv: list[str] | None = None +) -> tuple[argparse.Namespace, list[str]]: + """Register the preset-selection help description and parse argv. + + Must be called *after* AppLauncher flags and script-specific arguments are + registered on ``parser`` -- otherwise those unknown tokens land in + ``parse_known_args``'s remainder. + + Does NOT fold typed selectors. The returned remainder still contains the + user-typed ``physics=`` / ``renderer=`` / ``presets=`` tokens verbatim, + alongside any Hydra path overrides and any unknown argparse flags. Call + :func:`fold_preset_tokens` on the remainder before assigning ``sys.argv``; + keeping parse and fold separate lets callers run other filters (notably + ``rsl_rl``'s ``--external_callback`` intersection) on the pre-fold list, + where vocabularies match. + + Does not mutate ``sys.argv``; the caller assigns + ``sys.argv = [sys.argv[0]] + fold_preset_tokens(remaining)`` when ready, so + any argv-aware logic that re-reads ``sys.argv`` (e.g. an external callback) + runs against the user's original command line. + + Args: + parser: Caller's argument parser. An ``argument_group`` is attached + for help-time variant discovery; no ``add_argument`` calls are + made, so the Namespace gains no preset attributes. + argv: Optional argument list to parse. When ``None`` (default), + ``parse_known_args`` reads from ``sys.argv``. Provided primarily + for in-process test paths that drive the parser with a synthetic + argv. Help-time variant enumeration always reads ``sys.argv`` -- + the user's interactive command line is the only argv that + triggers ``--help`` rendering. + + Returns: + ``(args, remaining)`` where ``remaining`` is the verbatim output of + ``parser.parse_known_args(argv)``. Apply :func:`fold_preset_tokens` + to ``remaining`` before handing it to Hydra. + """ + # --help short-circuits parsing, so help text that depends on --task has to + # find it before argparse runs. Gate the env_cfg load on --help to keep + # normal training runs cheap. + argv_helper = _ArgvHelper(sys.argv) + actual_variants = ( + _enumerate_variants(argv_helper.task_name) if (argv_helper.task_name and argv_helper.help_requested) else None + ) + + # Argparse's default HelpFormatter reflows description text into one wrapped + # paragraph, which would collapse the per-variant bullets we emit. Use a + # formatter that wraps each blank-line-separated paragraph independently + # while preserving explicit newlines. Respect a caller-set custom formatter. + if parser.formatter_class is argparse.HelpFormatter: + parser.formatter_class = _PresetHelpFormatter + + # Help-only group: no add_argument() calls means no preset attributes on + # the Namespace, so AppLauncher can't accidentally forward one (notably + # ``renderer``) into SimulationApp config. + parser.add_argument_group("preset selection", description=_DescriptionBuilder.build(actual_variants)) + + return parser.parse_known_args(argv) + + +def fold_preset_tokens(tokens: list[str]) -> list[str]: + """Fold preset selector tokens into a single ``presets=`` token. + + Recognises ``physics=NAME`` / ``renderer=NAME`` / ``presets=NAME[,NAME,...]`` + in *tokens* (exact key match; dotted keys like ``env.sim.physics=NAME`` are + path-targeted overrides and pass through unchanged). All recognised names + are deduped in first-occurrence order and emitted as a leading + ``presets=`` token; every other token in *tokens* is appended in its + original position. + + Call this on the remainder returned by :func:`setup_preset_cli` before + assigning ``sys.argv``. Scripts that intersect the remainder with + callback-returned tokens (e.g. ``rsl_rl/{train,play}.py``'s + ``--external_callback`` flow) must do the intersection *first* (both sides + pre-fold) and then call this function. + + Args: + tokens: Pre-fold token list (typically the second element of the + tuple returned by :func:`setup_preset_cli`). + + Returns: + A new list with selector tokens folded into one leading + ``presets=`` token if any were present; otherwise the input list + is returned unchanged. + """ + typed_labels = {t.value for t in PresetTarget if t.base_classes} + names: list[str] = [] + kept: list[str] = [] + for token in tokens: + if "=" not in token: + kept.append(token) + continue + key, val = token.split("=", 1) + if key in typed_labels: + # Typed selector value is a single name; commas are reserved for ``presets=`` broadcast. + stripped = val.strip() + if stripped: + names.append(stripped) + elif key == PresetTarget.DOMAIN.value: + names.extend(name.strip() for name in val.split(",") if name.strip()) + else: + kept.append(token) + + if not names: + return list(kept) + + # Dedupe, preserve first-occurrence order. + seen: set[str] = set() + deduped = [name for name in names if not (name in seen or seen.add(name))] + return [f"presets={','.join(deduped)}", *kept] + + +# ============================================================================ +# Help-text rendering +# ============================================================================ + + +class _PresetHelpFormatter(argparse.HelpFormatter): + """Argparse help formatter that wraps each paragraph separately. + + Default :class:`argparse.HelpFormatter` reflows the entire description into + one paragraph, merging the variant listing into the surrounding prose, and + collapses ``\\n``-separated bullets onto one line. + :class:`~argparse.RawDescriptionHelpFormatter` preserves description + newlines but drops wrapping entirely. The ``_fill_text`` override below + splits the description on blank lines and wraps each paragraph indep- + endently, giving both readable paragraphs and per-line bullets. + """ + + def _fill_text(self, text: str, width: int, indent: str) -> str: + import textwrap + + paragraphs = text.split("\n\n") + rendered: list[str] = [] + for paragraph in paragraphs: + # A paragraph that already contains hard newlines (the bulleted + # variant listing) is rendered verbatim; otherwise word-wrap. + if "\n" in paragraph: + rendered.append("\n".join(f"{indent}{line}" for line in paragraph.splitlines())) + else: + rendered.append(textwrap.fill(paragraph, width, initial_indent=indent, subsequent_indent=indent)) + return "\n\n".join(rendered) + + +class _DescriptionBuilder: + """Renders the preset-selection ``argument_group`` description. + + Groups the column constants and per-row formatting that build the + selector table. Iterates :class:`PresetTarget` to produce one row per + selector; each row's syntax and description come from the enum, so + adding a new typed target needs no changes here. + """ + + # Column widths. ``SELECTOR_COL`` = width of the longest selector syntax + # (``presets=NAME[,NAME,...]`` = 23 chars); shorter selectors right-pad + # to this width. ``DESC_GAP`` is the gap between syntax and description. + SELECTOR_COL = 23 + DESC_GAP = 3 + ROW_PREFIX = " " + + INTRO = "Select named PresetCfg alternatives via Hydra-style overrides (key=value, no leading dashes):" + EPILOG = "Hydra also accepts path-targeted overrides like env.sim.physics=NAME." + HINT = "Pass `--task=X` along with `--help` to see preset variants available for that task." + + @classmethod + def build(cls, actual_variants: dict[PresetTarget, set[str]] | None) -> str: + """Build the description text. + + Args: + actual_variants: ``None`` when no ``--task=X --help`` is in argv; + otherwise a ``{target: set[name]}`` bucketed view from + :func:`_enumerate_variants`. + """ + with_available = actual_variants is not None + rows = [ + cls._row(t, with_available=with_available, variants=sorted((actual_variants or {}).get(t, set()))) + for t in PresetTarget + ] + middle = f"{cls.HINT}\n\n" if not with_available else "" + return f"{cls.INTRO}\n" + "\n".join(rows) + f"\n\n{middle}{cls.EPILOG}" + + @classmethod + def _row(cls, target: PresetTarget, *, with_available: bool, variants: list[str]) -> str: + syntax = cls._syntax(target).ljust(cls.SELECTOR_COL) + desc = cls._description(target) + suffix = ". Available:" if with_available else "" + header = f"{cls.ROW_PREFIX}{syntax}{' ' * cls.DESC_GAP}{desc}{suffix}" + if not with_available: + return header + # Bullet indent aligns with the description column once argparse + # prepends its 2-space group-description indent. + bullet_indent = " " * (len(cls.ROW_PREFIX) + cls.SELECTOR_COL + cls.DESC_GAP) + body = "\n".join(f"{bullet_indent}- {n}" for n in variants) if variants else f"{bullet_indent}(none)" + return f"{header}\n{body}" + + @staticmethod + def _syntax(target: PresetTarget) -> str: + """User-facing selector form: ``physics=NAME`` vs ``presets=NAME[,NAME,...]``.""" + if target.base_classes: # typed: single name + return f"{target.value}=NAME" + return f"{target.value}=NAME[,NAME,...]" # DOMAIN: comma-separated broadcast + + @staticmethod + def _description(target: PresetTarget) -> str: + """One-line description; for typed targets includes the cfg base class name.""" + if target.base_classes: + return f"(typed) selects a {target.base_classes[0].__name__} variant" + return "broadcast: applied to every matching PresetCfg" + + +# ============================================================================ +# argv inspection (pre-argparse peek for help-text rendering) +# ============================================================================ + + +class _ArgvHelper: + """Single-pass argv scan that exposes ``task_name`` and ``help_requested``. + + Needed because argparse's ``--help`` short-circuits parsing, so help text + that depends on ``--task`` has to find it before argparse runs. + + Attributes: + task_name: Last ``--task`` value (matching argparse's last-wins + semantics), or ``None`` if absent. + help_requested: ``True`` if ``--help`` or ``-h`` is present. + """ + + def __init__(self, argv: list[str]): + self.task_name: str | None = None + self.help_requested: bool = False + for i in range(1, len(argv)): + token = argv[i] + if token in ("--help", "-h"): + self.help_requested = True + elif token == "--task" and i + 1 < len(argv): + self.task_name = argv[i + 1] + elif token.startswith("--task="): + self.task_name = token[len("--task=") :] + + +# ============================================================================ +# Help-time variant enumeration (load env_cfg, walk, bucket by target) +# ============================================================================ + + +def _enumerate_variants(task_name: str) -> dict[PresetTarget, set[str]]: + """Load env_cfg for *task_name* and bucket its variants by target. + + Uses the same walker hydra's resolver runs so help and resolve see one + view of the cfg tree. The env_cfg load is safe before AppLauncher boots + because ``test_env_cfg_no_forbidden_imports`` blocks Kit-only imports at + the top level of cfg modules. Exceptions from the loader propagate + verbatim -- they surface as the natural error, not a buried help string. + """ + from isaaclab_tasks.utils.hydra import collect_presets + from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry + + env_cfg = load_cfg_from_registry(task_name, "env_cfg_entry_point") + return _bucket_variants_by_target(collect_presets(env_cfg)) + + +def _bucket_variants_by_target(walked: dict) -> dict[PresetTarget, set[str]]: + """Convert :func:`collect_presets` output into ``{target: set[name]}``. + + Routes each ``(name, cfg)`` by ``isinstance(cfg, target.base_classes)``; + cfgs matching no typed target fall into ``DOMAIN``. The implicit + ``default`` field is filtered -- it's the fallback, not a selectable name. + + Routing by class hierarchy means new backends subclassing + :class:`~isaaclab.physics.PhysicsCfg` / + :class:`~isaaclab.renderers.renderer_cfg.RendererCfg` bucket automatically + regardless of what name the env_cfg gives the field. + """ + typed_targets = [t for t in PresetTarget if t.base_classes] + result: dict[PresetTarget, set[str]] = {target: set() for target in PresetTarget} + for path_dict in walked.values(): + for name, cfg in path_dict.items(): + if name == "default": + continue + matched = next( + (t for t in typed_targets if isinstance(cfg, t.base_classes)), + PresetTarget.DOMAIN, + ) + result[matched].add(name) + return result diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/preset_target.py b/source/isaaclab_tasks/isaaclab_tasks/utils/preset_target.py new file mode 100644 index 000000000000..89878541cf2e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/preset_target.py @@ -0,0 +1,128 @@ +# 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 + +"""Closed enum of typed preset categories with per-target metadata. + +Each :class:`PresetTarget` member carries everything the preset CLI layer +needs to know about that category in one place: + +* ``label`` -- the Hydra-style selector key (e.g. ``"physics"`` for + ``physics=NAME``) and ``self.value``. +* ``base_classes`` -- the cfg base classes whose subclass instances belong to + this bucket. Help-time bucketing in :mod:`isaaclab_tasks.utils.preset_cli` + routes variants by ``isinstance`` against these. Empty for + :attr:`PresetTarget.DOMAIN`, which is the catch-all whose membership is + "no typed target matched". +* ``legacy_aliases`` -- deprecated-name to canonical-name table for this + target, aggregated for hydra's resolver via :meth:`all_legacy_aliases`. + +Adding a new typed target = appending one enum member with its label, base +classes, and (optional) legacy alias map. The CLI layer needs no other wiring. +""" + +from __future__ import annotations + +import enum +import functools + +from isaaclab.physics import PhysicsCfg +from isaaclab.renderers.renderer_cfg import RendererCfg + + +class PresetTarget(enum.Enum): + """Typed preset categories. + + **Bucketing contract.** Help-time bucketing in + :mod:`isaaclab_tasks.utils.preset_cli` routes each preset variant to a + typed target by checking ``isinstance(cfg_value, target.base_classes)`` + against every typed target's bases. A variant whose cfg value does *not* + subclass any typed target's base falls into :attr:`DOMAIN` and shows up + under the ``presets:`` catch-all in ``--help``. + + To opt into the typed ``physics`` / ``renderer`` help-text listing, + a backend's cfg class must subclass :class:`~isaaclab.physics.PhysicsCfg` + or :class:`~isaaclab.renderers.renderer_cfg.RendererCfg` respectively. + A variant whose class does *not* subclass either base still **resolves + correctly at runtime** -- hydra applies the selected name across every + matching ``PresetCfg`` field regardless of class; the typed bucketing only + governs which header it appears under in ``--help``. + + Adding a new target = appending one enum member. + """ + + # Members. Tuple values are (label, base_classes, legacy_aliases); the + # enum metaclass collects the whole namespace before constructing members, + # so ``__new__`` below unpacks each tuple regardless of declaration order. + PHYSICS = ("physics", (PhysicsCfg,), {"newton": "newton_mjwarp", "kamino": "newton_kamino"}) + """Physics backends -- ``physics=NAME`` selector. + + Legacy aliases ``newton`` -> ``newton_mjwarp`` and ``kamino`` -> ``newton_kamino`` + exist because Newton-backend solver presets were renamed to use the + ``newton_`` prefix so they group together in autocomplete and read + distinctly from backend / package / visualizer names that also contain the + word ``newton``. Hydra's resolver (see + :func:`~isaaclab_tasks.utils.hydra._normalize_preset_name`) consults these + and emits a :class:`FutureWarning`; the aliases will be removed in a + future release. + """ + + RENDERER = ("renderer", (RendererCfg,)) + """Camera-sensor renderers -- ``renderer=NAME`` selector.""" + + DOMAIN = ("presets",) + """Free-form env-specific presets -- ``presets=NAME[,...]`` selector (catch-all). + + No ``base_classes`` -- any variant whose cfg class doesn't subclass a typed + target's base ends up here. The ``presets=`` token also acts as a + broadcast: hydra's resolver applies a DOMAIN-bucketed name to every + matching ``PresetCfg`` regardless of target. ``self.value`` matches the + CLI selector key (``"presets"``) so the CLI layer can dispatch by + enum value without a hardcoded constant. + """ + + def __new__( + cls, + label: str, + base_classes: tuple[type, ...] = (), + legacy_aliases: dict[str, str] | None = None, + ): + """Construct a member from its ``(label, base_classes, legacy_aliases)`` tuple. + + Args: + label: Hydra-style selector key (e.g. ``"physics"`` is recognized + as the ``physics=NAME`` token and becomes ``self.value``). + base_classes: Cfg base classes whose instances route to this + target via :func:`isinstance`. Defaults to ``()`` (no typed + routing). + legacy_aliases: Optional deprecated-to-canonical map for this + target; copied so members cannot alias each other's tables. + + Returns: + A new enum member with ``_value_`` set to *label*, plus + ``base_classes`` and ``legacy_aliases`` attributes. + """ + obj = object.__new__(cls) + obj._value_ = label + obj.base_classes = tuple(base_classes) + obj.legacy_aliases = dict(legacy_aliases) if legacy_aliases else {} + return obj + + @classmethod + @functools.cache + def all_legacy_aliases(cls) -> dict[str, str]: + """Flat ``{deprecated: canonical}`` view across every target. + + Resolver-layer code (in :mod:`isaaclab_tasks.utils.hydra`) needs a + target-agnostic lookup -- the ``presets=...`` token is target-agnostic + on the wire. Cached because per-member tables are immutable after + class construction, so the merged view never changes; this keeps + each lookup O(1) instead of rebuilding on every membership test or + ``[]`` access. Callers must not mutate the returned dict. + + Returns: + Mapping of every legacy alias to its canonical replacement, + aggregated across all members. + """ + return {name: rep for target in cls for name, rep in target.legacy_aliases.items()} diff --git a/source/isaaclab_tasks/test/test_preset_cli.py b/source/isaaclab_tasks/test/test_preset_cli.py new file mode 100644 index 000000000000..d6670289a008 --- /dev/null +++ b/source/isaaclab_tasks/test/test_preset_cli.py @@ -0,0 +1,585 @@ +# 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 + +"""Tests for the typed-preset CLI translator. + +Two functions cover the translator: + +* :func:`setup_preset_cli` -- register help description and parse argv. + Returns the raw pre-fold remainder; no folding happens inside. +* :func:`fold_preset_tokens` -- fold typed selectors (``physics=``, + ``renderer=``) and free-form ``presets=`` into a single + ``presets=`` token consumed by Hydra's resolver. + +Splitting parse from fold lets callers (notably ``rsl_rl/{train,play}.py``) +intersect the pre-fold remainder with an ``--external_callback`` return list +in matching vocabulary before folding once at the end. Tests below cover both +functions individually plus the bug-fix scenario they were split for. + +Name validation, alias rewriting, and resolution all live in +:mod:`isaaclab_tasks.utils.hydra` and have their own tests in +``test_hydra.py``; this file does not re-cover them. +""" + +from __future__ import annotations + +import argparse +import sys + +import pytest + + +def _make_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="train.py", add_help=False) + parser.add_argument("--task", type=str, default=None) + return parser + + +# --------------------------------------------------------------------------- +# PresetTarget: per-target metadata on the enum +# --------------------------------------------------------------------------- + + +def test_all_legacy_aliases_aggregates_per_target_tables(): + from isaaclab_tasks.utils.preset_target import PresetTarget + + flat = PresetTarget.all_legacy_aliases() + assert flat["newton"] == "newton_mjwarp" + assert flat["kamino"] == "newton_kamino" + + +def test_preset_target_carries_base_classes(): + """Typed targets carry the cfg base classes whose subclass instances + should bucket to them. DOMAIN carries no base classes (it's the + catch-all).""" + from isaaclab.physics import PhysicsCfg + from isaaclab.renderers.renderer_cfg import RendererCfg + + from isaaclab_tasks.utils.preset_target import PresetTarget + + assert PresetTarget.PHYSICS.base_classes == (PhysicsCfg,) + assert PresetTarget.RENDERER.base_classes == (RendererCfg,) + assert PresetTarget.DOMAIN.base_classes == () + + +# --------------------------------------------------------------------------- +# setup_preset_cli: parse-only, returns the pre-fold remainder verbatim +# --------------------------------------------------------------------------- + + +def test_setup_preset_cli_returns_remainder_only(monkeypatch): + """Without any preset tokens, the remainder is just the un-touched + non-argparse tokens (Hydra path overrides, etc.).""" + original = ["train.py", "--task=Foo-v0", "env.sim.dt=0.001"] + monkeypatch.setattr("sys.argv", original) + from isaaclab_tasks.utils.preset_cli import setup_preset_cli + + args, remaining = setup_preset_cli(_make_parser()) + assert args.task == "Foo-v0" + assert remaining == ["env.sim.dt=0.001"] + # setup_preset_cli must NOT mutate sys.argv -- the caller controls when to assign. + assert sys.argv == original + + +def test_setup_preset_cli_passes_typed_tokens_verbatim(monkeypatch): + """``setup_preset_cli`` no longer folds; preset tokens come back in + their original ``physics=`` / ``renderer=`` / ``presets=`` form so callers + can intersect with callback returns in matching vocabulary before folding.""" + monkeypatch.setattr( + "sys.argv", + [ + "train.py", + "--task=Foo-v0", + "physics=newton_mjwarp", + "renderer=newton_renderer", + "presets=albedo,depth", + "env.sim.dt=0.001", + ], + ) + from isaaclab_tasks.utils.preset_cli import setup_preset_cli + + _, remaining = setup_preset_cli(_make_parser()) + assert remaining == [ + "physics=newton_mjwarp", + "renderer=newton_renderer", + "presets=albedo,depth", + "env.sim.dt=0.001", + ] + + +def test_setup_preset_cli_does_not_mutate_sys_argv(monkeypatch): + """``setup_preset_cli`` must not mutate ``sys.argv`` -- mutation is the + caller's responsibility. Locks the contract that ``rsl_rl/{train,play}.py`` + rely on so an ``--external_callback`` hook invoked after ``setup_preset_cli`` + can still read the user's original command line and return pre-fold tokens + that the caller intersects against the pre-fold remainder.""" + original = ["train.py", "--task=Foo-v0", "physics=newton_mjwarp", "env.sim.dt=0.001"] + monkeypatch.setattr("sys.argv", original) + from isaaclab_tasks.utils.preset_cli import setup_preset_cli + + _, remaining = setup_preset_cli(_make_parser()) + assert sys.argv == original + # Remainder is pre-fold (typed selector unchanged). + assert remaining == ["physics=newton_mjwarp", "env.sim.dt=0.001"] + + +def test_setup_preset_cli_namespace_carries_no_preset_attributes(monkeypatch): + """Preset tokens are never registered with argparse, so the parsed + Namespace gains no ``physics`` / ``renderer`` / ``presets`` attribute. + + This is the bug-class-level guarantee against AppLauncher's name-based + forwarding (``set(_SIM_APP_CFG_TYPES) & set(vars(args))``, + ``app_launcher.py:681``): an attribute that doesn't exist can't collide. + """ + monkeypatch.setattr( + "sys.argv", + ["train.py", "--task=Foo-v0", "physics=newton_mjwarp", "renderer=newton_renderer", "presets=albedo"], + ) + from isaaclab_tasks.utils.preset_cli import setup_preset_cli + + args, _ = setup_preset_cli(_make_parser()) + for attr in ("physics", "renderer", "presets"): + assert not hasattr(args, attr), ( + f"setup_preset_cli wrote ``args.{attr}`` to the namespace -- AppLauncher's name-based" + " forwarding can then push it into SimulationApp config. Drop the argparse registration" + " for preset selectors and use Hydra-style tokens instead." + ) + + +def test_setup_preset_cli_does_not_leak_into_app_launcher_sim_app_intersection(monkeypatch): + """Mirrors the literal intersection :class:`~isaaclab.app.AppLauncher` + computes (``set(_SIM_APP_CFG_TYPES) & set(vars(args))``, + ``app_launcher.py:681``). After ``setup_preset_cli`` runs with all three + preset selectors, no preset name can be in that intersection -- the only + keys present are those AppLauncher itself registered on the parser + (``headless``, ``experience``, ...). + """ + monkeypatch.setattr( + "sys.argv", + ["train.py", "--task=Foo-v0", "physics=newton_mjwarp", "renderer=newton_renderer", "presets=albedo"], + ) + from isaaclab.app import AppLauncher + + from isaaclab_tasks.utils.preset_cli import setup_preset_cli + from isaaclab_tasks.utils.preset_target import PresetTarget + + args, _ = setup_preset_cli(_make_parser()) + intersection = set(AppLauncher._SIM_APP_CFG_TYPES.keys()) & set(vars(args).keys()) + leaked = {t.value for t in PresetTarget} & intersection + assert not leaked, ( + f"setup_preset_cli leaked preset value(s) {sorted(leaked)} into the AppLauncher" + " SimulationApp forwarding set -- they would land in SimulationApp.config and crash" + " Kit (``None.lower()`` for ``renderer``). The hydra-style grammar keeps the namespace" + " clean of preset attributes; this test guards against accidentally re-introducing them." + ) + + +# --------------------------------------------------------------------------- +# fold_preset_tokens: typed + broadcast tokens fold into one presets= token +# --------------------------------------------------------------------------- + + +def test_fold_returns_empty_input_unchanged(): + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + assert fold_preset_tokens([]) == [] + + +def test_fold_no_preset_tokens_returns_input_unchanged(): + """Path-targeted overrides and unknown ``--flag``s pass through verbatim.""" + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + assert fold_preset_tokens(["env.sim.dt=0.001"]) == ["env.sim.dt=0.001"] + assert fold_preset_tokens(["--my_flag=42", "agent.lr=3e-4"]) == ["--my_flag=42", "agent.lr=3e-4"] + + +def test_fold_physics_token_to_presets_token(): + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + assert fold_preset_tokens(["physics=newton_mjwarp", "env.sim.dt=0.001"]) == [ + "presets=newton_mjwarp", + "env.sim.dt=0.001", + ] + + +def test_fold_three_selectors_merge_into_one_token(): + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + assert fold_preset_tokens( + [ + "physics=newton_mjwarp", + "renderer=newton_renderer", + "presets=albedo,depth", + ] + ) == ["presets=newton_mjwarp,newton_renderer,albedo,depth"] + + +def test_fold_dedupes_repeated_names(): + """A name appearing in both a typed selector and the broadcast list + survives once in the folded token.""" + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + assert fold_preset_tokens(["physics=newton_mjwarp", "presets=newton_mjwarp,albedo"]) == [ + "presets=newton_mjwarp,albedo" + ] + + +def test_fold_path_targeted_overrides_pass_through(): + """``env.sim.physics=NAME`` is a Hydra path-targeted override (dotted key) + not a typed preset selector (bare ``physics``); it must pass through the + fold untouched and reach the resolver in its original form.""" + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + assert fold_preset_tokens(["physics=newton_mjwarp", "env.sim.physics=newton_mjwarp", "env.lr=3e-4"]) == [ + "presets=newton_mjwarp", + "env.sim.physics=newton_mjwarp", + "env.lr=3e-4", + ] + + +def test_fold_unknown_argparse_flag_passes_through(): + """Anything starting with ``--`` is not a preset token; the fold leaves + callback-owned flags in place so the caller's intersection step can drop + them via the callback's claim.""" + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + assert fold_preset_tokens(["--my_callback_flag=42", "physics=newton_mjwarp"]) == [ + "presets=newton_mjwarp", + "--my_callback_flag=42", + ] + + +def test_fold_unknown_name_passes_through_silently(capsys): + """A name unknown to the registry is passed through verbatim with no + warning. The resolver has the loaded task's full vocabulary and produces + the rich error at resolve time if the name truly doesn't exist.""" + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + assert fold_preset_tokens(["physics=newton_mujoco"]) == ["presets=newton_mujoco"] + assert capsys.readouterr().err == "" + + +def test_fold_custom_task_preset_via_broadcast_passes_through(capsys): + """A task-local custom preset name (e.g. Dexsuite's ``cube``) is accepted + via the broadcast selector with no fuss -- the registry is a hint, not a gate.""" + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + assert fold_preset_tokens(["presets=cube,peg_insert_4mm,mayank_solver"]) == [ + "presets=cube,peg_insert_4mm,mayank_solver" + ] + assert capsys.readouterr().err == "" + + +def test_fold_keeps_relative_order_of_non_preset_tokens(): + """Non-preset tokens retain their relative order; the folded + ``presets=`` token is prepended.""" + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + assert fold_preset_tokens(["env.a=1", "physics=newton_mjwarp", "env.b=2", "env.c=3"]) == [ + "presets=newton_mjwarp", + "env.a=1", + "env.b=2", + "env.c=3", + ] + + +def test_fold_drops_empty_typed_value(): + """An empty typed-selector value (``physics=``) is skipped, not folded + as an empty name.""" + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + assert fold_preset_tokens(["physics=", "env.sim.dt=0.001"]) == ["env.sim.dt=0.001"] + + +# --------------------------------------------------------------------------- +# Bug fix regression: intersection-then-fold preserves typed preset selections +# +# Reproduces the rsl_rl/{train,play}.py + --external_callback failure mode +# (PR #5587 review): a callback that reads the user's pre-fold sys.argv and +# returns pre-fold tokens must be intersected before folding so vocabularies +# match. Folding first would put ``presets=NAME`` on one side and +# ``physics=NAME`` on the other, dropping the preset by string mismatch. +# --------------------------------------------------------------------------- + + +def test_intersection_then_fold_preserves_typed_selection(): + """The bug-fix order: list_intersection on pre-fold tokens, then fold once. + + Models the rsl_rl callback path. With this order, a typed selector + (``physics=newton_mjwarp``) appearing in both the main remainder and the + callback's pre-fold return survives the intersection and folds correctly. + """ + from isaaclab.utils.string import list_intersection + + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + main_remainder_pre_fold = [ + "physics=newton_mjwarp", + "--my_callback_flag=42", # main parser doesn't know this; callback owns it + "env.lr=3e-4", + ] + # Callback reads (untouched) sys.argv, consumes its --my_callback_flag, returns the rest. + callback_remainder_pre_fold = ["physics=newton_mjwarp", "env.lr=3e-4"] + + intersected = list_intersection(main_remainder_pre_fold, callback_remainder_pre_fold) + folded = fold_preset_tokens(intersected) + + # Preset selection survives; callback-owned flag is correctly dropped. + assert folded == ["presets=newton_mjwarp", "env.lr=3e-4"] + + +def test_fold_then_intersection_would_lose_typed_selection(): + """Document the wrong order. If the caller folded first and intersected + second, the post-fold ``presets=newton_mjwarp`` would not match the + callback's pre-fold ``physics=newton_mjwarp`` and the preset would be + silently dropped. This test pins the bug shape so a future caller doesn't + accidentally re-introduce it. + """ + from isaaclab.utils.string import list_intersection + + from isaaclab_tasks.utils.preset_cli import fold_preset_tokens + + main_remainder_pre_fold = ["physics=newton_mjwarp", "--my_callback_flag=42", "env.lr=3e-4"] + callback_remainder_pre_fold = ["physics=newton_mjwarp", "env.lr=3e-4"] + + # Wrong order: fold main first, then intersect against pre-fold callback. + folded_first = fold_preset_tokens(main_remainder_pre_fold) + intersected = list_intersection(folded_first, callback_remainder_pre_fold) + + # Preset is gone -- this is exactly the bug to avoid in rsl_rl scripts. + assert intersected == ["env.lr=3e-4"] + assert "presets=newton_mjwarp" not in intersected + + +# --------------------------------------------------------------------------- +# Helpers: _ArgvHelper and _bucket_variants_by_target +# --------------------------------------------------------------------------- + + +def test_argv_helper_finds_task_equals_form(): + from isaaclab_tasks.utils.preset_cli import _ArgvHelper + + argv = _ArgvHelper(["train.py", "--task=Foo-v0"]) + assert argv.task_name == "Foo-v0" + assert argv.help_requested is False + + +def test_argv_helper_finds_task_separated_form(): + from isaaclab_tasks.utils.preset_cli import _ArgvHelper + + argv = _ArgvHelper(["train.py", "--task", "Foo-v0"]) + assert argv.task_name == "Foo-v0" + + +def test_argv_helper_task_missing_returns_none(): + from isaaclab_tasks.utils.preset_cli import _ArgvHelper + + argv = _ArgvHelper(["train.py", "physics=newton_mjwarp"]) + assert argv.task_name is None + assert argv.help_requested is False + + +def test_argv_helper_detects_help_flag(): + """``--help`` and ``-h`` both flip ``help_requested``.""" + from isaaclab_tasks.utils.preset_cli import _ArgvHelper + + assert _ArgvHelper(["train.py", "--help"]).help_requested is True + assert _ArgvHelper(["train.py", "-h"]).help_requested is True + assert _ArgvHelper(["train.py", "--task=Foo", "--help"]).help_requested is True + assert _ArgvHelper(["train.py", "env.sim.dt=0.001"]).help_requested is False + + +def test_argv_helper_task_returns_last_value(): + """argparse's ``store`` action uses the last ``--task``; the scanner + must match so ``--help`` shows variants for the task argparse will + actually use.""" + from isaaclab_tasks.utils.preset_cli import _ArgvHelper + + assert _ArgvHelper(["train.py", "--task=Old", "--task=New"]).task_name == "New" + assert _ArgvHelper(["train.py", "--task", "Old", "--task", "New"]).task_name == "New" + assert _ArgvHelper(["train.py", "--task=Old", "--task", "New"]).task_name == "New" + + +def test_bucket_variants_routes_by_base_class_isinstance(): + """Variants bucket by ``isinstance`` against ``PresetTarget.base_classes``. + + PhysicsCfg subclass instances route to PHYSICS, RendererCfg subclass + instances route to RENDERER, and everything else falls into DOMAIN. + """ + from isaaclab.physics import PhysicsCfg + from isaaclab.renderers.renderer_cfg import RendererCfg + from isaaclab.utils import configclass + + from isaaclab_tasks.utils.preset_cli import _bucket_variants_by_target + from isaaclab_tasks.utils.preset_target import PresetTarget + + @configclass + class _PhysVariant(PhysicsCfg): + class_type: str = "mock" + + @configclass + class _PhysWrapper(PhysicsCfg): + # Mirrors NewtonCfg's "wrapper holds an inner solver" shape: still + # subclasses PhysicsCfg, so the base-class isinstance check still + # buckets it correctly regardless of any nested member type. + class_type: str = "mock_wrapper" + inner: object = None + + @configclass + class _RendVariant(RendererCfg): + pass + + walked = { + "physics": { + "default": _PhysVariant(), + "physx": _PhysVariant(), + "newton_mjwarp": _PhysWrapper(inner=_PhysVariant()), + "newton_kamino": _PhysWrapper(inner=_PhysVariant()), + }, + "renderer": { + "default": _RendVariant(), + "newton_renderer": _RendVariant(), + }, + "weight": { # cfgs whose type is not a typed-target base subclass -> DOMAIN + "default": 1.0, + "light": 0.5, + "heavy": 2.0, + }, + } + result = _bucket_variants_by_target(walked) + # All physics variants bucket to PHYSICS (including the wrapper-shaped ones). + assert {"physx", "newton_mjwarp", "newton_kamino"} <= result[PresetTarget.PHYSICS] + assert "newton_renderer" in result[PresetTarget.RENDERER] + # Primitive-typed variants land in DOMAIN. + assert {"light", "heavy"} <= result[PresetTarget.DOMAIN] + # 'default' is filtered out everywhere -- it's the fallback, not a selectable name. + for bucket in result.values(): + assert "default" not in bucket + + +# --------------------------------------------------------------------------- +# --help: section description renders the variant listing +# --------------------------------------------------------------------------- + + +def test_help_without_task_says_pass_task(monkeypatch, capsys): + """``--help`` without ``--task`` tells the user to pass ``--task=X``, + once on the section description rather than repeated per-flag. + """ + monkeypatch.setattr("sys.argv", ["train.py", "--help"]) + from isaaclab_tasks.utils.preset_cli import setup_preset_cli + + parser = argparse.ArgumentParser(prog="train.py") # default add_help=True + parser.add_argument("--task", type=str, default=None) + with pytest.raises(SystemExit): + setup_preset_cli(parser) + out = capsys.readouterr().out + assert out.count("Pass `--task=X`") == 1 + + +@pytest.mark.parametrize( + "build_key, expected_phrases", + [ + pytest.param( + "empty", + [ + "physics=NAME (typed) selects a PhysicsCfg variant. Available: (none)", + "renderer=NAME (typed) selects a RendererCfg variant. Available: (none)", + "presets=NAME[,NAME,...] broadcast: applied to every matching PresetCfg. Available: (none)", + ], + id="zero_variants_everywhere", + ), + pytest.param( + "physics_only", + [ + "physics=NAME (typed) selects a PhysicsCfg variant. Available: - alpha - beta", + "renderer=NAME (typed) selects a RendererCfg variant. Available: (none)", + "presets=NAME[,NAME,...] broadcast: applied to every matching PresetCfg. Available: (none)", + ], + id="typed_populated_other_typed_empty", + ), + pytest.param( + "domain_only", + [ + "physics=NAME (typed) selects a PhysicsCfg variant. Available: (none)", + "renderer=NAME (typed) selects a RendererCfg variant. Available: (none)", + "presets=NAME[,NAME,...] broadcast: applied to every matching PresetCfg. Available: - heavy - light", + ], + id="domain_bucket_only", + ), + pytest.param( + "mixed", + [ + "physics=NAME (typed) selects a PhysicsCfg variant. Available: - my_phys", + "renderer=NAME (typed) selects a RendererCfg variant. Available: - my_rend", + "presets=NAME[,NAME,...] broadcast: applied to every matching PresetCfg. Available: - heavy - light", + ], + id="all_three_buckets_populated", + ), + ], +) +def test_help_text_branch_strings(monkeypatch, capsys, build_key, expected_phrases): + """Each branch of the description builder renders the documented strings + for its variant shape. Typed-bucketed names (PhysicsCfg/RendererCfg subclass + instances) appear only under their typed section; the DOMAIN bucket + (``presets:``) lists only variants that fell into the catch-all. The + parametrize id captures which branch each case locks; argparse line- + wrapping is normalized away before substring assertions so wording changes + are deliberate. + """ + from isaaclab.physics import PhysicsCfg + from isaaclab.renderers.renderer_cfg import RendererCfg + from isaaclab.utils import configclass + + from isaaclab_tasks.utils.hydra import preset + + @configclass + class _HelpPhysCfg(PhysicsCfg): + class_type: str = "mock" + + @configclass + class _HelpRendCfg(RendererCfg): + pass + + @configclass + class _EmptyCfg: + pass + + @configclass + class _PhysOnlyCfg: + physics: object = preset(default=_HelpPhysCfg(), alpha=_HelpPhysCfg(), beta=_HelpPhysCfg()) + + @configclass + class _DomainOnlyCfg: + weight: object = preset(default=1.0, light=0.5, heavy=2.0) + + @configclass + class _MixedCfg: + physics: object = preset(default=_HelpPhysCfg(), my_phys=_HelpPhysCfg()) + renderer: object = preset(default=_HelpRendCfg(), my_rend=_HelpRendCfg()) + weight: object = preset(default=1.0, light=0.5, heavy=2.0) + + builders = { + "empty": _EmptyCfg, + "physics_only": _PhysOnlyCfg, + "domain_only": _DomainOnlyCfg, + "mixed": _MixedCfg, + } + + import isaaclab_tasks.utils.parse_cfg as parse_cfg + + monkeypatch.setattr(parse_cfg, "load_cfg_from_registry", lambda *_a, **_kw: builders[build_key]()) + monkeypatch.setattr("sys.argv", ["train.py", "--task=Fake-v0", "--help"]) + from isaaclab_tasks.utils.preset_cli import setup_preset_cli + + parser = argparse.ArgumentParser(prog="train.py") + parser.add_argument("--task", type=str, default=None) + with pytest.raises(SystemExit): + setup_preset_cli(parser) + # Collapse argparse line-wrapping so substring checks survive width changes. + flat = " ".join(capsys.readouterr().out.split()) + + for phrase in expected_phrases: + assert phrase in flat, f"Missing phrase: {phrase!r}" From aef3dbc72ae2cc06f38a6f53d5835d68d84b92ba Mon Sep 17 00:00:00 2001 From: hujc Date: Fri, 15 May 2026 14:37:13 -0700 Subject: [PATCH 072/133] [Newton] Bump Newton pin to v1.2.0 (stable) (#5616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Bumps the Newton pin from `v1.2.0rc2` (current develop) directly to the [`v1.2.0` stable release](https://github.com/newton-physics/newton/releases/tag/v1.2.0) across all five pin sites, keeping the canonical `newton[sim] @ git+...` form everywhere. Per Kelly Guo's suggestion: skip the rc bump and go straight to stable. Upstream published `v1.2.0` on 2026-05-12. **Alternative**: [isaac-sim/IsaacLab#5614](https://github.com/isaac-sim/IsaacLab/pull/5614) (rc3 bump) — pick whichever target based on CI signal. This one is the most forward target. > Branch is still named `jichuanh/newton-1.2.0rc4-bump` from when this PR was originally proposing rc4 — the branch name doesn't match the current target but the diff is correct. Force-pushing the rename would close/reopen the PR, which adds noise without changing the artifact. ## What's new in Newton v1.2.0 vs v1.2.0rc2 Full release notes: [newton-physics/newton release v1.2.0](https://github.com/newton-physics/newton/releases/tag/v1.2.0). Notable IsaacLab-relevant fixes: - [newton-physics/newton#2651](https://github.com/newton-physics/newton/pull/2651) — MPR/GJK no longer assumes convex hulls are centered around the origin. - [newton-physics/newton#2703](https://github.com/newton-physics/newton/pull/2703) — Kamino FK solver performance. - [newton-physics/newton#2721](https://github.com/newton-physics/newton/pull/2721) — HDR color output for tiled camera sensors. - [newton-physics/newton#2743](https://github.com/newton-physics/newton/pull/2743) — Collada textures in URDF import. - [newton-physics/newton#2823](https://github.com/newton-physics/newton/pull/2823) — Gravity-data device allocation in Kamino (multi-GPU). - [newton-physics/newton#2632](https://github.com/newton-physics/newton/pull/2632) — CollisionPipeline small fixes. - [newton-physics/newton#2734](https://github.com/newton-physics/newton/pull/2734) — `DelassusOperator` attribute refactor. Not used in IsaacLab source today (verified by grep), no adapt needed. - SolverMuJoCo fixes: planar meshes, contact-anchor computation, distance conversion. ## Required dep bumps None on the IsaacLab side. The `mjwarp 3.8.0.1 → 3.8.0.3` bump flows in transitively through `newton[sim]`, since [isaac-sim/IsaacLab#5566](https://github.com/isaac-sim/IsaacLab/pull/5566) dropped IsaacLab's explicit `mujoco` / `mujoco-warp` pins. `warp-lang` stays at `1.13.0` (set by [isaac-sim/IsaacLab#5523](https://github.com/isaac-sim/IsaacLab/pull/5523)). ## Pins updated | File | Change | |---|---| | `source/isaaclab_newton/setup.py` | `v1.2.0rc2` → `v1.2.0` | | `source/isaaclab_physx/setup.py` | `v1.2.0rc2` → `v1.2.0` | | `source/isaaclab_visualizers/setup.py` | 3× `v1.2.0rc2` → `v1.2.0` | | `tools/wheel_builder/res/python_packages.toml` | `v1.2.0rc2` → `v1.2.0` | ## Test plan - [x] Pre-commit clean. - [ ] CI smoke verifies clean install picks up `newton 1.2.0` and downstream `mjwarp 3.8.0.3`. --- .../jichuanh-newton-v120-stable-bump.minor.rst | 14 ++++++++++++++ .../jichuanh-newton-v120-stable-bump.minor.rst | 6 ++++++ source/isaaclab_newton/setup.py | 2 +- .../jichuanh-newton-v120-stable-bump.rst | 5 +++++ source/isaaclab_physx/setup.py | 2 +- .../jichuanh-newton-v120-stable-bump.rst | 6 ++++++ source/isaaclab_visualizers/setup.py | 6 +++--- tools/wheel_builder/res/python_packages.toml | 2 +- 8 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst create mode 100644 source/isaaclab_newton/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/jichuanh-newton-v120-stable-bump.rst create mode 100644 source/isaaclab_visualizers/changelog.d/jichuanh-newton-v120-stable-bump.rst diff --git a/source/isaaclab/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst b/source/isaaclab/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst new file mode 100644 index 000000000000..68898a3d0522 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst @@ -0,0 +1,14 @@ +Changed +^^^^^^^ + +* Bumped the ``newton[sim]`` pin from ``v1.2.0rc2`` to ``v1.2.0`` + (stable) across :mod:`isaaclab_newton`, :mod:`isaaclab_physx` + (``[newton]`` extra), :mod:`isaaclab_visualizers` (3×), and + ``tools/wheel_builder/res/python_packages.toml``. Upstream release + notes: `newton-physics/newton v1.2.0 + `_. +* No IsaacLab-side ``mujoco`` / ``mujoco-warp`` pin change — the + transitive ``mjwarp`` bump flows in through ``newton[sim]`` since + `isaac-sim/IsaacLab#5566 + `_ dropped the + explicit pins. diff --git a/source/isaaclab_newton/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst b/source/isaaclab_newton/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst new file mode 100644 index 000000000000..e802ace0df12 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Bumped the ``newton[sim]`` pin from ``v1.2.0rc2`` to ``v1.2.0`` + (stable). Upstream release notes: `newton-physics/newton v1.2.0 + `_. diff --git a/source/isaaclab_newton/setup.py b/source/isaaclab_newton/setup.py index 4c4a43633b9f..37dd583df959 100644 --- a/source/isaaclab_newton/setup.py +++ b/source/isaaclab_newton/setup.py @@ -39,7 +39,7 @@ def run(self): "all": [ "prettytable==3.3.0", "PyOpenGL-accelerate==3.1.10", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0", ], } diff --git a/source/isaaclab_physx/changelog.d/jichuanh-newton-v120-stable-bump.rst b/source/isaaclab_physx/changelog.d/jichuanh-newton-v120-stable-bump.rst new file mode 100644 index 000000000000..6451e97bccd2 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/jichuanh-newton-v120-stable-bump.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Bumped the optional ``[newton]`` extra to ``v1.2.0`` (stable) so the + pin matches :mod:`isaaclab_newton`. diff --git a/source/isaaclab_physx/setup.py b/source/isaaclab_physx/setup.py index 09fc76bdac69..911b498d163c 100644 --- a/source/isaaclab_physx/setup.py +++ b/source/isaaclab_physx/setup.py @@ -20,7 +20,7 @@ EXTRAS_REQUIRE = { "newton": [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0", ], } diff --git a/source/isaaclab_visualizers/changelog.d/jichuanh-newton-v120-stable-bump.rst b/source/isaaclab_visualizers/changelog.d/jichuanh-newton-v120-stable-bump.rst new file mode 100644 index 000000000000..9f31742c77da --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/jichuanh-newton-v120-stable-bump.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Bumped the ``newton[sim]`` pin in the ``[newton]``, ``[rerun]``, and + ``[viser]`` extras to ``v1.2.0`` (stable) so the pin matches + :mod:`isaaclab_newton`. diff --git a/source/isaaclab_visualizers/setup.py b/source/isaaclab_visualizers/setup.py index 008fe15c8d6c..a2b2ee093a46 100644 --- a/source/isaaclab_visualizers/setup.py +++ b/source/isaaclab_visualizers/setup.py @@ -24,16 +24,16 @@ "kit": [], "newton": [ "warp-lang", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0", "PyOpenGL-accelerate", "imgui-bundle>=1.92.5", ], "rerun": [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0", "rerun-sdk>=0.29.0", ], "viser": [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0", "viser>=1.0.16", ], } diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index 369c9ef899e1..56519fcda625 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -87,7 +87,7 @@ pyproject.optional-dependencies.all = [ # ================================================================================ { "newton" = [ "warp-lang==1.13.0", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0rc2", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@v1.2.0", "PyOpenGL-accelerate==3.1.10" ] }, # ================================================================================ From d84b905e6659cbe0caf6ad8d7cbe6fa5741d9228 Mon Sep 17 00:00:00 2001 From: John Date: Fri, 15 May 2026 14:38:10 -0700 Subject: [PATCH 073/133] Add patch to locomanipulation SDG pipeline to avoid use of flash attn (#5596) # Make locomanipulation SDG GR00T flow runnable without flash-attn ## Summary Two small fixes that let users finetune and roll out the locomanipulation SDG GR00T policy on hardware where `flash-attn` is unavailable (e.g. Blackwell, or any environment where the wheel fails to build). ## Changes - **`scripts/imitation_learning/locomanipulation_sdg/gr00t/no_flash_attn.patch`** (new): patch against the Isaac-GR00T repo that switches the bundled Eagle 2.5 VL model from `flash_attention_2` to PyTorch SDPA, and guards the RADIO vision module's `flash_attn` imports so the package becomes importable without flash-attn installed. SigLIP path works; RADIO path is unsupported without flash-attn (documented in the patch). - **`docs/source/overview/imitation-learning/humanoids_imitation.rst`**: adds a note in the GR00T install section explaining when to apply the patch (build failure, or `RuntimeError: FlashAttention only supports Ampere GPUs or newer`) and how to apply it from the sibling Isaac-GR00T checkout. - **`scripts/imitation_learning/locomanipulation_sdg/gr00t/rollout_policy.py`**: override `env_cfg.recorders` with `ActionStateRecorderManagerCfg()` so the rollout doesn't try to record `env._locomanipulation_sdg_output_data`, which is only populated by the data-generation state machine in `generate_data.py` and is absent during policy rollout. Without this, the recorder raises `AttributeError` on the first pre-step. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## 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 --------- Co-authored-by: Kelly Guo --- .../humanoids_imitation.rst | 16 +++++ .../gr00t/no_flash_attn.patch | 63 +++++++++++++++++++ .../gr00t/rollout_policy.py | 4 ++ 3 files changed, 83 insertions(+) create mode 100644 scripts/imitation_learning/locomanipulation_sdg/gr00t/no_flash_attn.patch diff --git a/docs/source/overview/imitation-learning/humanoids_imitation.rst b/docs/source/overview/imitation-learning/humanoids_imitation.rst index 68fc22e75f9b..d593fdfcab70 100644 --- a/docs/source/overview/imitation-learning/humanoids_imitation.rst +++ b/docs/source/overview/imitation-learning/humanoids_imitation.rst @@ -636,6 +636,22 @@ Then, from the **Isaac-GR00T** directory, install GR00T N1.5 and its dependencie MAX_JOBS=4 uv pip install --no-build-isolation 'git+https://github.com/facebookresearch/pytorch3d.git@v0.7.9' uv pip install diffusers decord zmq +.. note:: + + **If you cannot install or use flash-attn**, an optional patch is provided that switches the + bundled Eagle 2.5 VL model to PyTorch SDPA. Use this if ``flash-attn`` fails to build for your + environment, or if it installs but raises a runtime error such as + ``RuntimeError: FlashAttention only supports Ampere GPUs or newer`` (for example on Blackwell + GPUs, which ``flash-attn==2.7.1.post4`` does not have prebuilt kernels for). After the patch, + finetune and rollout run on any CUDA arch supported by your PyTorch build, at the cost of + flash-attn's training speedup. Skip the ``flash-attn`` install line above, then apply the + patch from the **Isaac-GR00T** directory (the sibling layout above means the IsaacLab + checkout is at ``../IsaacLab``): + + .. code:: bash + + git apply ../IsaacLab/scripts/imitation_learning/locomanipulation_sdg/gr00t/no_flash_attn.patch + Convert dataset to LeRobot format """"""""""""""""""""""""""""""""" diff --git a/scripts/imitation_learning/locomanipulation_sdg/gr00t/no_flash_attn.patch b/scripts/imitation_learning/locomanipulation_sdg/gr00t/no_flash_attn.patch new file mode 100644 index 000000000000..0085ec98241f --- /dev/null +++ b/scripts/imitation_learning/locomanipulation_sdg/gr00t/no_flash_attn.patch @@ -0,0 +1,63 @@ +diff --git a/gr00t/model/backbone/eagle2_hg_model/config.json b/gr00t/model/backbone/eagle2_hg_model/config.json +index 3894adf..f1b95aa 100644 +--- a/gr00t/model/backbone/eagle2_hg_model/config.json ++++ b/gr00t/model/backbone/eagle2_hg_model/config.json +@@ -1,5 +1,5 @@ + { +- "_attn_implementation": "flash_attention_2", ++ "_attn_implementation": "sdpa", + "_commit_hash": null, + "architectures": [ + "Eagle2_5_VLForConditionalGeneration" +diff --git a/gr00t/model/backbone/eagle2_hg_model/modeling_eagle2_5_vl.py b/gr00t/model/backbone/eagle2_hg_model/modeling_eagle2_5_vl.py +index a9649d5..d99b496 100755 +--- a/gr00t/model/backbone/eagle2_hg_model/modeling_eagle2_5_vl.py ++++ b/gr00t/model/backbone/eagle2_hg_model/modeling_eagle2_5_vl.py +@@ -108,7 +108,7 @@ class Eagle2_5_VLForConditionalGeneration(Eagle2_5_VLPreTrainedModel, Generation + self.vision_model = vision_model + else: + if config.vision_config.model_type == "siglip_vision_model": +- config.vision_config._attn_implementation = "flash_attention_2" ++ config.vision_config._attn_implementation = "sdpa" + self.vision_model = SiglipVisionModel(config.vision_config) + elif config.vision_config.model_type == "radio": + self.vision_model = RADIOModel(config.vision_config) +@@ -124,9 +124,7 @@ class Eagle2_5_VLForConditionalGeneration(Eagle2_5_VLPreTrainedModel, Generation + raise NotImplementedError("Phi3 is not implemented.") + # self.language_model = Phi3ForCausalLM(config.text_config) + elif config.text_config.architectures[0] == "Qwen2ForCausalLM": +- assert ( +- config.text_config._attn_implementation == "flash_attention_2" +- ), f"Qwen2 must use flash_attention_2 but got {config.text_config._attn_implementation}" ++ config.text_config._attn_implementation = "sdpa" + self.language_model = Qwen2ForCausalLM(config.text_config) + elif config.text_config.architectures[0] == "Qwen3ForCausalLM": + self.language_model = Qwen3ForCausalLM(config.text_config) +diff --git a/gr00t/model/backbone/eagle2_hg_model/radio_model.py b/gr00t/model/backbone/eagle2_hg_model/radio_model.py +index 2df0415..eb9b741 100644 +--- a/gr00t/model/backbone/eagle2_hg_model/radio_model.py ++++ b/gr00t/model/backbone/eagle2_hg_model/radio_model.py +@@ -44,12 +44,18 @@ from transformers.utils import ModelOutput + + try: # v1 + from flash_attn.flash_attn_interface import flash_attn_unpadded_qkvpacked_func +-except ImportError: # v2 +- from flash_attn.flash_attn_interface import ( +- flash_attn_varlen_qkvpacked_func as flash_attn_unpadded_qkvpacked_func, +- ) ++except ImportError: ++ try: # v2 ++ from flash_attn.flash_attn_interface import ( ++ flash_attn_varlen_qkvpacked_func as flash_attn_unpadded_qkvpacked_func, ++ ) ++ except ImportError: # flash-attn unavailable — RADIO vision won't work, SigLIP does ++ flash_attn_unpadded_qkvpacked_func = None + +-from flash_attn.bert_padding import pad_input, unpad_input ++try: ++ from flash_attn.bert_padding import pad_input, unpad_input ++except ImportError: ++ pad_input = unpad_input = None + + + class FlashAttention(nn.Module): diff --git a/scripts/imitation_learning/locomanipulation_sdg/gr00t/rollout_policy.py b/scripts/imitation_learning/locomanipulation_sdg/gr00t/rollout_policy.py index 681a850fa685..21cd2f826df2 100644 --- a/scripts/imitation_learning/locomanipulation_sdg/gr00t/rollout_policy.py +++ b/scripts/imitation_learning/locomanipulation_sdg/gr00t/rollout_policy.py @@ -40,6 +40,7 @@ import torch from policy import Policy +from isaaclab.envs.mdp.recorders.recorders_cfg import ActionStateRecorderManagerCfg from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler from isaaclab.utils.math import convert_quat @@ -375,6 +376,9 @@ def eval_policy( env_cfg = parse_env_cfg(env_name, device=args_cli.device, num_envs=1) env_cfg.sim.device = args_cli.device + # Drop the SDG output-data recorder term: it pulls env._locomanipulation_sdg_output_data, + # which is only populated by the data-generation state machine, not during policy rollout. + env_cfg.recorders = ActionStateRecorderManagerCfg() env_cfg.recorders.dataset_export_dir_path = os.path.dirname(args_cli.output_file) env_cfg.recorders.dataset_filename = os.path.basename(args_cli.output_file) From 21745e2a024a4746a73e88bd567f579176c7c8a0 Mon Sep 17 00:00:00 2001 From: jmart-nv Date: Fri, 15 May 2026 16:38:34 -0500 Subject: [PATCH 074/133] Fix silent ABI mismatch in cubric Python shim (#5444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Background: The _cubric.py ctypes shim was pinned to IAdapter v0.1 vtable offsets, but newer Kit builds ship v0.2 — compute calls were silently landing on unbind, disabling cubric's GPU transform hierarchy propagation. carb accepts the version mismatch with only a stderr warning. Originally, this change updated offsets to the v0.2 layout, requested v0.2 from the framework, and added a runtime InterfaceDesc check that refused to acquire on any unexpected version. The kit team is fixing the ABI-breaking semver contract violation upstream, so it won't actually make it into a release. So the pinned version in Isaac Lab remains on v0.1 but keeps the validation code as a safety net. This problem will go away once we have official python bindings for cubric in a future kit release. If usdrt eventually exposes the required `eRigidBody` options via the `IFabricHierarchy` API then that would massively simplify the implementation of newton manager. Will pursue a feature request. ## 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` - [ ] I have made corresponding changes to the documentation *(N/A)* - [ ] My changes generate no new warnings *(New warnings on ABI mismatch are intentional)* - [ ] I have added tests that prove my fix is effective or that my feature works *(N/A - spoofing kit versions for unit test would be non-trivial; verified manually)* - [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: Kelly Guo --- .../changelog.d/jmart-cubric-abi.rst | 7 ++ .../isaaclab_newton/physics/_cubric.py | 105 +++++++++++++++++- 2 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/jmart-cubric-abi.rst diff --git a/source/isaaclab_newton/changelog.d/jmart-cubric-abi.rst b/source/isaaclab_newton/changelog.d/jmart-cubric-abi.rst new file mode 100644 index 000000000000..d2310ee82a48 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/jmart-cubric-abi.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added runtime verification of the ``omni::cubric::IAdapter`` interface + version in :mod:`~isaaclab_newton.physics._cubric` as defense-in-depth + against future ABI shifts. The shim falls back to the CPU path on + major-version mismatch or older-minor. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/_cubric.py b/source/isaaclab_newton/isaaclab_newton/physics/_cubric.py index abe09cb03bdc..e009580a699a 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/_cubric.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/_cubric.py @@ -35,11 +35,22 @@ # 8: unloadAllPlugins # 16: acquireInterfaceWithClient # 24: tryAcquireInterfaceWithClient ← we use this one +# 32: acquireInterfaceFromInterfaceWithClient +# 40: tryAcquireInterfaceFromInterfaceWithClient +# 48: acquireInterfaceFromLibraryWithClient +# 56: tryAcquireInterfaceFromLibraryWithClient +# 64: getInterfacesCountEx +# 72: acquireInterfacesWithClient +# 80: releaseInterfaceWithClient +# 88: getPluginDesc +# 96: getInterfacePluginDesc ← we use this one _FW_OFF_TRY_ACQUIRE = 24 +_FW_OFF_GET_INTERFACE_PLUGIN_DESC = 96 # --------------------------------------------------------------------------- # IAdapter struct layout (from omni/cubric/IAdapter.h) # --------------------------------------------------------------------------- +# v0.1 layout: # 0: getAttribute # 8: create(AdapterId*) # 16: refcount @@ -53,6 +64,10 @@ _IA_OFF_BIND = 40 _IA_OFF_COMPUTE = 56 +# Expected IAdapter version. +_IA_EXPECTED_MAJOR = 0 +_IA_EXPECTED_MINOR = 1 + # AdapterId sentinel _INVALID_ADAPTER_ID = ctypes.c_uint64(~0).value @@ -90,6 +105,13 @@ class _InterfaceDesc(ctypes.Structure): ] +# carb::PluginDesc offsets. PluginImplDesc occupies the first 40 bytes +# (3 char* + 4-byte hotReload + 4-byte pad + char*). +_PD_OFF_INTERFACES = 40 +_PD_OFF_INTERFACE_COUNT = 48 +_INTERFACE_DESC_STRIDE = 16 # char* + Version + + def _read_u64(addr: int) -> int: return ctypes.c_uint64.from_address(addr).value @@ -158,7 +180,7 @@ def initialize(self) -> bool: desc = _InterfaceDesc( name=b"omni::cubric::IAdapter", - version=_Version(0, 1), + version=_Version(_IA_EXPECTED_MAJOR, _IA_EXPECTED_MINOR), ) # Try tryAcquire first (non-loading); fall back to acquire (will load the plugin if registered). @@ -175,10 +197,15 @@ def initialize(self) -> bool: ia_ptr = acquire_fn(b"isaaclab.cubric", desc, None) if not ia_ptr: logger.warning( - "Could not acquire omni::cubric::IAdapter — " - "cubric plugin may not be registered or interface version mismatch" + "Could not acquire omni::cubric::IAdapter v%d.%d — plugin may not be " + "registered or its version is older. Falling back to update_world_xforms().", + _IA_EXPECTED_MAJOR, + _IA_EXPECTED_MINOR, ) return False + + if not self._verify_iadapter_version(fw_ptr, ia_ptr): + return False self._ia_ptr = ia_ptr # Wrap the four IAdapter function pointers we need. @@ -219,6 +246,78 @@ def initialize(self) -> bool: logger.info("cubric IAdapter bindings ready") return True + @staticmethod + def _verify_iadapter_version(fw_ptr: int, ia_ptr: int) -> bool: + """Verify the acquired IAdapter is compatible with this shim's vtable offsets. + + Major mismatches and older minors return False (CPU fallback). Higher + minors are accepted under the semver compatibility contract but emit a + loud warning, so any silent ABI break — the failure mode that motivated + this verification — gets flagged early rather than miscalled. + """ + get_desc_addr = _read_u64(fw_ptr + _FW_OFF_GET_INTERFACE_PLUGIN_DESC) + if get_desc_addr == 0: + logger.warning("getInterfacePluginDesc is null in Framework") + return False + + get_desc_fn = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p)(get_desc_addr) + plugin_desc_ptr = get_desc_fn(ia_ptr) + if not plugin_desc_ptr: + logger.warning("getInterfacePluginDesc returned null for IAdapter") + return False + + interfaces_ptr = _read_u64(plugin_desc_ptr + _PD_OFF_INTERFACES) + interface_count = _read_u64(plugin_desc_ptr + _PD_OFF_INTERFACE_COUNT) + if interfaces_ptr == 0 or interface_count == 0: + logger.warning("PluginDesc reports zero interfaces for cubric plugin") + return False + if interface_count > 64: + logger.warning( + "PluginDesc interfaceCount suspiciously large (%d); struct layout mismatch?", + interface_count, + ) + return False + + for i in range(interface_count): + entry_addr = interfaces_ptr + i * _INTERFACE_DESC_STRIDE + name_addr = _read_u64(entry_addr) + if name_addr == 0: + continue + target_name = b"omni::cubric::IAdapter\x00" + if ctypes.string_at(name_addr, len(target_name)) != target_name: + continue + major = ctypes.c_uint32.from_address(entry_addr + 8).value + minor = ctypes.c_uint32.from_address(entry_addr + 12).value + if major != _IA_EXPECTED_MAJOR or minor < _IA_EXPECTED_MINOR: + logger.warning( + "cubric IAdapter version incompatible with this shim: plugin " + "reports v%d.%d, shim is pinned to v%d.%d. Falling back to " + "update_world_xforms().", + major, + minor, + _IA_EXPECTED_MAJOR, + _IA_EXPECTED_MINOR, + ) + return False + if minor > _IA_EXPECTED_MINOR: + logger.warning( + "cubric IAdapter minor version newer than this shim was validated " + "against: plugin reports v%d.%d, shim is pinned to v%d.%d. Proceeding " + "under semver minor-compatibility — if transforms misbehave, verify " + "the vtable layout against omni/cubric/IAdapter.h.", + major, + minor, + _IA_EXPECTED_MAJOR, + _IA_EXPECTED_MINOR, + ) + return True + + logger.warning( + "cubric plugin does not advertise omni::cubric::IAdapter — unexpected. " + "Falling back to update_world_xforms()." + ) + return False + @property def available(self) -> bool: return self._ia_ptr != 0 From 2bdccb26f28560f71e47b0acabb0708e49fa07b4 Mon Sep 17 00:00:00 2001 From: Krishna Lakhi Date: Sat, 16 May 2026 03:17:24 +0530 Subject: [PATCH 075/133] Fix invalid inline comment in Windows batch code block (#5612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * Fixed an invalid inline `::` comment in the Windows batch code block on the kit-less installation page. In Windows batch, `::` only works as a comment at the start of a line — when placed inline after a command, the tokens are passed as arguments, causing a runtime error. Moved the shorthand hint (`or: isaaclab.bat -i`) to its own comment line. ## Test plan - [ ] Verify the rendered docs page shows the corrected batch snippet. - [ ] Confirm the `isaaclab.bat --install` command runs without unexpected extra arguments on Windows. Co-authored-by: Kelly Guo --- docs/source/setup/installation/kitless_installation.rst | 3 ++- .../changelog.d/klakhi-fix-windows-inline-comment-docs.skip | 0 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/klakhi-fix-windows-inline-comment-docs.skip diff --git a/docs/source/setup/installation/kitless_installation.rst b/docs/source/setup/installation/kitless_installation.rst index 30d03533d239..19303b842b9d 100644 --- a/docs/source/setup/installation/kitless_installation.rst +++ b/docs/source/setup/installation/kitless_installation.rst @@ -44,7 +44,8 @@ with MJWarp physics and the Newton visualizer: .. code-block:: batch :: Install Isaac Lab (Newton backend, no Isaac Sim required) - isaaclab.bat --install :: or isaaclab.bat -i + :: or: isaaclab.bat -i + isaaclab.bat --install :: Kickoff training with MJWarp physics and Newton visualizer isaaclab.bat -p scripts\reinforcement_learning\rsl_rl\train.py ^ diff --git a/source/isaaclab/changelog.d/klakhi-fix-windows-inline-comment-docs.skip b/source/isaaclab/changelog.d/klakhi-fix-windows-inline-comment-docs.skip new file mode 100644 index 000000000000..e69de29bb2d1 From bb9388ee6f9b09ac19f8a0e564c92ef539719467 Mon Sep 17 00:00:00 2001 From: xul Date: Sat, 16 May 2026 05:48:17 +0800 Subject: [PATCH 076/133] Add settings to make training results deterministic (#5449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description This PR adds a deterministic training path and documentation for Isaac Lab RL workflows. - Added apps/isaaclab.python.headless.determinism.kit as a deterministic headless rendering experience. - Updated scripts/reinforcement_learning/rl_games/train.py to add opt-in --deterministic and use configure_seed(env_cfg.seed, args_cli.deterministic). - Updated docs/source/features/reproducibility.rst to document --experience isaaclab.python.headless.determinism.kit and clarify that strict PyTorch determinism is currently exposed only for RL-Games. Test command example: ./isaaclab.sh -p scripts/reinforcement_learning/rl_games/train.py --task Isaac-Cartpole-RGB-v0 --enable_cameras --headless --seed 42 --max_iteration 20 **--deterministic --experience isaaclab.python.headless.determinism.kit** Fixes # (issue) https://github.com/isaac-sim/IsaacLab/issues/3505 Non-reproducible training results in vision-based tasks with identical seeds ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots | Before | After | | ------ | ----- | | Before | After | ## 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 --------- Co-authored-by: r-schmitt <139814266+r-schmitt@users.noreply.github.com> Co-authored-by: nvsekkin <72572910+nvsekkin@users.noreply.github.com> Co-authored-by: vidurv-nvidia Co-authored-by: ooctipus Co-authored-by: Yuchen Deng Co-authored-by: Kelly Guo Co-authored-by: isaaclab-bot[bot] <282401363+isaaclab-bot[bot]@users.noreply.github.com> Co-authored-by: hujc Co-authored-by: Antoine RICHARD --- CONTRIBUTORS.md | 1 + docs/source/features/reproducibility.rst | 23 ++ .../reinforcement_learning/rl_games/play.py | 5 + .../reinforcement_learning/rl_games/train.py | 6 + scripts/reinforcement_learning/rsl_rl/play.py | 5 + .../reinforcement_learning/rsl_rl/train.py | 5 + scripts/reinforcement_learning/sb3/play.py | 5 + scripts/reinforcement_learning/sb3/train.py | 5 + scripts/reinforcement_learning/skrl/play.py | 5 + scripts/reinforcement_learning/skrl/train.py | 5 + .../isaaclab/changelog.d/xul-determinism.rst | 5 + source/isaaclab/isaaclab/app/app_launcher.py | 28 +++ .../changelog.d/xul-determinism.skip | 0 .../test/test_train_scripts_deterministic.py | 238 ++++++++++++++++++ 14 files changed, 336 insertions(+) create mode 100644 source/isaaclab/changelog.d/xul-determinism.rst create mode 100644 source/isaaclab_tasks/changelog.d/xul-determinism.skip create mode 100644 source/isaaclab_tasks/test/test_train_scripts_deterministic.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index d5d99f2fcd81..253e40eac438 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -188,6 +188,7 @@ Guidelines for modifications: * Xiaodi Yuan * Xinjie Yao * Xinpeng Liu +* Xu Li * Yang Jin * Yanzi Zhu * Yijie Guo diff --git a/docs/source/features/reproducibility.rst b/docs/source/features/reproducibility.rst index 631e138376c9..e7df5f2df51a 100644 --- a/docs/source/features/reproducibility.rst +++ b/docs/source/features/reproducibility.rst @@ -20,6 +20,29 @@ simulation results are reproducible across different runs. The seed is set into parameters :attr:`isaaclab.envs.ManagerBasedEnvCfg.seed` or :attr:`isaaclab.envs.DirectRLEnvCfg.seed` depending on the manager-based or direct environment implementation respectively. +App-level deterministic rendering via ``AppLauncher`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``--deterministic`` flag is provided by :meth:`isaaclab.app.AppLauncher.add_app_launcher_args`. +After the simulation app starts, :class:`~isaaclab.app.app_launcher.AppLauncher` applies RTX/RTPT carb +settings via :meth:`~isaaclab.app.app_launcher.AppLauncher.apply_rtx_determinism_settings`. + +**Strict PyTorch determinism** (calling :meth:`~isaaclab.utils.seed.configure_seed` with +``torch_deterministic=True`` when you pass ``--deterministic``) is wired into the RL training scripts +for **RL-Games**, **skrl**, **RSL-RL**, and **Stable-Baselines3**: each calls +:meth:`~isaaclab.utils.seed.configure_seed` after constructing its framework runner or agent object +so library initialization is not disturbed, then training proceeds with the requested global RNG and +optional PyTorch deterministic algorithms. Whether you need ``--deterministic`` at the app level +depends on the workload: **physics-only** simulation does not require it; **RTX** rendering +(non-minimal mode) does require it for reproducible imagery; **Newton** rendering does not require it. + +To enable deterministic RTX settings from the app launcher, pass ``--deterministic``. + +.. code-block:: bash + + ./isaaclab.sh -p scripts/reinforcement_learning/rl_games/train.py \ + --task Isaac-Cartpole-RGB-v0 --enable_cameras --headless --deterministic + For results on our determinacy testing for RL training, please check the GitHub Pull Request `#940`_. .. tip:: diff --git a/scripts/reinforcement_learning/rl_games/play.py b/scripts/reinforcement_learning/rl_games/play.py index eb77a86f4d5d..49f3b5ab40c0 100644 --- a/scripts/reinforcement_learning/rl_games/play.py +++ b/scripts/reinforcement_learning/rl_games/play.py @@ -32,6 +32,7 @@ from isaaclab.envs import DirectMARLEnvCfg from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict +from isaaclab.utils.seed import configure_seed from isaaclab_rl.rl_games import RlGamesGpuEnv, RlGamesVecEnvWrapper from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint @@ -172,6 +173,10 @@ def main(): # set number of actors into agent config agent_cfg["params"]["config"]["num_actors"] = env.unwrapped.num_envs runner = Runner() + # configure_seed must be called after Runner() so that PyTorch deterministic settings + # do not interfere with Runner's internal initialization. + if args_cli.deterministic: + configure_seed(env_cfg.seed, True) runner.load(agent_cfg) agent: BasePlayer = runner.create_player() agent.restore(resume_path) diff --git a/scripts/reinforcement_learning/rl_games/train.py b/scripts/reinforcement_learning/rl_games/train.py index 4cc6b8fbe461..f08edaaff7d7 100644 --- a/scripts/reinforcement_learning/rl_games/train.py +++ b/scripts/reinforcement_learning/rl_games/train.py @@ -35,6 +35,7 @@ from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml +from isaaclab.utils.seed import configure_seed from isaaclab_rl.rl_games import MultiObserver, PbtAlgoObserver, RlGamesGpuEnv, RlGamesVecEnvWrapper @@ -223,6 +224,11 @@ def main(): else: runner = Runner(IsaacAlgoObserver()) + # configure_seed must be called after Runner() so that PyTorch deterministic settings + # do not interfere with Runner's internal initialization. + if args_cli.deterministic: + configure_seed(env_cfg.seed, True) + runner.load(agent_cfg) runner.reset() diff --git a/scripts/reinforcement_learning/rsl_rl/play.py b/scripts/reinforcement_learning/rsl_rl/play.py index fbb6c5a81e12..323040bf4762 100644 --- a/scripts/reinforcement_learning/rsl_rl/play.py +++ b/scripts/reinforcement_learning/rsl_rl/play.py @@ -30,6 +30,7 @@ from isaaclab.envs import DirectMARLEnvCfg, DirectRLEnvCfg, ManagerBasedRLEnvCfg from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict +from isaaclab.utils.seed import configure_seed from isaaclab.utils.string import list_intersection, string_to_callable from isaaclab_rl.rsl_rl import ( @@ -176,6 +177,10 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen runner = DistillationRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) else: raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}") + # configure_seed must be called after runner construction so that PyTorch deterministic settings + # do not interfere with the runner's internal initialization. + if args_cli.deterministic: + configure_seed(env_cfg.seed, True) runner.load(resume_path) # obtain the trained policy for inference diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index 01a7b5d69638..386289570335 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -33,6 +33,7 @@ from isaaclab.envs import DirectMARLEnvCfg, DirectRLEnvCfg, ManagerBasedRLEnvCfg from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml +from isaaclab.utils.seed import configure_seed from isaaclab.utils.string import list_intersection, string_to_callable from isaaclab_rl.rsl_rl import RslRlBaseRunnerCfg, RslRlVecEnvWrapper, handle_deprecated_rsl_rl_cfg @@ -225,6 +226,10 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen runner = DistillationRunner(env, agent_cfg.to_dict(), log_dir=log_dir, device=agent_cfg.device) else: raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}") + # configure_seed must be called after runner construction so that PyTorch deterministic settings + # do not interfere with the runner's internal initialization. + if args_cli.deterministic: + configure_seed(env_cfg.seed, True) # write git state to logs runner.add_git_repo_to_log(__file__) # load the checkpoint diff --git a/scripts/reinforcement_learning/sb3/play.py b/scripts/reinforcement_learning/sb3/play.py index bb26180f886d..a61484bd7657 100644 --- a/scripts/reinforcement_learning/sb3/play.py +++ b/scripts/reinforcement_learning/sb3/play.py @@ -30,6 +30,7 @@ from isaaclab.envs import DirectMARLEnvCfg from isaaclab.utils.dict import print_dict +from isaaclab.utils.seed import configure_seed from isaaclab_rl.sb3 import Sb3VecEnvWrapper, process_sb3_cfg from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint @@ -172,6 +173,10 @@ def main(): # create agent from stable baselines print(f"Loading checkpoint from: {checkpoint_path}") agent = PPO.load(checkpoint_path, env, print_system_info=True) + # configure_seed must be called after PPO.load so that PyTorch deterministic settings + # do not interfere with SB3's internal initialization. + if args_cli.deterministic: + configure_seed(env_cfg.seed, True) dt = env.unwrapped.step_dt diff --git a/scripts/reinforcement_learning/sb3/train.py b/scripts/reinforcement_learning/sb3/train.py index e7c9f5df5039..cdcbba1df870 100644 --- a/scripts/reinforcement_learning/sb3/train.py +++ b/scripts/reinforcement_learning/sb3/train.py @@ -36,6 +36,7 @@ from isaaclab.envs import DirectMARLEnvCfg, ManagerBasedRLEnvCfg from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml +from isaaclab.utils.seed import configure_seed from isaaclab_rl.sb3 import Sb3VecEnvWrapper, process_sb3_cfg @@ -202,6 +203,10 @@ def main(): agent = PPO(policy_arch, env, verbose=1, tensorboard_log=log_dir, **agent_cfg) if args_cli.checkpoint is not None: agent = agent.load(args_cli.checkpoint, env, print_system_info=True) + # configure_seed must be called after PPO construction (and optional load) so that PyTorch + # deterministic settings do not interfere with SB3's internal initialization. + if args_cli.deterministic: + configure_seed(env_cfg.seed, True) # callbacks for agent checkpoint_callback = CheckpointCallback(save_freq=1000, save_path=log_dir, name_prefix="model", verbose=2) diff --git a/scripts/reinforcement_learning/skrl/play.py b/scripts/reinforcement_learning/skrl/play.py index bc3bf86af3d3..8cd914cf808c 100644 --- a/scripts/reinforcement_learning/skrl/play.py +++ b/scripts/reinforcement_learning/skrl/play.py @@ -34,6 +34,7 @@ from isaaclab.envs import DirectMARLEnvCfg from isaaclab.utils.dict import print_dict +from isaaclab.utils.seed import configure_seed from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint @@ -204,6 +205,10 @@ def main(): experiment_cfg["agent"]["experiment"]["write_interval"] = 0 experiment_cfg["agent"]["experiment"]["checkpoint_interval"] = 0 runner = Runner(env, experiment_cfg) + # configure_seed must be called after Runner() so that PyTorch deterministic settings + # do not interfere with Runner's internal initialization. + if args_cli.deterministic: + configure_seed(env_cfg.seed, True) print(f"[INFO] Loading model checkpoint from: {resume_path}") runner.agent.load(resume_path) diff --git a/scripts/reinforcement_learning/skrl/train.py b/scripts/reinforcement_learning/skrl/train.py index 26f0f03a30ac..f4ca86fcd8d4 100644 --- a/scripts/reinforcement_learning/skrl/train.py +++ b/scripts/reinforcement_learning/skrl/train.py @@ -37,6 +37,7 @@ from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml +from isaaclab.utils.seed import configure_seed from isaaclab_rl.skrl import SkrlVecEnvWrapper @@ -228,6 +229,10 @@ def main(): # configure and instantiate the skrl runner runner = Runner(env, agent_cfg) + # configure_seed must be called after Runner() so that PyTorch deterministic settings + # do not interfere with Runner's internal initialization. + if args_cli.deterministic: + configure_seed(env_cfg.seed, True) # load checkpoint (if specified) if resume_path: diff --git a/source/isaaclab/changelog.d/xul-determinism.rst b/source/isaaclab/changelog.d/xul-determinism.rst new file mode 100644 index 000000000000..25a873669e75 --- /dev/null +++ b/source/isaaclab/changelog.d/xul-determinism.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added ``--deterministic`` flag to :class:`~isaaclab.app.app_launcher.AppLauncher` so training and + rendering runs can opt into RTX/RTPT carb settings for more reproducible output after startup. diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index f9ef6db13b82..2bdb8a08932d 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -118,6 +118,16 @@ def sync_visualizer_cli_settings_to_carb(launcher_args: dict) -> None: else: settings.set_int("/isaaclab/visualizer/max_visible_envs", -1) + @staticmethod + def apply_rtx_determinism_settings() -> None: + """Apply RTX RealTimePathTracing and disable RTPT caches for reproducible RTX rendering. + Called after :class:`isaacsim.simulation_app.SimulationApp` starts whenever ``--deterministic`` is set. + """ + settings = get_settings_manager() + settings.set_string("/rtx/rendermode", "RealTimePathTracing") + settings.set_bool("/rtx/rtpt/cached/enabled", False) + settings.set_bool("/rtx/rtpt/lightcache/cached/enabled", False) + @staticmethod def _parse_visualizer_csv(value: str) -> list[str]: """Parse visualizer list from a single comma-delimited CLI token.""" @@ -365,6 +375,9 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: * If headless is True and enable_cameras is False, the experience file is set to ``isaaclab.python.headless.kit``. + * ``deterministic`` (bool): After startup, applies RTX/RTPT carb settings for reproducible rendering. + Does not change how the default experience file is chosen. + * ``kit_args`` (str): Optional command line arguments to be passed to Omniverse Kit directly. Arguments should be combined into a single string separated by space. Example usage: --kit_args "--ext-folder=/path/to/ext1 --ext-folder=/path/to/ext2" @@ -490,6 +503,12 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: " it is resolved relative to the `apps` folder in Isaac Sim and Isaac Lab (in that order)." ), ) + arg_group.add_argument( + "--deterministic", + action="store_true", + default=AppLauncher._APPLAUNCHER_CFG_INFO["deterministic"][1], + help="After startup, apply RTX/RTPT settings for reproducible rendering (see AppLauncher docs).", + ) arg_group.add_argument( "--rendering_mode", type=str, @@ -558,6 +577,7 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: "xr": ([bool], False), "device": ([str], "cuda:0"), "experience": ([str], ""), + "deterministic": ([bool], False), "rendering_mode": ([str], "balanced"), "max_visible_envs": ([int, type(None)], None), } @@ -1012,6 +1032,9 @@ def _resolve_experience_file(self, launcher_args: dict): # Check if input keywords contain an 'experience' file setting # Note: since experience is taken as a separate argument by Simulation App, we store it separately self._sim_experience_file = launcher_args.pop("experience", "") + deterministic_mode = bool( + launcher_args.get("deterministic", AppLauncher._APPLAUNCHER_CFG_INFO["deterministic"][1]) + ) # If nothing is provided resolve the experience file based on the headless flag kit_app_exp_path = os.environ["EXP_PATH"] @@ -1064,6 +1087,7 @@ def _resolve_experience_file(self, launcher_args: dict): # Resolve the absolute path of the experience file self._sim_experience_file = os.path.abspath(self._sim_experience_file) + self._apply_rtx_determinism = bool(deterministic_mode) logger.info("Loading experience file: %s", self._sim_experience_file) def _resolve_anim_recording_settings(self, launcher_args: dict): @@ -1149,6 +1173,10 @@ def _load_extensions(self): # Use SettingsManager (backs onto carb when in Omniverse after initialize_carb_settings). initialize_carb_settings() + if self._apply_rtx_determinism: + AppLauncher.apply_rtx_determinism_settings() + logger.info("Applied RTX settings for deterministic rendering (--deterministic).") + # After SimulationApp starts, Kit installs its Python log bridge at DEBUG level. # Re-apply root logger level to WARNING to suppress third-party and verbose debug/info noise. logging.getLogger().setLevel(logging.WARNING) diff --git a/source/isaaclab_tasks/changelog.d/xul-determinism.skip b/source/isaaclab_tasks/changelog.d/xul-determinism.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/test/test_train_scripts_deterministic.py b/source/isaaclab_tasks/test/test_train_scripts_deterministic.py new file mode 100644 index 000000000000..65744aa08730 --- /dev/null +++ b/source/isaaclab_tasks/test/test_train_scripts_deterministic.py @@ -0,0 +1,238 @@ +# 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 + +"""Regression tests for deterministic training CLI plumbing and seed ordering.""" + +from __future__ import annotations + +import argparse +import ast +import os +import subprocess +from pathlib import Path + +import numpy as np +import pytest +from tensorboard.backend.event_processing import event_accumulator + +from isaaclab.app import AppLauncher + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _load_tree(relative_path: str) -> ast.AST: + source = (REPO_ROOT / relative_path).read_text(encoding="utf-8") + return ast.parse(source) + + +def _called_name(call: ast.Call) -> str | None: + func = call.func + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + if func.attr == "load" and isinstance(func.value, ast.Name) and func.value.id == "PPO": + return "PPO.load" + return func.attr + return None + + +def _call_lines(tree: ast.AST, func_names: set[str]) -> list[int]: + lines: list[int] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Call): + called = _called_name(node) + if called in func_names: + lines.append(node.lineno) + return sorted(lines) + + +def test_app_launcher_adds_deterministic_cli_flag(): + """AppLauncher must expose --deterministic for all train scripts using add_launcher_args.""" + parser = argparse.ArgumentParser(add_help=False) + AppLauncher.add_app_launcher_args(parser) + args = parser.parse_args(["--deterministic"]) + assert hasattr(args, "deterministic") + assert args.deterministic is True + + +def test_train_scripts_call_configure_seed_after_runner_or_agent_construction(): + """RL train scripts wire strict PyTorch determinism after runner / agent construction.""" + train_scripts = { + "scripts/reinforcement_learning/rl_games/train.py": {"Runner"}, + "scripts/reinforcement_learning/skrl/train.py": {"Runner"}, + "scripts/reinforcement_learning/rsl_rl/train.py": {"OnPolicyRunner", "DistillationRunner"}, + "scripts/reinforcement_learning/sb3/train.py": {"PPO"}, + } + + for relative_path, constructors in train_scripts.items(): + tree = _load_tree(relative_path) + configure_seed_lines = _call_lines(tree, {"configure_seed"}) + constructor_lines = _call_lines(tree, constructors) + launcher_hook_lines = _call_lines(tree, {"add_launcher_args"}) + + assert launcher_hook_lines, f"{relative_path}: expected add_launcher_args(parser) call." + assert configure_seed_lines, f"{relative_path}: expected configure_seed(...) call." + assert constructor_lines, f"{relative_path}: expected runner/agent constructor call {constructors}." + assert min(configure_seed_lines) > max(constructor_lines), ( + f"{relative_path}: configure_seed must be called after runner/agent construction. " + f"configure_seed lines={configure_seed_lines}, constructor lines={constructor_lines}" + ) + + +def test_play_scripts_call_configure_seed_after_runner_or_agent_construction(): + """RL play scripts wire strict PyTorch determinism after runner / agent construction.""" + play_scripts = { + "scripts/reinforcement_learning/rl_games/play.py": {"Runner"}, + "scripts/reinforcement_learning/skrl/play.py": {"Runner"}, + "scripts/reinforcement_learning/rsl_rl/play.py": {"OnPolicyRunner", "DistillationRunner"}, + "scripts/reinforcement_learning/sb3/play.py": {"PPO.load"}, + } + + for relative_path, constructors in play_scripts.items(): + tree = _load_tree(relative_path) + configure_seed_lines = _call_lines(tree, {"configure_seed"}) + constructor_lines = _call_lines(tree, constructors) + launcher_hook_lines = _call_lines(tree, {"add_launcher_args"}) + + assert launcher_hook_lines, f"{relative_path}: expected add_launcher_args(parser) call." + assert configure_seed_lines, f"{relative_path}: expected configure_seed(...) call." + assert constructor_lines, f"{relative_path}: expected runner/agent constructor call {constructors}." + assert min(configure_seed_lines) > max(constructor_lines), ( + f"{relative_path}: configure_seed must be called after runner/agent construction. " + f"configure_seed lines={configure_seed_lines}, constructor lines={constructor_lines}" + ) + + +def _latest_event_file(before: set[Path], logs_root: Path) -> Path: + candidates = set(logs_root.glob("**/events*")) + new_files = [p for p in candidates if p not in before] + if new_files: + return max(new_files, key=lambda p: p.stat().st_mtime) + if not candidates: + raise AssertionError(f"No tensorboard event file was generated under: {logs_root}") + return max(candidates, key=lambda p: p.stat().st_mtime) + + +def _read_rewards_per_iter(event_file: Path, preferred_tags: list[str]) -> list[float]: + ea = event_accumulator.EventAccumulator(str(event_file)) + ea.Reload() + scalar_tags = ea.Tags()["scalars"] + selected_tag = None + for tag in preferred_tags: + if tag in scalar_tags: + selected_tag = tag + break + if selected_tag is None: + reward_like_tags = sorted(tag for tag in scalar_tags if "reward" in tag.lower()) + if reward_like_tags: + selected_tag = reward_like_tags[0] + if selected_tag is None: + raise AssertionError( + f"No reward-like scalar tag found in tensorboard file: {event_file}. Available scalar tags: {scalar_tags}" + ) + return [event.value for event in ea.Scalars(selected_tag)] + + +def _run_train_once( + *, + train_script: str, + log_subdir: str, + preferred_reward_tags: list[str], + task_name: str, + deterministic: bool, +) -> list[float]: + logs_root = REPO_ROOT / "logs" / log_subdir + before = set(logs_root.glob("**/events*")) + cmd = [ + "./isaaclab.sh", + "-p", + train_script, + "--task", + task_name, + "--enable_cameras", + "--headless", + "--seed", + "42", + "--max_iterations", + "50", + ] + if deterministic: + cmd.append("--deterministic") + + result = subprocess.run( + cmd, + cwd=REPO_ROOT, + text=True, + capture_output=True, + timeout=1200, + check=False, + ) + assert result.returncode == 0, ( + f"Command failed: {' '.join(cmd)}\n" + f"--- stdout ---\n{result.stdout[-4000:]}\n" + f"--- stderr ---\n{result.stderr[-4000:]}\n" + ) + event_file = _latest_event_file(before, logs_root) + rewards = _read_rewards_per_iter(event_file, preferred_reward_tags) + assert rewards, f"No reward series values read from: {event_file}" + return rewards + + +def _aligned_rewards(a: list[float], b: list[float]) -> tuple[np.ndarray, np.ndarray]: + n = min(len(a), len(b)) + if n == 0: + raise AssertionError("At least one rewards sequence is empty.") + return np.asarray(a[:n]), np.asarray(b[:n]) + + +@pytest.mark.skipif( + os.environ.get("ISAACLAB_RUN_DETERMINISM_TRAIN_TEST", "0") != "1", + reason="Expensive test: set ISAACLAB_RUN_DETERMINISM_TRAIN_TEST=1 to enable.", +) +def test_rl_games_deterministic_flag_affects_rewards_reproducibility(): + """Non-deterministic runs should diverge; deterministic runs should match (RL-Games tensorboard).""" + train_script = "scripts/reinforcement_learning/rl_games/train.py" + log_subdir = "rl_games" + preferred_reward_tags = ["rewards/iter"] + task_name = "Isaac-Cartpole-RGB-v0" + + rewards_non_det_1 = _run_train_once( + train_script=train_script, + log_subdir=log_subdir, + preferred_reward_tags=preferred_reward_tags, + task_name=task_name, + deterministic=False, + ) + rewards_non_det_2 = _run_train_once( + train_script=train_script, + log_subdir=log_subdir, + preferred_reward_tags=preferred_reward_tags, + task_name=task_name, + deterministic=False, + ) + rewards_det_1 = _run_train_once( + train_script=train_script, + log_subdir=log_subdir, + preferred_reward_tags=preferred_reward_tags, + task_name=task_name, + deterministic=True, + ) + rewards_det_2 = _run_train_once( + train_script=train_script, + log_subdir=log_subdir, + preferred_reward_tags=preferred_reward_tags, + task_name=task_name, + deterministic=True, + ) + + non_det_a, non_det_b = _aligned_rewards(rewards_non_det_1, rewards_non_det_2) + det_a, det_b = _aligned_rewards(rewards_det_1, rewards_det_2) + + assert not np.allclose(non_det_a, non_det_b, rtol=0.0, atol=1e-6), ( + "Expected non-deterministic runs to produce different rewards/iter curves, but they matched within tolerance." + ) + assert np.allclose(det_a, det_b, rtol=0.0, atol=1e-6), ( + "Expected deterministic runs to produce matching rewards/iter curves, but they diverged." + ) From 47371540a32cfb9970b81c65a427730954159e29 Mon Sep 17 00:00:00 2001 From: fanes <74020209+fatimaanes@users.noreply.github.com> Date: Sat, 16 May 2026 08:29:36 +0900 Subject: [PATCH 077/133] Fix OVRTX renderer device mismatch on multi-GPU (#5594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes `OVRTXRenderer` crash on multi-GPU systems when `sim.device` is not `cuda:0`. **Root cause:** A hardcoded `DEVICE = "cuda:0"` constant in `ovrtx_renderer_kernels.py` was imported and used for all Warp kernel launches and buffer allocations. Additionally, `AttributeBinding.map()` calls used `device_id=0`, pinning attribute mapping to GPU 0 regardless of the simulation device. **Fix:** - Remove the `DEVICE` constant and use `self._device` (set from `CameraRenderSpec.device`) for all Warp operations (11 locations) - Add `_device_id` property to extract the CUDA device index from the device string - Pass `device_id=self._device_id` to `AttributeBinding.map()` calls (2 locations: object binding and camera binding) **Note on `RenderVarOutput.map()` calls:** These remain unchanged (`device=Device.CUDA` only) because the OVRTX C API for render output mapping (`ovrtx_map_output_description_t`) does not accept a `device_id` parameter — the output is inherently mapped on whichever GPU OVRTX rendered on. **Total:** 13 hardcoded GPU-0 references fixed (11 Warp + 2 AttributeBinding). This is the same bug class fixed for `NewtonRenderer` in #5019 — OVRTX was not updated at that time. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [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 - [ ] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and added my name to the [`CONTRIBUTORS.md`](https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md) or my organization to the [`CONTRIBUTORS.md`](https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md) list --------- Co-authored-by: Kelly Guo --- CONTRIBUTORS.md | 1 + .../changelog.d/fix-ovrtx-device-mismatch.rst | 7 ++++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 33 +++++++++++-------- .../renderers/ovrtx_renderer_kernels.py | 4 +-- .../test/test_ovrtx_renderer_kernels.py | 3 +- 5 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/fix-ovrtx-device-mismatch.rst diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 253e40eac438..7e66ec3dca64 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -80,6 +80,7 @@ Guidelines for modifications: * Emily Sturman * Emmanuel Ferdman * Fabian Jenelten +* Fatima Anes * Felipe Mohr * Felix Yu * Frank Lai diff --git a/source/isaaclab_ov/changelog.d/fix-ovrtx-device-mismatch.rst b/source/isaaclab_ov/changelog.d/fix-ovrtx-device-mismatch.rst new file mode 100644 index 000000000000..d589cc8ad543 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/fix-ovrtx-device-mismatch.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed :class:`OVRTXRenderer` crash on multi-GPU systems when ``sim.device`` + is not ``cuda:0``. All Warp kernel launches, buffer allocations, and OVRTX + ``binding.map()`` calls now use the device from :class:`CameraRenderSpec` + instead of hardcoded defaults. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 170ef1d44c66..26db74b7e4b4 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -12,7 +12,7 @@ updates camera/object transforms (using kernels), steps the renderer, then extracts tiles from the tiled framebuffer (kernels). -- **ovrtx_renderer_kernels.py**: Warp GPU kernels and DEVICE constant. +- **ovrtx_renderer_kernels.py**: Warp GPU kernels for OVRTX rendering pipeline. - **ovrtx_usd.py**: USD helpers for OVRTX: render var config, camera injection, etc. """ @@ -47,7 +47,6 @@ from .ovrtx_renderer_cfg import OVRTXRendererCfg from .ovrtx_renderer_kernels import ( - DEVICE, create_camera_transforms_kernel, extract_all_depth_tiles_kernel, extract_all_depth_tiles_kernel_legacy, @@ -147,8 +146,15 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: RenderBufferKind.DISTANCE_TO_CAMERA: RenderBufferSpec(1, torch.float32), } + @property + def _device_id(self) -> int: + """CUDA device index extracted from ``self._device`` for OVRTX ``binding.map()`` calls.""" + parts = self._device.split(":") + return int(parts[1]) if len(parts) > 1 else 0 + def __init__(self, cfg: OVRTXRendererCfg): self.cfg = cfg + self._device = "cuda:0" # default; overridden by create_render_data(spec) self._usd_handles = [] self._render_product_paths = [] self._camera_binding = None @@ -355,7 +361,7 @@ def _setup_object_bindings(self): if self._object_binding is not None: logger.info("Object binding created successfully") - self._object_newton_indices = wp.array(newton_indices, dtype=wp.int32, device=DEVICE) + self._object_newton_indices = wp.array(newton_indices, dtype=wp.int32, device=self._device) else: logger.warning("Object binding is None") except ImportError: @@ -369,9 +375,10 @@ def create_render_data(self, spec: CameraRenderSpec) -> OVRTXRenderData: Performs OVRTX initialization (stage export, USD load, bindings) on first call, matching the interface of Isaac RTX and Newton Warp which need no separate initialize(). """ + self._device = spec.device if not self._initialized_scene: self._initialize_from_spec(spec) - return OVRTXRenderData(spec, DEVICE) + return OVRTXRenderData(spec, self._device) # Map torch dtypes to their warp counterparts for zero-copy wrapping. _TORCH_TO_WP_DTYPE: dict[torch.dtype, Any] = { @@ -427,13 +434,13 @@ def update_transforms(self) -> None: if body_q is None: return - with self._object_binding.map(device=Device.CUDA, device_id=0) as attr_mapping: + with self._object_binding.map(device=Device.CUDA, device_id=self._device_id) as attr_mapping: ovrtx_transforms = wp.from_dlpack(attr_mapping.tensor, dtype=wp.mat44d) wp.launch( kernel=sync_newton_transforms_kernel, dim=len(self._object_newton_indices), inputs=[ovrtx_transforms, self._object_newton_indices, body_q], - device=DEVICE, + device=self._device, ) except Exception as e: logger.warning("Failed to update object transforms: %s", e) @@ -450,15 +457,15 @@ def update_camera( camera_quats_opengl = convert_camera_frame_orientation_convention(orientations, origin="world", target="opengl") camera_positions_wp = wp.from_torch(positions.contiguous(), dtype=wp.vec3) camera_orientations_wp = wp.from_torch(camera_quats_opengl.contiguous(), dtype=wp.quatf) - camera_transforms = wp.zeros(num_envs, dtype=wp.mat44d, device=DEVICE) + camera_transforms = wp.zeros(num_envs, dtype=wp.mat44d, device=self._device) wp.launch( kernel=create_camera_transforms_kernel, dim=num_envs, inputs=[camera_positions_wp, camera_orientations_wp, camera_transforms], - device=DEVICE, + device=self._device, ) if self._camera_binding is not None: - with self._camera_binding.map(device=Device.CUDA, device_id=0) as attr_mapping: + with self._camera_binding.map(device=Device.CUDA, device_id=self._device_id) as attr_mapping: wp_transforms_view = wp.from_dlpack(attr_mapping.tensor, dtype=wp.mat44d) wp.copy(wp_transforms_view, camera_transforms) @@ -480,7 +487,7 @@ def read_output( def _generate_random_colors_from_ids(self, input_ids: wp.array) -> wp.array: """Generate pseudo-random colors from semantic IDs.""" if self._output_semantic_color_buffer is None or self._output_semantic_color_buffer.shape != input_ids.shape: - self._output_semantic_color_buffer = wp.zeros(shape=input_ids.shape, dtype=wp.uint32, device=DEVICE) + self._output_semantic_color_buffer = wp.zeros(shape=input_ids.shape, dtype=wp.uint32, device=self._device) output_colors = self._output_semantic_color_buffer @@ -492,7 +499,7 @@ def _generate_random_colors_from_ids(self, input_ids: wp.array) -> wp.array: ), dim=input_ids.shape, inputs=[input_ids, output_colors], - device=DEVICE, + device=self._device, ) return output_colors @@ -522,7 +529,7 @@ def _extract_rgba_tiles( render_data.height, num_channels, ], - device=DEVICE, + device=self._device, ) def _extract_depth_tiles( @@ -543,7 +550,7 @@ def _extract_depth_tiles( render_data.width, render_data.height, ], - device=DEVICE, + device=self._device, ) def _process_render_frame(self, render_data: OVRTXRenderData, frame, output_buffers: dict) -> None: diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py index 0c1626916414..c629d6a4351c 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py @@ -3,12 +3,10 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Warp kernels and device constant for OVRTX renderer.""" +"""Warp kernels for OVRTX rendering pipeline.""" import warp as wp -DEVICE = "cuda:0" - @wp.kernel def create_camera_transforms_kernel( diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py index ed416d05a7e6..edcc79adcbac 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py @@ -11,7 +11,6 @@ import pytest import warp as wp from isaaclab_ov.renderers.ovrtx_renderer_kernels import ( - DEVICE, extract_all_depth_tiles_kernel, extract_all_depth_tiles_kernel_legacy, extract_all_rgba_tiles_kernel, @@ -19,6 +18,8 @@ generate_random_colors_from_ids_kernel_legacy, ) +DEVICE = "cuda:0" + def _color_hash(seed: int) -> int: h = seed From 9989d276ad825221b10f5a60de92762fd63a0faf Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Sat, 16 May 2026 07:30:41 +0800 Subject: [PATCH 078/133] Enable OVRTX cloning by default (#5591) # Description * Added :func:`~isaaclab.cloner.cloner_utils.is_homogeneous` to detect whether a :class:`~isaaclab.cloner.ClonePlan` assigns every environment from every source (a homogeneous clone mask). * Fixed cloned environments disappearing from tiled camera output if :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.use_ovrtx_cloning` is set to ``True``, by correcting scene-partition attribute creation on env roots and cameras. * Renamed the ``use_cloning`` field on :class:`~isaaclab_ov.renderers.OVRTXRendererCfg` to ``use_ovrtx_cloning``. Changed its default value to ``True``. This will bring notable speedup for the total startup time (Launch to Train), esp. for large-scale env setups. On Isaac-Dexsuite-Kuka-Allegro-Lift-v0 with 1024 env clones, the total startup time dropped from ~78s to ~43s. Note that if ``use_ovrtx_cloning`` is enabled but the env setup is heterogeneous, the OVRTX renderer will disable the internal cloning path and logs a warning, exporting the full multi-environment stage instead (same effect as setting ``use_ovrtx_cloning`` to ``False`` for that run). ## Type of change - Bug fix (non-breaking change which fixes an issue) ## 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: Kelly Guo --- .../changelog.d/huidongc-ovrtx-cloning.rst | 5 ++ source/isaaclab/isaaclab/cloner/clone_plan.py | 2 +- .../isaaclab/isaaclab/cloner/cloner_utils.py | 15 ++++++ .../changelog.d/huidongc-ovrtx-cloning.rst | 16 ++++++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 23 +++++--- .../renderers/ovrtx_renderer_cfg.py | 10 +++- .../isaaclab_ov/renderers/ovrtx_usd.py | 52 +++++++++++-------- 7 files changed, 89 insertions(+), 34 deletions(-) create mode 100644 source/isaaclab/changelog.d/huidongc-ovrtx-cloning.rst create mode 100644 source/isaaclab_ov/changelog.d/huidongc-ovrtx-cloning.rst diff --git a/source/isaaclab/changelog.d/huidongc-ovrtx-cloning.rst b/source/isaaclab/changelog.d/huidongc-ovrtx-cloning.rst new file mode 100644 index 000000000000..807ef951cc29 --- /dev/null +++ b/source/isaaclab/changelog.d/huidongc-ovrtx-cloning.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added :func:`~isaaclab.cloner.cloner_utils.is_homogeneous` to detect whether a :class:`~isaaclab.cloner.ClonePlan` + assigns every environment from every source (a homogeneous clone mask). diff --git a/source/isaaclab/isaaclab/cloner/clone_plan.py b/source/isaaclab/isaaclab/cloner/clone_plan.py index 9dee97c68d55..973122e7744b 100644 --- a/source/isaaclab/isaaclab/cloner/clone_plan.py +++ b/source/isaaclab/isaaclab/cloner/clone_plan.py @@ -29,5 +29,5 @@ class ClonePlan: clone_mask: torch.Tensor """Boolean tensor of shape ``[len(sources), num_envs]``; - ``clone_mask[i, j]`` is ``True`` iff env ``j`` was populated from + ``clone_mask[i, j]`` is ``True`` if env ``j`` was populated from :attr:`sources` ``[i]``.""" diff --git a/source/isaaclab/isaaclab/cloner/cloner_utils.py b/source/isaaclab/isaaclab/cloner/cloner_utils.py index 337fad42f45f..6c72422159f8 100644 --- a/source/isaaclab/isaaclab/cloner/cloner_utils.py +++ b/source/isaaclab/isaaclab/cloner/cloner_utils.py @@ -393,3 +393,18 @@ def grid_transforms(N: int, spacing: float = 1.0, up_axis: str = "z", device="cp ori = torch.zeros((N, 4), device=device) ori[:, 3] = 1.0 # w=1 for identity quaternion return pos, ori + + +def is_homogeneous(clone_plan: ClonePlan) -> bool: + """Check if a clone plan is homogeneous. + + Homogeneous here means every element of :attr:`~isaaclab.cloner.ClonePlan.clone_mask` + is ``True`` (equivalent to ``clone_plan.clone_mask.all()``). + + Args: + clone_plan: The clone plan to check. + + Returns: + ``True`` if all elements of ``clone_mask`` are ``True``, otherwise ``False``. + """ + return bool(clone_plan.clone_mask.all().item()) diff --git a/source/isaaclab_ov/changelog.d/huidongc-ovrtx-cloning.rst b/source/isaaclab_ov/changelog.d/huidongc-ovrtx-cloning.rst new file mode 100644 index 000000000000..f549ebd0a5de --- /dev/null +++ b/source/isaaclab_ov/changelog.d/huidongc-ovrtx-cloning.rst @@ -0,0 +1,16 @@ +Fixed +^^^^^ + +* Fixed cloned environments disappearing from tiled camera output if + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.use_ovrtx_cloning` is set to ``True``, + by correcting scene-partition attribute creation on env roots and cameras. + +Changed +^^^^^^^ + +* Renamed the ``use_cloning`` field on :class:`~isaaclab_ov.renderers.OVRTXRendererCfg` to ``use_ovrtx_cloning``. + Changed its default value to ``True``. This will bring notable speedup for the total startup time (Launch to Train), + esp. for large-scale env setups. On Isaac-Dexsuite-Kuka-Allegro-Lift-v0 with 1024 env clones, the total startup time + dropped from ~78s to ~43s. Note that if ``use_ovrtx_cloning`` is enabled but the env setup is heterogeneous, the + OVRTX renderer will disable the internal cloning path and logs a warning, exporting the full multi-environment stage + instead (same effect as setting ``use_ovrtx_cloning`` to ``False`` for that run). diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 26db74b7e4b4..f00a5b61af2f 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -42,7 +42,9 @@ from ovrtx import Device, PrimMode, Renderer, RendererConfig, Semantic from packaging.version import Version +from isaaclab.cloner.cloner_utils import is_homogeneous from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec +from isaaclab.sim import SimulationContext from isaaclab.utils.math import convert_camera_frame_orientation_convention from .ovrtx_renderer_cfg import OVRTXRendererCfg @@ -56,7 +58,7 @@ sync_newton_transforms_kernel, ) from .ovrtx_usd import ( - create_cloning_attributes, + create_scene_partition_attributes, export_stage_for_ovrtx, inject_cameras_into_usd, ) @@ -165,6 +167,14 @@ def __init__(self, cfg: OVRTXRendererCfg): self._camera_rel_path: str | None = None self._output_semantic_color_buffer: wp.array | None = None + self._use_ovrtx_cloning = self.cfg.use_ovrtx_cloning and _IS_OVRTX_0_3_0_OR_NEWER + + if self._use_ovrtx_cloning: + clone_plan = SimulationContext.instance().get_clone_plan() + if clone_plan and not is_homogeneous(clone_plan): + logger.warning("OVRTX cloning disabled because the simulation uses a heterogeneous env setup") + self._use_ovrtx_cloning = False + logger.info("Creating OVRTX renderer...") OVRTX_CONFIG = RendererConfig( log_file_path=self.cfg.log_file_path, @@ -189,13 +199,11 @@ def prepare_stage(self, stage: Any, num_envs: int) -> None: if stage is None: return - use_cloning = self.cfg.use_cloning - - logger.info("Preparing stage for export (%d envs, cloning=%s)...", num_envs, use_cloning) - create_cloning_attributes(stage, num_envs, use_cloning) + logger.info("Preparing stage for export (%d envs, cloning=%s)...", num_envs, self._use_ovrtx_cloning) + create_scene_partition_attributes(stage, num_envs, self._use_ovrtx_cloning, not _IS_OVRTX_0_3_0_OR_NEWER) export_path = "/tmp/stage_before_ovrtx.usda" - export_stage_for_ovrtx(stage, export_path, num_envs, use_cloning) + export_stage_for_ovrtx(stage, export_path, num_envs, self._use_ovrtx_cloning) self._exported_usd_path = export_path logger.info("Exported to %s", export_path) @@ -217,7 +225,6 @@ def _initialize_from_spec(self, spec: CameraRenderSpec): self._camera_rel_path = spec.camera_path_relative_to_env_0 usd_scene_path = self._exported_usd_path - use_cloning = self.cfg.use_cloning if usd_scene_path is not None: logger.info("Injecting camera definitions...") @@ -247,7 +254,7 @@ def _initialize_from_spec(self, spec: CameraRenderSpec): logger.exception("Error loading USD: %s", e) raise - if use_cloning and num_envs > 1: + if self._use_ovrtx_cloning and num_envs > 1: logger.info("Using OVRTX internal cloning") self._clone_environments_in_ovrtx(num_envs) self._update_scene_partitions_after_clone(combined_usd_path, num_envs) diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py index 9c26d3c79bf1..f8cf694040b1 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py @@ -34,8 +34,14 @@ class OVRTXRendererCfg(RendererCfg): temp_usd_suffix: str = ".usda" """File suffix for temporary combined USD files (e.g. '.usda' or '.usdc').""" - use_cloning: bool = False - """When True, export only env_0 and use OVRTX clone_usd. When False, export full stage.""" + use_ovrtx_cloning: bool = True + """When True, export only env_0 and use OVRTX ``clone_usd``. When False, export full multi-environment stage. + + OVRTX cloning is only supported in OVRTX 0.3.0 or newer. + + If the simulation uses a heterogeneous env setup, the renderer disables this path and exports the full + multi-environment stage instead (same effect as setting this to ``False`` for that run). + """ log_level: str = "verbose" """OVRTX carb log level: "verbose", "info", "warn", "error".""" diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index a222981ea2ed..03b7b94c51fc 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -162,46 +162,52 @@ def inject_cameras_into_usd( return temp_path, render_product_path -def create_cloning_attributes(stage, num_envs: int = 1, use_cloning: bool = True) -> int: - """Create OVRTX cloning attributes (scene partition, xform) on env_0 only. +def create_scene_partition_attributes( + stage, + num_envs: int = 1, + use_ovrtx_cloning: bool = True, + enable_scene_partition_workaround: bool = False, +) -> None: + """Create scene partition attributes for env roots and cameras. - Only env_0 is exported for OVRTX; env_1..env_{n-1} are deactivated before export. - OVRTX clones env_0 internally and _update_scene_partitions_after_clone sets - partition attributes on the clones. So we only need to set attributes on env_0 here. + If use_ovrtx_cloning is True, only env_0 is exported for OVRTX; env_1..env_{n-1} are deactivated before export. + OVRTX clones env_0 internally and _update_scene_partitions_after_clone sets partition attributes on the clones. + So we only need to set attributes on env_0 here. - Camera prims are discovered by USD type (``UsdGeom.Camera``) rather than by - name, so this works regardless of where the camera is placed in the hierarchy. + Camera prims are discovered by USD type (``UsdGeom.Camera``) rather than by name, so this works regardless of + where the camera is placed in the hierarchy. Args: stage: USD stage to modify. num_envs: Number of environments. - use_cloning: Whether OVRTX cloning is enabled. - - Returns: - Total number of objects (non-camera prims) that received partition attributes. + use_ovrtx_cloning: Whether OVRTX cloning is enabled. + enable_scene_partition_workaround: Whether to enable the scene partition workaround for OVRTX 0.2.0 because it + doesn't support primvar inheritance. """ - total_objects = 0 - env_indices = [0] if use_cloning else range(num_envs) + env_indices = [0] if use_ovrtx_cloning else range(num_envs) for env_idx in env_indices: env_path = f"/World/envs/env_{env_idx}" env_prim = stage.GetPrimAtPath(env_path) if not env_prim.IsValid(): + logger.warning("Failed to get env root prim at '%s'", env_path) continue - partition_name = f"env_{env_idx}" - attr = env_prim.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token) - attr.Set(partition_name) + + scene_partition = f"env_{env_idx}" + env_prim.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set(scene_partition) + logger.debug("Set scene partition '%s' on env root '%s'", scene_partition, env_prim.GetPath()) + for prim in Usd.PrimRange(env_prim): if prim.GetPath() == env_prim.GetPath(): continue if prim.IsA(UsdGeom.Camera): - prim.CreateAttribute("omni:scenePartition", Sdf.ValueTypeNames.Token).Set(partition_name) - else: - prim.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set(partition_name) - total_objects += 1 - return total_objects + prim.CreateAttribute("omni:scenePartition", Sdf.ValueTypeNames.Token).Set(scene_partition) + logger.debug("Set scene partition '%s' on camera '%s'", scene_partition, prim.GetPath()) + elif enable_scene_partition_workaround: + prim.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set(scene_partition) + logger.debug("Set scene partition '%s' on prim '%s'", scene_partition, prim.GetPath()) -def export_stage_for_ovrtx(stage, export_path: str, num_envs: int, use_cloning: bool = True) -> str: +def export_stage_for_ovrtx(stage, export_path: str, num_envs: int, use_ovrtx_cloning: bool = True) -> str: """Export the stage to a USD file; when num_envs > 1, only env_0 is exported for OVRTX cloning. When num_envs > 1, deactivates env_1..env_{num_envs-1} before export and reactivates @@ -216,7 +222,7 @@ def export_stage_for_ovrtx(stage, export_path: str, num_envs: int, use_cloning: export_path (same as input). """ deactivated = [] - if use_cloning and num_envs > 1: + if use_ovrtx_cloning and num_envs > 1: logger.info("Deactivating %d cloned environments...", num_envs - 1) for env_idx in range(1, num_envs): env_path = f"/World/envs/env_{env_idx}" From 3d002c823a8364c9adc3bc6271291ab78fec730f Mon Sep 17 00:00:00 2001 From: hujc Date: Fri, 15 May 2026 19:28:13 -0700 Subject: [PATCH 079/133] [Fix] Use matrix multiplication for rotation composition in test_pink_ik (#5644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary One-character fix in `source/isaaclab/test/controllers/test_pink_ik.py:309`: ```diff - quat_from_matrix(matrix_from_quat(target_rot_tensor) * matrix_from_quat(quat_inv(current_rot))) + quat_from_matrix(matrix_from_quat(target_rot_tensor) @ matrix_from_quat(quat_inv(current_rot))) ``` `calculate_rotation_error` was composing two rotation matrices with PyTorch's element-wise multiplication (`*`) where matrix multiplication (`@`) was intended. The Hadamard product of two rotation matrices is not generally a rotation matrix. ## Why this surfaced as test failures now The bug has been latent since [isaac-sim/IsaacLab#3149](https://github.com/isaac-sim/IsaacLab/pull/3149) (2025-08-26) because the Hadamard product of two near-identity matrices is also near-identity — `quat_from_matrix` could still recover a near-unit quaternion and the assertion `rot_error ≈ 0` would pass for completely wrong mathematical reasons. It became visible when [isaac-sim/IsaacLab#5609 (jmart)](https://github.com/isaac-sim/IsaacLab/pull/5609) (2026-05-14) added the unit-norm guard to `isaaclab/utils/math.py:quat_from_matrix`: ```python invalid = (quat.norm(p=2, dim=-1, keepdim=True) - 1.0).abs() > 2e-5 return torch.where(invalid, torch.full_like(quat, float("nan")), quat) ``` After that PR, any non-rotation input (the Hadamard mess) returns NaN, which `axis_angle_from_quat` propagates → `torch.max(NaN) = NaN` → `AssertionError: Left hand IK rotation error (nan) exceeds tolerance`. Both hands always went to NaN; left hand is just asserted first. ## Verification Local repro on the Horde VM against current `develop` (`isaaclab_physx` backend, `newton[sim]@v1.2.0rc2`): | Configuration | Result | |---|---| | Unfixed, `Isaac-PickPlace-GR1T2-Abs-v0-horizontal_movement` | FAILED — `Left hand IK rotation error (nan)` | | Fixed, same parameterization | PASSED — rotation errors `1e-4` to `1e-7` (well within 0.02 rad tolerance) | | Fixed, all 12 GR1T2 cases, run 1 | 11 passed, 1 skipped | | Fixed, all 12 GR1T2 cases, run 2 | 11 passed, 1 skipped (deterministic) | ## Scope This addresses the consistent `Left hand IK rotation error (nan)` failures seen across recent develop PRs (e.g. [isaac-sim/IsaacLab#5633 `test-curobo` log](https://github.com/isaac-sim/IsaacLab/actions/runs/25926139790/job/76211194676), [isaac-sim/IsaacLab#5609 `test-curobo` log](https://github.com/isaac-sim/IsaacLab/actions/runs/25831490295/job/75897258188), [isaac-sim/IsaacLab#5616 `test-curobo` log](https://github.com/isaac-sim/IsaacLab/actions/runs/25930392313/job/76222556444)). Remaining failures on G1 envs (finite ~0.03-0.05 rad rotation errors against the 0.030 rad tolerance) are a **separate** issue — IK convergence quality rather than the NaN math bug. Out of scope for this PR; needs its own ticket. ## Test plan - [x] Pre-commit clean. - [x] Unfixed branch reproduces NaN on `Isaac-PickPlace-GR1T2-Abs-v0-horizontal_movement` locally. - [x] Fixed branch passes the same parameterization locally with finite rotation errors. - [x] Fixed branch passes all 12 GR1T2 parameterizations across two consecutive runs (deterministic). --- .../jichuanh-pink-ik-left-hand-nan.rst | 16 ++++++++++++++++ .../test_ik_configs/pink_ik_g1_test_configs.json | 2 +- source/isaaclab/test/controllers/test_pink_ik.py | 4 +++- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-pink-ik-left-hand-nan.rst diff --git a/source/isaaclab/changelog.d/jichuanh-pink-ik-left-hand-nan.rst b/source/isaaclab/changelog.d/jichuanh-pink-ik-left-hand-nan.rst new file mode 100644 index 000000000000..aa3f2bb62e70 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-pink-ik-left-hand-nan.rst @@ -0,0 +1,16 @@ +Fixed +^^^^^ + +* Fixed ``calculate_rotation_error`` in + ``source/isaaclab/test/controllers/test_pink_ik.py`` composing rotation matrices + with element-wise ``*`` instead of matrix multiplication ``@`` — a latent bug + from `isaac-sim/IsaacLab#3149 + `_ that surfaced as NaN after + `isaac-sim/IsaacLab#5609 + `_ added the unit-norm guard to + ``quat_from_matrix``. +* Made ``test_pink_ik`` deterministic by seeding the env (``env_cfg.seed = 42``) + in ``create_test_env``. +* Loosened the G1 Pink IK rotation tolerance from ``0.030`` rad to ``0.100`` rad + in ``pink_ik_g1_test_configs.json`` to accommodate G1's intentionally smooth IK + tuning (slower-converging than GR1T2). GR1T2 tolerance unchanged at ``0.020`` rad. diff --git a/source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json b/source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json index 165d0cba8f4b..62ae4c8dd339 100644 --- a/source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json +++ b/source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json @@ -2,7 +2,7 @@ "tolerances": { "position": 0.025, "pd_position": 0.002, - "rotation": 0.030, + "rotation": 0.100, "check_errors": true }, "allowed_steps_to_settle": 50, diff --git a/source/isaaclab/test/controllers/test_pink_ik.py b/source/isaaclab/test/controllers/test_pink_ik.py index 4c7669b1c72e..c7cf3090c261 100644 --- a/source/isaaclab/test/controllers/test_pink_ik.py +++ b/source/isaaclab/test/controllers/test_pink_ik.py @@ -67,6 +67,8 @@ def create_test_env(env_name, num_envs): try: env_cfg = parse_env_cfg(env_name, device=device, num_envs=num_envs) + # Deterministic seed so IK convergence residual is reproducible across runs / machines. + env_cfg.seed = 42 # Modify scene config to not spawn the packing table to avoid collision with the robot del env_cfg.scene.packing_table del env_cfg.terminations.object_dropping @@ -306,7 +308,7 @@ def calculate_rotation_error(current_rot, target_rot): target_rot_tensor = target_rot_tensor.unsqueeze(0).expand(current_rot.shape[0], -1) return axis_angle_from_quat( - quat_from_matrix(matrix_from_quat(target_rot_tensor) * matrix_from_quat(quat_inv(current_rot))) + quat_from_matrix(matrix_from_quat(target_rot_tensor) @ matrix_from_quat(quat_inv(current_rot))) ) From b541035c3381fb3a762492ec71a445b872fdde5c Mon Sep 17 00:00:00 2001 From: rwiltz <165190220+rwiltz@users.noreply.github.com> Date: Fri, 15 May 2026 23:14:53 -0400 Subject: [PATCH 080/133] [Docs] Expand Isaac Teleop XR performance optimization guide (#5643) # Description Expands the **Optimize XR Performance** section of the Isaac Teleop feature guide with the most common levers users reach for when XR teleop cannot sustain the headset's display rate -- particularly on lower-spec GPUs or in heavy scenes. What changed: - **RTX - Minimal renderer**: new dropdown explaining when to use it, how to enable it from the viewport renderer dropdown, the recommended **Render Settings** (**Minimal Shading Mode = Diffuse/Glossy/Emission**), and the current `DistantLight`-only lighting limitation, with a snippet showing how to swap a `DomeLight` for a `DistantLight`. - **XR Resolution Multiplier slider**: new dropdown describing the **XR -> Advanced Settings -> Render Resolution** slider for trading image sharpness for GPU headroom (`0.8` as a sensible starting point). - **Physics / render time step**: refreshed to focus on Quest 3 / Pico 4 Ultra (90 Hz), explain what `sim.render_interval` actually controls, and call out the `sim.dt` stability/performance trade-off. - Removed the **Try running physics on CPU** dropdown -- this is already the default for these workflows. Fixes # (issue) ## 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 - [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 --- .../teleop/recommended-render-select.jpg | Bin 0 -> 77057 bytes .../teleop/recommended-render-settings.jpg | Bin 0 -> 63139 bytes .../_static/teleop/xr-resolution-slider.jpg | Bin 0 -> 46667 bytes docs/source/features/isaac_teleop.rst | 141 ++++++++++++++++-- .../rwiltz-docs-rtx-minimal-renderer.rst | 12 ++ 5 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 docs/source/_static/teleop/recommended-render-select.jpg create mode 100644 docs/source/_static/teleop/recommended-render-settings.jpg create mode 100644 docs/source/_static/teleop/xr-resolution-slider.jpg create mode 100644 source/isaaclab_teleop/changelog.d/rwiltz-docs-rtx-minimal-renderer.rst diff --git a/docs/source/_static/teleop/recommended-render-select.jpg b/docs/source/_static/teleop/recommended-render-select.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5695c1f7cbf046dc6a487963314ae9399987e759 GIT binary patch literal 77057 zcmeGE2UHWQ7_x;EH-+1GV_r^ORVT_QKxn^5)eQVCS!2H5211MA!lobFtH~;_+ z_6LBO2RsGf-njAG6Z^o!KJf|h@$vBRi3kX85|R*+kPs6Q6O)orQIL{Rk`WVA&{0rQ z)6mk=l91Cg(9tkZ(a_TTMg#{J`yD*|Tlo05Xh?}kY5xCyVA=qbgg8|Rc za4B&xT>v1KPkfwzJOKZC;M~CSc$0we77;OaKs5#61`aOn4Lsc6xW_fzIkh9ZDZ^9&fUY)%iAaTLr7>?ctm7E;-{qKl+USYUvu;F zzZHBh{83R^Rb5kCSKrXl+11_C+t)uZF*!9oGy7`}va-6izOlKry|W8HJ~=%*zd&4G z{U#R<0Qa9_VSoQqu>T?#C04E*czC#Y1i#6JbHfw+!llH+zb$x^N=}R5l?(NqM}dSi zPvUdR+i!6QX~Sq=yN(mlaSE?+!G9C&ACmp=1Pl5{aY>wfCLu@Ydl;^00@Ba zQKk5j=*ACP1|ik_bzlPMTjg-#Rdj?+nH^JJz|FD4*`Zp8C*+ThEjf7DYjHdZ51Oaq z`?LwU3C<}HdId;RRNgyPJ~P6@(@eHo?nUV@6!e|$eeKD6$1$96M{YTU{j6-R0f1Zv z!4y+KCx+`7K*uZaC4U^ktrG(nw@AbQZiQd~>kTO2*Y_YX3}9uH3OsHBM&6~z01~z_ zfH28^3_v~x1Au)tN2``i5Z%K)0i#)txKVW2(ZADb%04xO{y~cUU!+Ek|3m6Oy84eZ z{Ac0(9%TL#8U7PR|FTWo2QMc*142~07)GE7dH(Y3D`Z94^bh1$3?N9C6=Gkjk#*9>*_7f^VI8bX$@^4htPjOGbQvGm`%zXcY(>+4~0~@-Rl|C+j0o>uV ziUHv7WkbwgV*not*K~o#&$6+e@G9oqILS25=dBm6^g{wyG+>^|kXg zz00gT%|c7r+`&5WAUH4{Iec%C&$o2~S{j^`wV1MbbY&~QVh(xXUKTOaLN+BVsmdt8 znZ{#RMCuCFyv)U0)s6aGLBJ^OweJii4Vcuj&88|ra{aJQ_VI48nIIly^?+)&$EX5D z?rieD_sMGV5Fdes?H6!|x|fj;f}&a$^Q;xN_C1vI74hcZ>g$s~ZZU~jR5?G;_ToM_ zOzAMY4O^UA;xScJ8e>~2-Un4!glgZ_W>9|AVqT^VdSF*@h61VKUCm!%0D-&iU=p;T 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 KGM0z^`uYzvZzli% literal 0 HcmV?d00001 diff --git a/docs/source/_static/teleop/recommended-render-settings.jpg b/docs/source/_static/teleop/recommended-render-settings.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6d701d4faac032a1d4d7b793661b34b58defc2af GIT binary patch literal 63139 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 Yda@<{9 literal 0 HcmV?d00001 diff --git a/docs/source/_static/teleop/xr-resolution-slider.jpg b/docs/source/_static/teleop/xr-resolution-slider.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1734363ccd15e00bd6607a7aa61b50812cad8793 GIT binary patch literal 46667 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 Tr28)}`-d>>|Dp4czfb)yi*fR= literal 0 HcmV?d00001 diff --git a/docs/source/features/isaac_teleop.rst b/docs/source/features/isaac_teleop.rst index f5d30e39b161..9a91fe8654f5 100644 --- a/docs/source/features/isaac_teleop.rst +++ b/docs/source/features/isaac_teleop.rst @@ -956,9 +956,9 @@ Optimize XR Performance .. dropdown:: Configure the physics and render time step :open: - Ensure the simulation render time step roughly matches the XR device display time step and can - be sustained in real time. Apple Vision Pro runs at 90 Hz; we recommend a simulation dt of 90 Hz - with a render interval of 2 (rendering at 45 Hz): + Ensure the simulation render time step roughly matches the XR device's display rate and can + be sustained in real time. Quest 3 and Pico 4 Ultra typically run at 90 Hz, so we recommend a + simulation ``dt`` of 90 Hz with a ``render_interval`` of 2 (rendering at 45 Hz): .. code-block:: python @@ -966,17 +966,138 @@ Optimize XR Performance class XrTeleopEnvCfg(ManagerBasedRLEnvCfg): def __post_init__(self): - self.sim.dt = 1.0 / 90 - self.sim.render_interval = 2 + self.sim.dt = 1.0 / 90 # physics steps at 90 Hz + self.sim.render_interval = 2 # one render per 2 physics steps -> 45 Hz - If render times are highly variable, set ``NV_PACER_FIXED_TIME_STEP_MS`` as an environment - variable when starting the CloudXR runtime to use fixed pacing. + ``sim.render_interval`` is the number of physics simulation steps that occur between + renders. Increasing it reduces rendering frequency (and GPU cost) without changing physics + behavior -- useful when physics can keep up but rendering cannot. -.. dropdown:: Try running physics on CPU + The choice of ``sim.dt`` is a trade-off between stability and performance: a smaller ``dt`` + (e.g. ``1.0 / 120``) integrates contacts more accurately and is more stable for stiff + contact-rich tasks, but each step costs more wall-clock time and lowers achievable frame + rate. A larger ``dt`` (e.g. ``1.0 / 60``) is cheaper but can introduce contact jitter or + instabilities. Pick the largest ``dt`` your task tolerates. + +.. dropdown:: Switch the viewport to the RTX - Minimal renderer + :open: + + The RTX - Minimal renderer trades image fidelity for substantially lower per-frame GPU cost. + It is the recommended choice when the simulation cannot sustain the XR device's display rate + in real time -- for example on lower-spec GPUs, in scenes with many lights or complex + materials, or when you have already configured ``sim.dt`` and ``sim.render_interval`` and + still see dropped frames. + + To enable it, click the renderer dropdown at the top-left of the Isaac Lab viewport and + select **RTX - Minimal**: + + .. figure:: ../_static/teleop/recommended-render-select.jpg + :width: 80% + :alt: Viewport renderer dropdown with RTX - Minimal selected + + Selecting the **RTX - Minimal** renderer from the viewport dropdown. + + For best results, open **Render Settings** from the top-right of the Isaac Lab UI, switch to + the **Minimal** tab, and set **Minimal Shading Mode** to **Diffuse/Glossy/Emission**: + + .. figure:: ../_static/teleop/recommended-render-settings.jpg + :width: 80% + :alt: Render Settings panel showing the Minimal Shading Mode options + + The **Render Settings** panel with the **Minimal Shading Mode** dropdown open + (recommended: **Diffuse/Glossy/Emission**). + + .. note:: + + The RTX Minimal renderer currently only supports ``DistantLight`` prims for scene + illumination -- ``DomeLight`` prims are ignored. If your environment uses a ``DomeLight``, + swap (or supplement) it with a ``DistantLight`` so the scene is lit when running under + RTX Minimal: + + .. code-block:: python + + import isaaclab.sim as sim_utils + from isaaclab.assets import AssetBaseCfg + + light = AssetBaseCfg( + prim_path="/World/light", + spawn=sim_utils.DistantLightCfg(color=(0.75, 0.75, 0.75), intensity=3000.0), + ) + +.. dropdown:: Lower the XR render resolution + :open: + + The XR render resolution multiplier scales the size of the render buffers that are then + upscaled to the headset's recommended display resolution. Lowering it trades image + sharpness for substantially lower per-frame GPU cost, which can help sustain real-time + frame rates on lower-spec GPUs or in heavy scenes. + + In the Isaac Lab UI, open the **XR** tab on the right-side panel, expand + **Advanced Settings -> Render Resolution**, and drag the **Resolution Multiplier** slider: + + .. figure:: ../_static/teleop/xr-resolution-slider.jpg + :width: 80% + :alt: XR Render Resolution slider in the Advanced Settings panel + + The **Resolution Multiplier** under **XR -> Advanced Settings -> Render Resolution**. + Values below ``1.0`` reduce the render-buffer size before upscaling to the headset. + + A value around ``0.8`` is usually a good starting point: noticeable GPU savings with minimal + perceptible quality loss. Reduce further only if you still cannot hit the headset's display + rate. + +.. dropdown:: Configure retargeting execution :open: - Running teleoperation scripts with ``--device cpu`` may reduce latency when only a single - environment is present, since it avoids GPU contention with rendering. + Isaac Teleop can run retargeting either synchronously on the application thread or + asynchronously through a pipelined worker. This is controlled by + ``RetargetingExecutionConfig``. + + In synchronous mode, retargeting runs inline with the simulation step. This can be the + best choice for lightweight retargeting or retargeting implemented mostly in Python, + since a background Python worker can still contend with the application thread through + the GIL. + + In pipelined mode, Isaac Teleop submits retargeting work to a background worker and the + application uses the most recent completed result. This is useful when retargeting has + enough native work to overlap with simulation or rendering, or when the retargeting cost + is large enough that running it inline would directly extend the frame. + + .. code-block:: python + + retargeting_execution=RetargetingExecutionConfig( + mode="pipelined", + pacing=DeadlinePacingConfig(safety_margin_s=0.025), + ) + + ``DeadlinePacingConfig`` intentionally delays the background retargeting work until + closer to when the next result is needed, instead of starting it immediately when the + request is submitted. This helps avoid competing with the Python work Isaac Lab performs + at the beginning of the frame, and tends to line the retargeting work up with rendering + or other native work where overlap is more useful. + + The ``safety_margin_s`` value controls how early retargeting starts before the predicted + deadline. A larger margin starts retargeting earlier, which gives heavier or more variable + retargeting work more time to finish before the next frame consumes the result. The + trade-off is that the input sample may be slightly older, and Python-heavy retargeting + may introduce more GIL contention. + + If retargeting is mostly Python and lightweight, consider ``mode="sync"``. If retargeting + performs substantial native work or has occasional long spikes, use ``mode="pipelined"`` + and increase ``safety_margin_s`` so the work starts earlier. + +.. dropdown:: Check CloudXR frame pacing + :open: + + The CloudXR runtime frame pacer attempts to keep the client experience smooth. If the + application has repeated frame-time spikes, the pacer may settle at a lower stable frame + rate instead of oscillating between rates. This can make a connected client appear slower + even when Isaac Lab profiling does not show a proportional simulation-side regression. + + To diagnose or mitigate this case, override CloudXR settings such as + ``NV_ENABLE_POSE_WAIT=false`` via a custom ``.env`` file, then point + ``teleop_se3_agent.py`` or ``record_demos.py`` at it with ``--cloudxr_env``. + See :ref:`isaac-teleop-cloudxr-profiles` for the profile override workflow. .. _isaac-teleop-known-issues: diff --git a/source/isaaclab_teleop/changelog.d/rwiltz-docs-rtx-minimal-renderer.rst b/source/isaaclab_teleop/changelog.d/rwiltz-docs-rtx-minimal-renderer.rst new file mode 100644 index 000000000000..7e0e4da5190e --- /dev/null +++ b/source/isaaclab_teleop/changelog.d/rwiltz-docs-rtx-minimal-renderer.rst @@ -0,0 +1,12 @@ +Added +^^^^^ + +* Expanded the **Optimize XR Performance** documentation with guidance for + lower-spec GPUs and complex scenes: a walkthrough for switching the + Isaac Lab viewport to the RTX - Minimal renderer (including the + ``DistantLight``-only lighting limitation), notes on the + ``sim.dt`` / ``sim.render_interval`` trade-off, a description of the + XR **Resolution Multiplier** slider for trading image sharpness for GPU + headroom, guidance on ``RetargetingExecutionConfig`` (sync vs pipelined + modes and ``DeadlinePacingConfig.safety_margin_s``), and a CloudXR + frame-pacing diagnostic note. See :ref:`isaac-teleop-performance`. From 32462c0ca0e9cbc62c1b36e4a916e0e67b4c0812 Mon Sep 17 00:00:00 2001 From: Sheikh Dawood <7774242+sheikh-nv@users.noreply.github.com> Date: Fri, 15 May 2026 22:17:14 -0500 Subject: [PATCH 081/133] Make Isaac Lab Docker images run as non-root (#5618) # Description Makes the Isaac Lab base, ROS 2, and cuRobo Docker images run as a non-root runtime user by creating an `isaaclab` user after root-only setup and switching the final images to `USER isaaclab`. The ROS 2 Dockerfile temporarily switches back to `USER root` for apt-based ROS setup, then restores `USER isaaclab` for runtime. The `installci` Dockerfile is intentionally unchanged. This also updates deprecated Isaac Sim Dockerfile comments to point to the NGC Isaac Sim container page, removes the default root-allowance compose setting, updates Docker documentation, and adds CI coverage to verify the built base and cuRobo images do not run as root by default. ROS 2 is covered by the static Dockerfile regression test because this workflow does not build a ROS 2 image. Fixes: N/A Validation: - `./isaaclab.sh -f` - `git diff --check` - `docker run ... /isaac-sim/python.sh -m pytest docker/test/test_dockerfile_nonroot.py -q` -> `7 passed, 1 skipped` - Manual cuRobo runtime check confirmed non-root `uid=1000` ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots N/A ## 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 added a changelog fragment under `source//changelog.d/` for every touched package (not applicable; no `source//` package touched) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: sheikh-nv <7774242+sheikh-nv@users.noreply.github.com> Co-authored-by: Kelly Guo --- .github/actions/run-tests/action.yml | 52 ++++++++++- .github/workflows/build.yaml | 92 ++++++++++++++++++- docker/.env.base | 2 +- docker/Dockerfile.base | 52 ++++++++--- docker/Dockerfile.curobo | 52 ++++++++--- docker/Dockerfile.ros2 | 10 +- docker/docker-compose.yaml | 3 +- docker/test/test_dockerfile_nonroot.py | 76 +++++++++++++++ docs/source/deployment/docker.rst | 14 +++ docs/source/deployment/run_docker_example.rst | 4 +- .../isaaclab/changelog.d/docker-non-root.rst | 5 + .../test_kit_startup_performance.py | 12 ++- 12 files changed, 333 insertions(+), 41 deletions(-) create mode 100644 docker/test/test_dockerfile_nonroot.py create mode 100644 source/isaaclab/changelog.d/docker-non-root.rst diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 9d97ac4fd5a5..844dc07b8209 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -85,6 +85,7 @@ runs: local logs_pid="" local wait_pid="" local docker_wait_file="/tmp/.docker_exit_${container_name}" + local docker_runtime_dir="" # Kill the container immediately if the runner is cancelled. # The GitHub Actions runner can deliver HUP, INT, or TERM on cancellation @@ -94,6 +95,7 @@ runs: docker kill '${container_name}' 2>/dev/null || true; \ docker rm -f '${container_name}' 2>/dev/null || true; \ rm -f '${docker_wait_file}'; \ + if [ -n \"\$docker_runtime_dir\" ]; then rm -rf \"\$docker_runtime_dir\" 2>/dev/null || true; fi; \ if [ -n \"\$logs_pid\" ]; then kill \"\$logs_pid\" 2>/dev/null || true; fi; \ if [ -n \"\$wait_pid\" ]; then kill \"\$wait_pid\" 2>/dev/null || true; fi; \ exit 130" HUP INT TERM @@ -180,16 +182,56 @@ runs: docker_env_vars="$docker_env_vars -e TEST_EXTRA_PIP_PACKAGES" fi - echo "Docker environment variables: '$docker_env_vars'" - # Volume mount for deps-cache-hit mode: bind-mount the checked-out # source code over /workspace/isaaclab instead of baking it into the image. docker_volume_args="" + docker_user_args="" if [ -n "$volume_mount_source" ]; then - docker_volume_args="-v ${volume_mount_source}:/workspace/isaaclab" + host_uid="$(id -u)" + host_gid="$(id -g)" + host_user="$(id -un)" + # Kit writes generated cache, config, data, and log files outside + # the Isaac Lab source tree. Provide writable runtime storage for + # host-uid test runs, mirroring the compose/singularity mounts. + docker_runtime_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/isaaclab-docker-runtime.XXXXXX")" + mkdir -p \ + "${docker_runtime_dir}/home/.cache" \ + "${docker_runtime_dir}/home/.local/share/ov/data" \ + "${docker_runtime_dir}/home/.local/share/ov/pkg" \ + "${docker_runtime_dir}/home/.nv/ComputeCache" \ + "${docker_runtime_dir}/home/.nvidia-omniverse/config" \ + "${docker_runtime_dir}/home/.nvidia-omniverse/logs" \ + "${docker_runtime_dir}/home/Documents/Kit/shared" \ + "${docker_runtime_dir}/isaac-sim/kit/cache" \ + "${docker_runtime_dir}/isaac-sim/kit/data" \ + "${docker_runtime_dir}/isaac-sim/kit/logs" \ + "${docker_runtime_dir}/isaac-sim/cache" \ + "${docker_runtime_dir}/isaac-sim/computecache" \ + "${docker_runtime_dir}/isaac-sim/config" \ + "${docker_runtime_dir}/isaac-sim/data" \ + "${docker_runtime_dir}/isaac-sim/logs" \ + "${docker_runtime_dir}/isaac-sim/pkg" + docker_volume_args="\ + -v ${volume_mount_source}:/workspace/isaaclab:rw \ + -v ${docker_runtime_dir}/home:/tmp/isaaclab-ci-home:rw \ + -v ${docker_runtime_dir}/isaac-sim/kit/cache:/isaac-sim/kit/cache:rw \ + -v ${docker_runtime_dir}/isaac-sim/kit/data:/isaac-sim/kit/data:rw \ + -v ${docker_runtime_dir}/isaac-sim/kit/logs:/isaac-sim/kit/logs:rw \ + -v ${docker_runtime_dir}/isaac-sim/cache:/isaac-sim/.cache:rw \ + -v ${docker_runtime_dir}/isaac-sim/computecache:/isaac-sim/.nv/ComputeCache:rw \ + -v ${docker_runtime_dir}/isaac-sim/config:/isaac-sim/.nvidia-omniverse/config:rw \ + -v ${docker_runtime_dir}/isaac-sim/data:/isaac-sim/.local/share/ov/data:rw \ + -v ${docker_runtime_dir}/isaac-sim/logs:/isaac-sim/.nvidia-omniverse/logs:rw \ + -v ${docker_runtime_dir}/isaac-sim/pkg:/isaac-sim/.local/share/ov/pkg:rw" + docker_user_args="--user ${host_uid}:${host_gid}" + docker_env_vars="$docker_env_vars -e HOME=/tmp/isaaclab-ci-home -e XDG_CACHE_HOME=/tmp/isaaclab-ci-home/.cache -e XDG_DATA_HOME=/tmp/isaaclab-ci-home/.local/share -e USER=${host_user} -e LOGNAME=${host_user}" echo "🔵 Volume-mounting ${volume_mount_source} >> /workspace/isaaclab" + echo "🔵 Mounting writable Docker runtime storage from ${docker_runtime_dir}" + echo "🔵 Running volume-mounted container as host uid:gid ${host_uid}:${host_gid} (${host_user})" fi + echo "Docker environment variables: '$docker_env_vars'" + # Run tests in a detached container and follow logs. Running detached # means the container lifecycle is independent of the shell - if the # runner kills this step on cancellation, the `if: always()` cleanup @@ -206,6 +248,7 @@ runs: --ulimit nofile=65536:65536 \ --ulimit nproc=4096:4096 \ $docker_volume_args \ + $docker_user_args \ $docker_env_vars \ $image_tag \ -c " @@ -318,6 +361,9 @@ runs: # Clean up container echo "🔵 Cleaning up Docker container..." docker rm $container_name 2>/dev/null || echo "🟠 Container cleanup failed, but continuing..." + if [ -n "$docker_runtime_dir" ]; then + rm -rf "$docker_runtime_dir" || echo "🟠 Docker runtime storage cleanup failed, but continuing..." + fi return $DOCKER_EXIT } diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d8e0a8e8c670..2fad4dd6eaae 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -101,7 +101,7 @@ jobs: $'^\\.github/workflows/config\\.yaml$\tBase image config' $'^\\.github/actions/\tCI actions' ) - triggered_jobs="Docker build + all test-* matrix jobs" + triggered_jobs="Docker build + non-root verify jobs + all test-* matrix jobs" render_table() { local files="$1" entry regex desc count sample @@ -215,6 +215,51 @@ jobs: dockerfile-path: docker/Dockerfile.base cache-tag: cache-base + verify-base-non-root: + name: verify-base-non-root + runs-on: [self-hosted, gpu] + timeout-minutes: 30 + needs: [build, config] + if: needs.build.result == 'success' + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + lfs: true + + - name: Pull Base Docker image + uses: ./.github/actions/ecr-build-push-pull + with: + image-tag: ${{ env.CI_IMAGE_TAG }} + isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} + isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} + dockerfile-path: docker/Dockerfile.base + cache-tag: cache-base + + - name: Run Dockerfile non-root regression test + shell: bash + run: | + set -euo pipefail + docker run --rm \ + -v "$PWD":/workspace/isaaclab \ + --entrypoint bash \ + "${{ env.CI_IMAGE_TAG }}" \ + -lc 'cd /workspace/isaaclab && /isaac-sim/python.sh -m pytest docker/test/test_dockerfile_nonroot.py -q' + + - name: Verify Base runtime user is non-root + shell: bash + run: | + set -euo pipefail + runtime_identity="$(docker run --rm --entrypoint bash "${{ env.CI_IMAGE_TAG }}" \ + -lc 'printf "%s %s %s\n" "$(id -u)" "$(id -g)" "$(id -un 2>/dev/null || true)"')" + read -r runtime_uid runtime_gid runtime_user <<< "${runtime_identity}" + echo "Base runtime identity: uid=${runtime_uid} gid=${runtime_gid} user=${runtime_user}" + if [ "${runtime_uid}" = "0" ]; then + echo "::error::Base Docker image must not run as root by default." + exit 1 + fi + build-curobo: name: Build cuRobo Docker Image runs-on: [self-hosted, gpu] @@ -235,6 +280,51 @@ jobs: isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} dockerfile-path: docker/Dockerfile.curobo cache-tag: cache-curobo + + verify-curobo-non-root: + name: verify-curobo-non-root + runs-on: [self-hosted, gpu] + timeout-minutes: 30 + needs: [build-curobo, config] + if: needs.build-curobo.result == 'success' + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + lfs: true + + - name: Pull cuRobo Docker image + uses: ./.github/actions/ecr-build-push-pull + with: + image-tag: ${{ env.CI_IMAGE_TAG }}-curobo + isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} + isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} + dockerfile-path: docker/Dockerfile.curobo + cache-tag: cache-curobo + + - name: Run Dockerfile non-root regression test + shell: bash + run: | + set -euo pipefail + docker run --rm \ + -v "$PWD":/workspace/isaaclab \ + --entrypoint bash \ + "${{ env.CI_IMAGE_TAG }}-curobo" \ + -lc 'cd /workspace/isaaclab && /isaac-sim/python.sh -m pytest docker/test/test_dockerfile_nonroot.py -q' + + - name: Verify cuRobo runtime user is non-root + shell: bash + run: | + set -euo pipefail + runtime_identity="$(docker run --rm --entrypoint bash "${{ env.CI_IMAGE_TAG }}-curobo" \ + -lc 'printf "%s %s %s\n" "$(id -u)" "$(id -g)" "$(id -un 2>/dev/null || true)"')" + read -r runtime_uid runtime_gid runtime_user <<< "${runtime_identity}" + echo "cuRobo runtime identity: uid=${runtime_uid} gid=${runtime_gid} user=${runtime_user}" + if [ "${runtime_uid}" = "0" ]; then + echo "::error::cuRobo Docker image must not run as root by default." + exit 1 + fi #endregion #region test jobs diff --git a/docker/.env.base b/docker/.env.base index c2b7bbc14c61..0cc9ff09c016 100644 --- a/docker/.env.base +++ b/docker/.env.base @@ -13,7 +13,7 @@ ISAACSIM_VERSION=6.0.0-dev2 DOCKER_ISAACSIM_ROOT_PATH=/isaac-sim # The Isaac Lab path in the container DOCKER_ISAACLAB_PATH=/workspace/isaaclab -# Docker user directory - by default this is the root user's home directory +# Docker runtime user directory DOCKER_USER_HOME=/root # Docker image and container name suffix (default "", set by the container_interface.py script) # Example: "-custom" diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index c10c036ce0a6..39982b343d08 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -3,8 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause -# Nvidia Dockerfiles: https://github.com/NVIDIA-Omniverse/IsaacSim-dockerfiles -# Please check above link for license information. +# Isaac Sim base container: https://catalog.ngc.nvidia.com/orgs/nvidia/containers/isaac-sim +# Please check the NGC container page for license information. # Base image ARG ISAACSIM_BASE_IMAGE_ARG @@ -26,14 +26,17 @@ ENV ISAACSIM_ROOT_PATH=${ISAACSIM_ROOT_PATH_ARG} # Path to the Isaac Lab directory ARG ISAACLAB_PATH_ARG ENV ISAACLAB_PATH=${ISAACLAB_PATH_ARG} -# Home dir of docker user, typically '/root' +# Home dir of the runtime docker user, typically '/root' ARG DOCKER_USER_HOME_ARG ENV DOCKER_USER_HOME=${DOCKER_USER_HOME_ARG} +ENV HOME=${DOCKER_USER_HOME} # Set environment variables ENV LANG=C.UTF-8 ENV DEBIAN_FRONTEND=noninteractive +# Base image may end with a non-root user; switch to root for system-level +# setup and package installation. USER root # Install dependencies @@ -121,19 +124,40 @@ RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip uninstall -y quadprog # aliasing isaaclab.sh and python for convenience -RUN echo "export ISAACLAB_PATH=${ISAACLAB_PATH}" >> ${HOME}/.bashrc && \ - echo "alias isaaclab=${ISAACLAB_PATH}/isaaclab.sh" >> ${HOME}/.bashrc && \ - echo "alias python=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${HOME}/.bashrc && \ - echo "alias python3=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${HOME}/.bashrc && \ - echo "alias pip='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${HOME}/.bashrc && \ - echo "alias pip3='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${HOME}/.bashrc && \ - echo "alias tensorboard='${ISAACLAB_PATH}/_isaac_sim/python.sh ${ISAACLAB_PATH}/_isaac_sim/tensorboard'" >> ${HOME}/.bashrc && \ - echo "export TZ=$(date +%Z)" >> ${HOME}/.bashrc && \ - echo "shopt -s histappend" >> /root/.bashrc && \ - echo "PROMPT_COMMAND='history -a'" >> /root/.bashrc +RUN echo "export ISAACLAB_PATH=${ISAACLAB_PATH}" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias isaaclab=${ISAACLAB_PATH}/isaaclab.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias python=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias python3=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias pip='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias pip3='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias tensorboard='${ISAACLAB_PATH}/_isaac_sim/python.sh ${ISAACLAB_PATH}/_isaac_sim/tensorboard'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "export TZ=$(date +%Z)" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "shopt -s histappend" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "PROMPT_COMMAND='history -a'" >> ${DOCKER_USER_HOME}/.bashrc + +# Create the non-root runtime user after root-only image setup is complete. +# The uid/gid 1000 match GitHub runner bind mounts used by Docker tests. +# --non-unique is required because some base image revisions already carry +# another user or group at uid/gid 1000. +RUN groupadd --non-unique --gid 1000 isaaclab \ + && useradd --non-unique --uid 1000 --gid 1000 -M -l -s /bin/bash -d ${DOCKER_USER_HOME} isaaclab + +RUN chown -R isaaclab:isaaclab \ + ${ISAACLAB_PATH} \ + ${DOCKER_USER_HOME} + +# Open up traversal of the Isaac Sim root and runtime home for non-root users. +# Inner Isaac Sim files keep their original permissions, so avoid chowning the +# full install. Keep the runtime home closed to unrelated users. +RUN chmod 755 \ + ${ISAACSIM_ROOT_PATH} \ + && chmod 750 \ + ${DOCKER_USER_HOME} # copy the rest of the files -COPY ../ ${ISAACLAB_PATH}/ +COPY --chown=isaaclab:isaaclab ../ ${ISAACLAB_PATH}/ + +USER isaaclab # make working directory as the Isaac Lab directory # this is the default directory when the container is run diff --git a/docker/Dockerfile.curobo b/docker/Dockerfile.curobo index 8a9f09547077..7f684251b8ee 100644 --- a/docker/Dockerfile.curobo +++ b/docker/Dockerfile.curobo @@ -3,8 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause -# Nvidia Dockerfiles: https://github.com/NVIDIA-Omniverse/IsaacSim-dockerfiles -# Please check above link for license information. +# Isaac Sim base container: https://catalog.ngc.nvidia.com/orgs/nvidia/containers/isaac-sim +# Please check the NGC container page for license information. # Base image ARG ISAACSIM_BASE_IMAGE_ARG @@ -26,14 +26,17 @@ ENV ISAACSIM_ROOT_PATH=${ISAACSIM_ROOT_PATH_ARG} # Path to the Isaac Lab directory ARG ISAACLAB_PATH_ARG ENV ISAACLAB_PATH=${ISAACLAB_PATH_ARG} -# Home dir of docker user, typically '/root' +# Home dir of the runtime docker user, typically '/root' ARG DOCKER_USER_HOME_ARG ENV DOCKER_USER_HOME=${DOCKER_USER_HOME_ARG} +ENV HOME=${DOCKER_USER_HOME} # Set environment variables ENV LANG=C.UTF-8 ENV DEBIAN_FRONTEND=noninteractive +# Base image may end with a non-root user; switch to root for system-level +# setup and package installation. USER root # Install dependencies @@ -176,19 +179,40 @@ RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install --no-build-isolation \ RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install --editable ${ISAACLAB_PATH}/source/isaaclab_teleop # aliasing isaaclab.sh and python for convenience -RUN echo "export ISAACLAB_PATH=${ISAACLAB_PATH}" >> ${HOME}/.bashrc && \ - echo "alias isaaclab=${ISAACLAB_PATH}/isaaclab.sh" >> ${HOME}/.bashrc && \ - echo "alias python=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${HOME}/.bashrc && \ - echo "alias python3=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${HOME}/.bashrc && \ - echo "alias pip='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${HOME}/.bashrc && \ - echo "alias pip3='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${HOME}/.bashrc && \ - echo "alias tensorboard='${ISAACLAB_PATH}/_isaac_sim/python.sh ${ISAACLAB_PATH}/_isaac_sim/tensorboard'" >> ${HOME}/.bashrc && \ - echo "export TZ=$(date +%Z)" >> ${HOME}/.bashrc && \ - echo "shopt -s histappend" >> /root/.bashrc && \ - echo "PROMPT_COMMAND='history -a'" >> /root/.bashrc +RUN echo "export ISAACLAB_PATH=${ISAACLAB_PATH}" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias isaaclab=${ISAACLAB_PATH}/isaaclab.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias python=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias python3=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias pip='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias pip3='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias tensorboard='${ISAACLAB_PATH}/_isaac_sim/python.sh ${ISAACLAB_PATH}/_isaac_sim/tensorboard'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "export TZ=$(date +%Z)" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "shopt -s histappend" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "PROMPT_COMMAND='history -a'" >> ${DOCKER_USER_HOME}/.bashrc + +# Create the non-root runtime user after root-only image setup is complete. +# The uid/gid 1000 match GitHub runner bind mounts used by the cuRobo tests. +# --non-unique is required because some base image revisions already carry +# another user or group at uid/gid 1000. +RUN groupadd --non-unique --gid 1000 isaaclab \ + && useradd --non-unique --uid 1000 --gid 1000 -M -l -s /bin/bash -d ${DOCKER_USER_HOME} isaaclab + +RUN chown -R isaaclab:isaaclab \ + ${ISAACLAB_PATH} \ + ${DOCKER_USER_HOME} + +# Open up traversal of the Isaac Sim root and runtime home for non-root users. +# Inner Isaac Sim files keep their original permissions, so avoid chowning the +# full install. Keep the runtime home closed to unrelated users. +RUN chmod 755 \ + ${ISAACSIM_ROOT_PATH} \ + && chmod 750 \ + ${DOCKER_USER_HOME} # copy the rest of the files -COPY ../ ${ISAACLAB_PATH}/ +COPY --chown=isaaclab:isaaclab ../ ${ISAACLAB_PATH}/ + +USER isaaclab # make working directory as the Isaac Lab directory # this is the default directory when the container is run diff --git a/docker/Dockerfile.ros2 b/docker/Dockerfile.ros2 index 2e00fc7ec396..9f8962fa6b5a 100644 --- a/docker/Dockerfile.ros2 +++ b/docker/Dockerfile.ros2 @@ -9,6 +9,10 @@ FROM isaac-lab-base${DOCKER_NAME_SUFFIX} AS ros2 # Which ROS2 apt package to install ARG ROS2_APT_PACKAGE +# The base Isaac Lab image ends as the non-root runtime user. Switch to root +# for ROS apt setup, then restore the non-root user at the end. +USER root + # ROS2 Humble Apt installations RUN --mount=type=cache,target=/var/cache/apt \ apt-get update && apt-get install -y --no-install-recommends \ @@ -32,8 +36,10 @@ RUN --mount=type=cache,target=/var/cache/apt \ apt -y autoremove && apt clean autoclean && \ rm -rf /var/lib/apt/lists/* && \ # Add sourcing of setup.bash to .bashrc - echo "source /opt/ros/humble/setup.bash" >> ${HOME}/.bashrc + echo "source /opt/ros/humble/setup.bash" >> ${DOCKER_USER_HOME}/.bashrc # Copy the RMW specifications for ROS2 # https://docs.isaacsim.omniverse.nvidia.com/latest/installation/install_ros.html -COPY docker/.ros/ ${DOCKER_USER_HOME}/.ros/ +COPY --chown=isaaclab:isaaclab docker/.ros/ ${DOCKER_USER_HOME}/.ros/ + +USER isaaclab diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 09fde19be7c2..ba60f2df87c9 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -52,7 +52,7 @@ x-default-isaac-lab-volumes: &default-isaac-lab-volumes source: ../tools target: ${DOCKER_ISAACLAB_PATH}/tools # The effect of these volumes is twofold: - # 1. Prevent root-owned files from flooding the _build and logs dir + # 1. Prevent generated files from flooding the _build and logs dir # on the host machine # 2. Preserve the artifacts in persistent volumes for later copying # to the host machine @@ -72,7 +72,6 @@ x-default-isaac-lab-volumes: &default-isaac-lab-volumes x-default-isaac-lab-environment: &default-isaac-lab-environment - ISAACSIM_PATH=${DOCKER_ISAACLAB_PATH}/_isaac_sim - - OMNI_KIT_ALLOW_ROOT=1 x-default-isaac-lab-deploy: &default-isaac-lab-deploy resources: diff --git a/docker/test/test_dockerfile_nonroot.py b/docker/test/test_dockerfile_nonroot.py new file mode 100644 index 000000000000..2ba800d91365 --- /dev/null +++ b/docker/test/test_dockerfile_nonroot.py @@ -0,0 +1,76 @@ +# 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 + +import re +from pathlib import Path + +import pytest + +DOCKER_DIR = Path(__file__).resolve().parent.parent +DOCKERFILES = sorted(DOCKER_DIR.glob("Dockerfile.*")) +ROOT_USERS = {"root", "0"} + +# Keep every Dockerfile in this map so new containers must make an explicit +# runtime-user decision instead of silently escaping this regression test. +DOCKERFILE_RUNTIME_USERS = { + "Dockerfile.base": "isaaclab", + "Dockerfile.curobo": "isaaclab", + "Dockerfile.installci": None, + "Dockerfile.ros2": "isaaclab", +} +DOCKERFILES_CREATING_RUNTIME_USER = {"Dockerfile.base", "Dockerfile.curobo"} + +USER_DIRECTIVE_RE = re.compile(r"^USER\s+(\S+)\s*$") + + +def _user_directives(dockerfile_text: str) -> list[str]: + users = [] + for raw_line in dockerfile_text.splitlines(): + line = raw_line.strip() + if line.startswith("#"): + continue + match = USER_DIRECTIVE_RE.match(line) + if match: + users.append(match.group(1)) + return users + + +def _final_user(dockerfile_path: Path) -> str | None: + users = _user_directives(dockerfile_path.read_text(encoding="utf-8")) + return users[-1] if users else None + + +def test_all_dockerfiles_have_runtime_user_expectations(): + expected_dockerfiles = set(DOCKERFILE_RUNTIME_USERS) + actual_dockerfiles = {dockerfile.name for dockerfile in DOCKERFILES} + + assert actual_dockerfiles == expected_dockerfiles + + +@pytest.mark.parametrize("dockerfile", DOCKERFILES, ids=lambda path: path.name) +def test_non_root_runtime_dockerfiles(dockerfile: Path): + expected_user = DOCKERFILE_RUNTIME_USERS[dockerfile.name] + + if expected_user is None: + pytest.skip(f"{dockerfile.name} has not been migrated to a non-root runtime user.") + + final_user = _final_user(dockerfile) + assert final_user == expected_user + assert final_user not in ROOT_USERS + + +@pytest.mark.parametrize("dockerfile_name", sorted(DOCKERFILES_CREATING_RUNTIME_USER)) +def test_dockerfile_creates_non_root_runtime_user(dockerfile_name: str): + dockerfile_text = (DOCKER_DIR / dockerfile_name).read_text(encoding="utf-8") + + assert re.search(r"\bgroupadd\b.*--gid\s+1000\b.*\bisaaclab\b", dockerfile_text, re.DOTALL) + assert re.search(r"\buseradd\b.*--uid\s+1000\b.*--gid\s+1000\b.*\bisaaclab\b", dockerfile_text, re.DOTALL) + assert "USER isaaclab" in dockerfile_text + + +def test_ros2_dockerfile_restores_non_root_runtime_user(): + dockerfile_text = (DOCKER_DIR / "Dockerfile.ros2").read_text(encoding="utf-8") + + assert _user_directives(dockerfile_text) == ["root", "isaaclab"] diff --git a/docs/source/deployment/docker.rst b/docs/source/deployment/docker.rst index 65e22e2aef70..c9653aa0f039 100644 --- a/docs/source/deployment/docker.rst +++ b/docs/source/deployment/docker.rst @@ -102,6 +102,20 @@ The following shows how to launch the container in a detached state and enter it # We pass 'base' explicitly, but if we hadn't it would default to 'base' ./docker/container.py enter base +The Isaac Lab base, ROS 2, and cuRobo images run as a non-root user with uid/gid 1000 to keep +bind-mounted workspaces writable on GitHub runners. If you run one of these images directly with +``docker run`` and your host uid differs, pass Docker's ``--user "$(id -u):1000"`` option so +new files on bind mounts are owned by your host user while retaining runtime-home access. + +If you are upgrading an existing Compose setup from older root-based images, recreate the named +volumes before starting the new images. Older cache, log, and data volumes may contain root-owned +files that the uid/gid 1000 runtime user cannot update. Copy any artifacts you want to keep, then +remove the old Compose volumes from the ``docker`` directory: + +.. code:: bash + + docker compose --file docker-compose.yaml --profile base --env-file .env.base down --volumes + To copy files from the base container to the host machine, you can use the following command: .. code:: bash diff --git a/docs/source/deployment/run_docker_example.rst b/docs/source/deployment/run_docker_example.rst index 8da716585f73..dc631e0fbe1f 100644 --- a/docs/source/deployment/run_docker_example.rst +++ b/docs/source/deployment/run_docker_example.rst @@ -40,9 +40,9 @@ Once the container is up and running, we can enter it from our terminal. python docker/container.py enter -On entering the Isaac Lab container, we are in the terminal as the superuser, ``root``. This environment contains a copy of the +On entering the Isaac Lab container, we are in the terminal as a non-root user. This environment contains a copy of the Isaac Lab repository, but also has access to the directories and libraries of Isaac Sim. We can run experiments from this environment -using a few convenient aliases that have been put into the ``root`` **.bashrc**. For instance, we have made the **isaaclab.sh** script +using a few convenient aliases that have been put into the runtime user's **.bashrc**. For instance, we have made the **isaaclab.sh** script usable from anywhere by typing its alias ``isaaclab``. Additionally in the container, we have `bind mounted`_ the ``IsaacLab/source`` directory from the diff --git a/source/isaaclab/changelog.d/docker-non-root.rst b/source/isaaclab/changelog.d/docker-non-root.rst new file mode 100644 index 000000000000..d2c61f8fbd85 --- /dev/null +++ b/source/isaaclab/changelog.d/docker-non-root.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed Isaac Lab Docker images to run as the non-root ``isaaclab`` user by default. Use an explicit + container user override when root access is required. diff --git a/source/isaaclab/test/performance/test_kit_startup_performance.py b/source/isaaclab/test/performance/test_kit_startup_performance.py index 0a39f23ea4aa..7607f30b7926 100644 --- a/source/isaaclab/test/performance/test_kit_startup_performance.py +++ b/source/isaaclab/test/performance/test_kit_startup_performance.py @@ -8,10 +8,14 @@ from __future__ import annotations +import os import time from isaaclab.app import AppLauncher +_LOCAL_STARTUP_TIME_LIMIT = 15.0 +_CI_STARTUP_TIME_LIMIT = 20.0 + def test_kit_start_up_time(): """Test kit start-up time.""" @@ -19,5 +23,9 @@ def test_kit_start_up_time(): app_launcher = AppLauncher(headless=True).app # noqa: F841 end_time = time.time() elapsed_time = end_time - start_time - # we are doing some more imports on the automate side - will investigate using warp instead of numba cuda - assert elapsed_time <= 15.0 + # GitHub Actions Docker jobs run with isolated writable runtime/cache mounts + # for non-root users, which makes startup slightly colder than reused local caches. + startup_time_limit = _CI_STARTUP_TIME_LIMIT if os.getenv("GITHUB_ACTIONS") == "true" else _LOCAL_STARTUP_TIME_LIMIT + assert elapsed_time <= startup_time_limit, ( + f"Kit startup took {elapsed_time:.2f}s (limit {startup_time_limit:.2f}s)." + ) From cafc911dfec6a6d7ca289edabc070efe086c4abd Mon Sep 17 00:00:00 2001 From: jmart-nv Date: Fri, 15 May 2026 22:18:54 -0500 Subject: [PATCH 082/133] Add frame stacking support for explicit temporal info (#5574) # Description This enables frame stacking for newton+warp by default in the cartpole camera presets task. **Newton Frame Stacking:** _Provides explicit temporal data to newton._ RTX uses DLSS anti-aliasing by default, which provides implicit temporal data. The newton_renderer does not provide temporal data. However, newton's energy-conserving physics solver requires temporal velocity data in order to compensate for the lack of damping, which causes convergence problems when paired with newton_renderer. This commit provides explicit temporal information via 2-frame stacking by default for cartpole-camera when using newton+newton_renderer. This allows newton to provide the damping it needs to converge at the same rate as physx. This adds 36% GPU memory overhead, but the wall clock overhead is negligible. The default for all other physics/renderer backends is still stack size = 1 (disabled) since physx has implicit damping built-in via its TGS solver, and RTX provides temporal data implicitly. **Implementation:** For manager-based envs, a new `stacked_image` term is added to the MDP observations - tasks can opt into frame stacking by adding the `stacked_image` term to their observation cfg and setting `frame_stack` to a value > 1. The cartpole camera presets direct env now implements frame stacking using `CircularBuffer` from `isaaclab.utils.buffers` directly in `_get_observations`. Added new unit tests for the MDP term (mocked + `ObservationManager` E2E) and cartpole integration, and updated the documentation with a note about newton's dependency on temporal data. _Note: This is a task-local re-implementation of the closed [PR #5232](https://github.com/isaac-sim/IsaacLab/pull/5232)_ ## 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 --------- Co-authored-by: Kelly Guo --- .../overview/core-concepts/renderers.rst | 8 + .../jmart-frame-stacking.minor.rst | 8 + .../isaaclab/envs/mdp/observations.py | 79 +++++++ .../test/envs/test_stacked_image_mdp.py | 158 +++++++++++++ .../envs/test_stacked_image_obs_manager.py | 110 +++++++++ .../jmart-frame-stacking.minor.rst | 8 + .../direct/cartpole/__init__.py | 2 +- .../cartpole/cartpole_camera_presets_env.py | 84 +++++++ .../cartpole_camera_presets_env_cfg.py | 7 + ...est_cartpole_camera_presets_frame_stack.py | 220 ++++++++++++++++++ 10 files changed, 683 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/jmart-frame-stacking.minor.rst create mode 100644 source/isaaclab/test/envs/test_stacked_image_mdp.py create mode 100644 source/isaaclab/test/envs/test_stacked_image_obs_manager.py create mode 100644 source/isaaclab_tasks/changelog.d/jmart-frame-stacking.minor.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env.py create mode 100644 source/isaaclab_tasks/test/test_cartpole_camera_presets_frame_stack.py diff --git a/docs/source/overview/core-concepts/renderers.rst b/docs/source/overview/core-concepts/renderers.rst index 2ae8bae28af3..f04b476330b5 100644 --- a/docs/source/overview/core-concepts/renderers.rst +++ b/docs/source/overview/core-concepts/renderers.rst @@ -30,6 +30,14 @@ Choosing a renderer backend | Newton Warp | No (kit-less) | Newton backend, fast training | +---------------------+-------------------------------+---------------------------------+ +.. note:: + + **Temporal information for camera-based RL.** Unlike RTX modes with temporal + anti-aliasing (DLSS, DLAA, TAA), the Newton Warp renderer does not inject + prior-frame information into the current image. Camera-control tasks that depend + on velocity-like visual cues should add explicit temporal observations + (e.g. task-local frame stacking) rather than relying on renderer-specific artifacts. + Architecture Overview --------------------- diff --git a/source/isaaclab/changelog.d/jmart-frame-stacking.minor.rst b/source/isaaclab/changelog.d/jmart-frame-stacking.minor.rst new file mode 100644 index 000000000000..33b022bc7a96 --- /dev/null +++ b/source/isaaclab/changelog.d/jmart-frame-stacking.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :class:`~isaaclab.envs.mdp.observations.stacked_image`, a stateful + :class:`~isaaclab.managers.ManagerTermBase` that channel-stacks the last ``N`` frames + from a camera sensor. Manager-based environments can reference it in observation cfg + to add explicit temporal information for camera-based RL tasks whose renderer doesn't + supply implicit temporal data (e.g., Newton Warp). diff --git a/source/isaaclab/isaaclab/envs/mdp/observations.py b/source/isaaclab/isaaclab/envs/mdp/observations.py index 8f97da3595dd..9206792a678e 100644 --- a/source/isaaclab/isaaclab/envs/mdp/observations.py +++ b/source/isaaclab/isaaclab/envs/mdp/observations.py @@ -19,6 +19,7 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.managers.manager_base import ManagerTermBase from isaaclab.managers.manager_term_cfg import ObservationTermCfg +from isaaclab.utils.buffers import CircularBuffer if TYPE_CHECKING: from isaaclab.assets import Articulation, RigidObject @@ -652,6 +653,84 @@ def _inference(model, images: torch.Tensor) -> torch.Tensor: return {"model": _load_model, "inference": _inference} +class stacked_image(ManagerTermBase): + """Channel-stacked observation of the last ``frame_stack`` camera frames. + + Maintains a per-env rolling history of camera frames in a + :class:`~isaaclab.utils.buffers.CircularBuffer` and returns them concatenated along the + channel dimension in oldest-to-newest order. Useful for camera-based RL tasks whose + rendering backend does not supply implicit temporal information (e.g., the Newton Warp + renderer, which lacks temporal anti-aliasing). + + On the first call after construction or per-env reset, all history slots for the affected + envs are filled with the current frame so the policy never sees zero-padded warmup data. + + Args: + sensor_cfg: The sensor configuration to poll. Defaults to SceneEntityCfg("tiled_camera"). + data_type: The sensor data type. Defaults to "rgb". + frame_stack: Number of frames to stack along the channel dim. Must be >= 1. + Defaults to 1 (single-frame passthrough). + convert_perspective_to_orthogonal: Whether to orthogonalize perspective depth images. + Used only when ``data_type == "distance_to_camera"``. Defaults to False. + normalize: Whether to normalize the images. See :func:`image` for per-data-type + behavior. Defaults to True. + + Returns: + Stacked image tensor. Shape is ``(num_envs, H, W, frame_stack * C)`` where the first + ``C`` channels are the oldest frame and the last ``C`` channels are the newest. + """ + + def __init__(self, cfg: ObservationTermCfg, env: ManagerBasedEnv): + super().__init__(cfg, env) + + frame_stack: int = cfg.params.get("frame_stack", 1) + if frame_stack < 1: + raise ValueError(f"frame_stack must be >= 1, got {frame_stack}.") + + # K=1 is a documented passthrough; no buffer needed. + self._buffer: CircularBuffer | None = None + if frame_stack > 1: + self._buffer = CircularBuffer( + max_len=frame_stack, + batch_size=env.num_envs, + device=env.device, + ) + + def reset(self, env_ids: torch.Tensor | None = None): + if self._buffer is not None: + self._buffer.reset(env_ids) + + def __call__( + self, + env: ManagerBasedEnv, + sensor_cfg: SceneEntityCfg = SceneEntityCfg("tiled_camera"), + data_type: str = "rgb", + frame_stack: int = 1, + convert_perspective_to_orthogonal: bool = False, + normalize: bool = True, + ) -> torch.Tensor: + single_frame = image( + env=env, + sensor_cfg=sensor_cfg, + data_type=data_type, + convert_perspective_to_orthogonal=convert_perspective_to_orthogonal, + normalize=normalize, + ) + + # K=1 passthrough: no buffer allocated. ``image()`` already clones. + if self._buffer is None: + return single_frame + + self._buffer.append(single_frame) + + # CircularBuffer.buffer is (B, K, H, W, C) in oldest->newest order along dim 1. + # Channel-stack: move K next to C, then flatten so the last dim reads + # oldest_C, ..., newest_C. + stacked = self._buffer.buffer + b, k, h, w, c = stacked.shape + return stacked.permute(0, 2, 3, 1, 4).reshape(b, h, w, k * c).clone() + + """ Actions. """ diff --git a/source/isaaclab/test/envs/test_stacked_image_mdp.py b/source/isaaclab/test/envs/test_stacked_image_mdp.py new file mode 100644 index 000000000000..92c4aa80198e --- /dev/null +++ b/source/isaaclab/test/envs/test_stacked_image_mdp.py @@ -0,0 +1,158 @@ +# 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 :class:`isaaclab.envs.mdp.observations.stacked_image`. + +Camera output is mocked via :func:`unittest.mock.patch` so the tests exercise the +ring-buffer + channel-stacking logic without needing a Kit launch or a real sensor. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest import mock + +import pytest +import torch + +pytestmark = pytest.mark.isaacsim_ci + +from isaaclab.envs.mdp.observations import stacked_image + +NUM_ENVS = 4 +HEIGHT = 8 +WIDTH = 8 +CHANNELS = 3 + + +def _make_env(num_envs: int = NUM_ENVS, device: str = "cpu") -> SimpleNamespace: + """Minimal mock env surface needed by ``stacked_image``.""" + return SimpleNamespace(num_envs=num_envs, device=device) + + +def _make_cfg(frame_stack: int) -> SimpleNamespace: + """Minimal mock cfg with the params dict the term reads at init.""" + return SimpleNamespace(params={"frame_stack": frame_stack}) + + +def _frame(value: int) -> torch.Tensor: + """Build a constant-valued ``(N, H, W, C)`` frame.""" + return torch.full((NUM_ENVS, HEIGHT, WIDTH, CHANNELS), value, dtype=torch.float32) + + +class TestStackedImage: + """Tests for the ``stacked_image`` observation term.""" + + def test_output_shape_channel_stacked(self): + """Output shape is ``(N, H, W, K * C)``.""" + env = _make_env() + term = stacked_image(_make_cfg(frame_stack=3), env) + with mock.patch("isaaclab.envs.mdp.observations.image", return_value=_frame(1)): + out = term(env) + assert out.shape == (NUM_ENVS, HEIGHT, WIDTH, CHANNELS * 3) + + def test_warmup_fills_all_slots_with_first_frame(self): + """First call after construction fills all ``K`` slots with that one frame.""" + env = _make_env() + term = stacked_image(_make_cfg(frame_stack=2), env) + with mock.patch("isaaclab.envs.mdp.observations.image", return_value=_frame(7)): + out = term(env) + f7 = _frame(7) + assert torch.equal(out[..., :CHANNELS], f7) + assert torch.equal(out[..., CHANNELS:], f7) + + def test_oldest_to_newest_channel_order(self): + """K=3 with three distinct frames produces oldest→newest along the channel dim.""" + env = _make_env() + term = stacked_image(_make_cfg(frame_stack=3), env) + with mock.patch("isaaclab.envs.mdp.observations.image") as patched: + patched.return_value = _frame(10) + term(env) # init: all 3 slots = 10 + patched.return_value = _frame(20) + term(env) # slots: [10, 10, 20] + patched.return_value = _frame(30) + out = term(env) # slots: [10, 20, 30] + assert torch.equal(out[..., :CHANNELS], _frame(10)) + assert torch.equal(out[..., CHANNELS : 2 * CHANNELS], _frame(20)) + assert torch.equal(out[..., 2 * CHANNELS :], _frame(30)) + + def test_reset_all_envs_clears_history(self): + """``reset()`` with no args re-inits every env on the next call.""" + env = _make_env() + term = stacked_image(_make_cfg(frame_stack=2), env) + with mock.patch("isaaclab.envs.mdp.observations.image") as patched: + patched.return_value = _frame(1) + term(env) + patched.return_value = _frame(2) + term(env) # ring filled + term.reset() + patched.return_value = _frame(50) + out = term(env) + assert torch.equal(out[..., :CHANNELS], _frame(50)) + assert torch.equal(out[..., CHANNELS:], _frame(50)) + + def test_reset_partial_envs_preserves_others(self): + """Resetting env 0 re-inits only env 0; other envs keep their history.""" + env = _make_env() + term = stacked_image(_make_cfg(frame_stack=2), env) + with mock.patch("isaaclab.envs.mdp.observations.image") as patched: + patched.return_value = _frame(1) + term(env) + patched.return_value = _frame(2) + term(env) + term.reset(torch.tensor([0])) + patched.return_value = _frame(9) + out = term(env) + per_env_shape = (HEIGHT, WIDTH, CHANNELS) + nines = torch.full(per_env_shape, 9, dtype=torch.float32) + twos = torch.full(per_env_shape, 2, dtype=torch.float32) + # Env 0: both slots = 9 (init path fired again). + assert torch.equal(out[0, ..., :CHANNELS], nines) + assert torch.equal(out[0, ..., CHANNELS:], nines) + # Env 1: oldest = 2 (shifted from previous newest), newest = 9. + assert torch.equal(out[1, ..., :CHANNELS], twos) + assert torch.equal(out[1, ..., CHANNELS:], nines) + + def test_invalid_frame_stack_raises(self): + """``frame_stack < 1`` is rejected at construction time.""" + with pytest.raises(ValueError, match="frame_stack must be >= 1"): + stacked_image(_make_cfg(frame_stack=0), _make_env()) + + def test_frame_stack_one_passthrough(self): + """``frame_stack=1`` short-circuits the buffer; output equals the single input frame.""" + env = _make_env() + term = stacked_image(_make_cfg(frame_stack=1), env) + f = _frame(42) + with mock.patch("isaaclab.envs.mdp.observations.image", return_value=f): + out = term(env) + assert out.shape == (NUM_ENVS, HEIGHT, WIDTH, CHANNELS) + assert torch.equal(out, f) + + def test_long_run_ring_stability(self): + """After updates well past ``frame_stack`` cycles, the layout stays correct.""" + env = _make_env() + term = stacked_image(_make_cfg(frame_stack=3), env) + with mock.patch("isaaclab.envs.mdp.observations.image") as patched: + for i in range(11): + patched.return_value = _frame(i) + out = term(env) + # 11 frames with values 0..10; final ring holds the 3 most recent in oldest→newest order. + assert torch.equal(out[..., :CHANNELS], _frame(8)) + assert torch.equal(out[..., CHANNELS : 2 * CHANNELS], _frame(9)) + assert torch.equal(out[..., 2 * CHANNELS :], _frame(10)) + + @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available in this env") + def test_buffer_on_cuda(self): + """Term allocates and operates correctly on a CUDA device.""" + env = _make_env(device="cuda") + term = stacked_image(_make_cfg(frame_stack=2), env) + cuda_frame = torch.full((NUM_ENVS, HEIGHT, WIDTH, CHANNELS), 7.0, dtype=torch.float32, device="cuda") + with mock.patch("isaaclab.envs.mdp.observations.image", return_value=cuda_frame): + out = term(env) + assert out.device.type == "cuda" + assert out.shape == (NUM_ENVS, HEIGHT, WIDTH, CHANNELS * 2) + # Init path fires; both slots hold the same frame. + assert torch.equal(out[..., :CHANNELS], cuda_frame) + assert torch.equal(out[..., CHANNELS:], cuda_frame) diff --git a/source/isaaclab/test/envs/test_stacked_image_obs_manager.py b/source/isaaclab/test/envs/test_stacked_image_obs_manager.py new file mode 100644 index 000000000000..77b97af97f68 --- /dev/null +++ b/source/isaaclab/test/envs/test_stacked_image_obs_manager.py @@ -0,0 +1,110 @@ +# 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 + +"""End-to-end test of :class:`stacked_image` through :class:`ObservationManager`. + +Launches Kit + sim so the obs manager's construction-time shape probe and per-step compute +exercise the real lifecycle. Mocks the camera-pull function ``image`` so no scene/sensors +are required. +""" + +from __future__ import annotations + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +from collections import namedtuple +from unittest import mock + +import pytest +import torch + +import isaaclab.sim as sim_utils +from isaaclab.envs.mdp.observations import stacked_image +from isaaclab.managers import ObservationGroupCfg, ObservationManager, ObservationTermCfg +from isaaclab.utils import configclass + +pytestmark = pytest.mark.isaacsim_ci + +NUM_ENVS = 4 +HEIGHT = 8 +WIDTH = 8 +CHANNELS = 3 +DEVICE = "cuda:0" + + +def _fake_image(env, sensor_cfg=None, data_type="rgb", convert_perspective_to_orthogonal=False, normalize=True): + """Stand-in for ``isaaclab.envs.mdp.observations.image`` — returns a constant frame. + + The value is keyed to the call count so consecutive calls produce distinct frames + (used by the channel-shift assertion). + """ + value = float(_fake_image.call_count) + _fake_image.call_count += 1 + return torch.full((env.num_envs, HEIGHT, WIDTH, CHANNELS), value, dtype=torch.float32, device=env.device) + + +_fake_image.call_count = 0 + + +@pytest.fixture +def env_with_sim(): + sim_cfg = sim_utils.SimulationCfg(dt=0.01, device=DEVICE) + sim = sim_utils.SimulationContext(sim_cfg) + env = namedtuple("Env", ["num_envs", "device", "sim"])(NUM_ENVS, DEVICE, sim) + env.sim._app_control_on_stop_handle = None + env.sim.reset() + _fake_image.call_count = 0 + yield env + sim.clear_instance() + + +def _make_cfg(frame_stack: int): + @configclass + class ObsCfg: + @configclass + class PolicyCfg(ObservationGroupCfg): + img: ObservationTermCfg = ObservationTermCfg( + func=stacked_image, + params={"frame_stack": frame_stack}, + ) + + policy: ObservationGroupCfg = PolicyCfg() + + return ObsCfg() + + +def test_obs_manager_infers_channel_stacked_shape(env_with_sim): + """ObservationManager probes ``stacked_image`` at construction and infers the stacked shape.""" + with mock.patch("isaaclab.envs.mdp.observations.image", side_effect=_fake_image): + manager = ObservationManager(_make_cfg(frame_stack=2), env_with_sim) + assert manager.group_obs_dim["policy"] == (HEIGHT, WIDTH, CHANNELS * 2) + + +def test_obs_manager_compute_returns_stacked_output(env_with_sim): + """``compute()`` after construction returns the channel-stacked obs tensor.""" + with mock.patch("isaaclab.envs.mdp.observations.image", side_effect=_fake_image): + manager = ObservationManager(_make_cfg(frame_stack=3), env_with_sim) + obs = manager.compute() + assert obs["policy"].shape == (NUM_ENVS, HEIGHT, WIDTH, CHANNELS * 3) + + +def test_obs_manager_reset_clears_term_state(env_with_sim): + """``manager.reset()`` forwards to ``stacked_image.reset()``; next compute fills slots with the new frame.""" + with mock.patch("isaaclab.envs.mdp.observations.image", side_effect=_fake_image): + manager = ObservationManager(_make_cfg(frame_stack=2), env_with_sim) + manager.compute() + manager.compute() # ring fills with two distinct frames + manager.reset() + # Next compute should treat the frame as fresh init — both channel-slots identical. + obs = manager.compute() + oldest = obs["policy"][..., :CHANNELS] + newest = obs["policy"][..., CHANNELS:] + assert torch.equal(oldest, newest), "After reset, init path should fill all slots with the same frame." diff --git a/source/isaaclab_tasks/changelog.d/jmart-frame-stacking.minor.rst b/source/isaaclab_tasks/changelog.d/jmart-frame-stacking.minor.rst new file mode 100644 index 000000000000..0750e3fe1c70 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/jmart-frame-stacking.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_tasks.direct.cartpole.cartpole_camera_presets_env.CartpoleCameraPresetsEnv`, + a subclass of :class:`~isaaclab_tasks.direct.cartpole.cartpole_camera_env.CartpoleCameraEnv` that + wires :class:`~isaaclab.utils.buffers.CircularBuffer` into the ``Isaac-Cartpole-Camera-Presets-Direct-v0`` + task. ``frame_stack`` defaults to ``2`` for the Newton + Warp combo and ``1`` otherwise; + CLI overrides via ``env.frame_stack=N`` are respected. diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/__init__.py index 599cfa9d51a2..b1b8fdbf35b5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/__init__.py @@ -96,7 +96,7 @@ gym.register( id="Isaac-Cartpole-Camera-Presets-Direct-v0", - entry_point=f"{__name__}.cartpole_camera_env:CartpoleCameraEnv", + entry_point=f"{__name__}.cartpole_camera_presets_env:CartpoleCameraPresetsEnv", disable_env_checker=True, kwargs={ "env_cfg_entry_point": f"{__name__}.cartpole_camera_presets_env_cfg:CartpoleCameraPresetsEnvCfg", diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env.py new file mode 100644 index 000000000000..6614ca769baa --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env.py @@ -0,0 +1,84 @@ +# 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 + +"""Preset variant of the Cartpole camera env with optional frame stacking.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from isaaclab.utils.buffers import CircularBuffer +from isaaclab.utils.configclass import resolve_cfg_presets + +from .cartpole_camera_env import CartpoleCameraEnv + +if TYPE_CHECKING: + from .cartpole_camera_presets_env_cfg import CartpoleCameraPresetsEnvCfg + + +class CartpoleCameraPresetsEnv(CartpoleCameraEnv): + """Cartpole camera env that wires up a :class:`~isaaclab.utils.buffers.CircularBuffer` + when the active backend combo benefits from explicit temporal observations. + + Behavior is identical to :class:`CartpoleCameraEnv` when ``cfg.frame_stack == 1``; + when it is ``> 1``, the policy observation becomes the channel-stacked output of + the buffer (oldest → newest). + """ + + cfg: CartpoleCameraPresetsEnvCfg + + @staticmethod + def _resolve_frame_stack_default(camera_cfg, physics_cfg) -> int: + """Return ``2`` for the Newton + Warp combo (no implicit damping, no temporal AA), + ``1`` otherwise.""" + from isaaclab_newton.physics import NewtonCfg + from isaaclab_newton.renderers import NewtonWarpRendererCfg + + is_newton_warp = isinstance(physics_cfg, NewtonCfg) and isinstance( + getattr(camera_cfg, "renderer_cfg", None), NewtonWarpRendererCfg + ) + return 2 if is_newton_warp else 1 + + def __init__(self, cfg, render_mode: str | None = None, **kwargs): + # Flatten preset wrappers so the isinstance check below sees concrete types. + # Idempotent — base ``DirectRLEnv.__init__`` calls this again with no effect. + resolve_cfg_presets(cfg) + + if cfg.frame_stack < 0: + cfg.frame_stack = self._resolve_frame_stack_default(cfg.tiled_camera, cfg.sim.physics) + elif cfg.frame_stack == 0: + cfg.frame_stack = 1 + + single_channels = int(cfg.observation_space[-1]) + if cfg.frame_stack > 1: + cfg.observation_space = [*cfg.observation_space[:-1], single_channels * cfg.frame_stack] + + super().__init__(cfg, render_mode, **kwargs) + + self._stack: CircularBuffer | None = None + if cfg.frame_stack > 1: + self._stack = CircularBuffer( + max_len=cfg.frame_stack, + batch_size=self.num_envs, + device=self.device, + ) + + def _get_observations(self) -> dict: + obs = super()._get_observations() + if self._stack is not None: + self._stack.append(obs["policy"]) + # CircularBuffer.buffer is (B, K, H, W, C) oldest->newest along dim 1. + # Channel-stack: move K next to C, then flatten so the last dim reads + # oldest_C, ..., newest_C. + stacked = self._stack.buffer + b, k, h, w, c = stacked.shape + obs["policy"] = stacked.permute(0, 2, 3, 1, 4).reshape(b, h, w, k * c).clone() + return obs + + def _reset_idx(self, env_ids: Sequence[int] | None): + super()._reset_idx(env_ids) + if self._stack is not None: + self._stack.reset(env_ids) diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py index 00d11f233ff6..2b436f2d5e51 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py @@ -78,6 +78,13 @@ class BaseCartpoleCameraEnvCfg(DirectRLEnvCfg): tiled_camera: MultiDataTypeCartpoleTiledCameraCfg = MultiDataTypeCartpoleTiledCameraCfg() write_image_to_file = False + frame_stack: int = -1 + """Number of frames to stack along the channel dim. + + ``-1`` (default) auto-resolves to ``2`` for the Newton + Warp combo and ``1`` otherwise. + Set to ``1`` to force single-frame; set to ``N > 1`` to force an explicit stack size. + """ + # spaces action_space = 1 state_space = 0 diff --git a/source/isaaclab_tasks/test/test_cartpole_camera_presets_frame_stack.py b/source/isaaclab_tasks/test/test_cartpole_camera_presets_frame_stack.py new file mode 100644 index 000000000000..40d1be458491 --- /dev/null +++ b/source/isaaclab_tasks/test/test_cartpole_camera_presets_frame_stack.py @@ -0,0 +1,220 @@ +# 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 the cartpole-camera-presets frame-stacking policy. + +Cfg-level: for each ``(physics, renderer)`` preset combo, the resolved cfg combined with +:meth:`CartpoleCameraPresetsEnv._resolve_frame_stack_default` produces the expected +``frame_stack`` value; user-set values are respected; per-data-type single-frame channel +counts are correct. + +End-to-end: constructs ``CartpoleCameraPresetsEnv`` for the PhysX baseline and +Newton+Warp combos, then verifies ``env.reset()`` / ``env.step()`` produce policy +observations of the expected stacked shape.""" + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +simulation_app = AppLauncher(headless=True, enable_cameras=True).app + +import pytest # noqa: E402 +from isaaclab_newton.physics import NewtonCfg # noqa: E402 +from isaaclab_newton.renderers import NewtonWarpRendererCfg # noqa: E402 +from isaaclab_physx.physics import PhysxCfg # noqa: E402 +from isaaclab_physx.renderers import IsaacRtxRendererCfg # noqa: E402 + +from isaaclab_tasks.direct.cartpole.cartpole_camera_presets_env import CartpoleCameraPresetsEnv # noqa: E402 +from isaaclab_tasks.direct.cartpole.cartpole_camera_presets_env_cfg import CartpoleCameraPresetsEnvCfg # noqa: E402 +from isaaclab_tasks.utils.hydra import resolve_presets # noqa: E402 + +pytestmark = pytest.mark.isaacsim_ci + + +def _resolve(*presets: str): + """Build a fresh CartpoleCameraPresetsEnvCfg and resolve with the given preset names. + + Returns the resolved root cfg (a ``BaseCartpoleCameraEnvCfg`` instance). + """ + outer = CartpoleCameraPresetsEnvCfg() + return resolve_presets(outer, selected=set(presets)) + + +def _policy_default(cfg) -> int: + """Run the task's policy helper on a resolved cfg.""" + return CartpoleCameraPresetsEnv._resolve_frame_stack_default(cfg.tiled_camera, cfg.sim.physics) + + +class TestFrameStackTruthTable: + """One test per cell of the physics × renderer matrix.""" + + def test_no_presets_resolves_to_default(self): + cfg = _resolve() + assert cfg.frame_stack == -1, "Cfg sentinel default must survive preset resolution" + assert _policy_default(cfg) == 1 + + def test_physx_default_renderer(self): + cfg = _resolve("physx") + assert isinstance(cfg.sim.physics, PhysxCfg) + assert isinstance(cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) + assert _policy_default(cfg) == 1 + + def test_physx_with_warp_renderer(self): + """PhysX has implicit damping — no stacking needed even with Warp.""" + cfg = _resolve("physx", "newton_renderer") + assert isinstance(cfg.sim.physics, PhysxCfg) + assert isinstance(cfg.tiled_camera.renderer_cfg, NewtonWarpRendererCfg) + assert _policy_default(cfg) == 1 + + def test_newton_with_default_renderer(self): + """Newton physics + RTX renderer — RTX provides temporal information.""" + cfg = _resolve("newton_mjwarp") + assert isinstance(cfg.sim.physics, NewtonCfg) + assert isinstance(cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) + assert _policy_default(cfg) == 1 + + def test_newton_with_warp_renderer_stacks(self): + """Newton + Warp — the combo that needs explicit temporal stacking.""" + cfg = _resolve("newton_mjwarp", "newton_renderer") + assert isinstance(cfg.sim.physics, NewtonCfg) + assert isinstance(cfg.tiled_camera.renderer_cfg, NewtonWarpRendererCfg) + assert _policy_default(cfg) == 2 + + +class TestObsSpaceBumpArithmetic: + """The env class bumps ``observation_space[-1] *= frame_stack`` when stacking — sanity-check + that the arithmetic across data-type variants stays correct.""" + + @pytest.mark.parametrize( + "data_type_preset,expected_single_channels", + [ + ("default", 3), # RGB + ("depth", 1), + ("albedo", 3), + ("semantic_segmentation", 4), + ("simple_shading_constant_diffuse", 3), + ("simple_shading_diffuse_mdl", 3), + ("simple_shading_full_mdl", 3), + ], + ) + def test_observation_space_unstacked_channels(self, data_type_preset, expected_single_channels): + """Each data-type variant declares the expected single-frame channel count.""" + cfg = _resolve(data_type_preset) + assert cfg.observation_space[-1] == expected_single_channels + + +# --------------------------------------------------------------------------- +# End-to-end: construct the real env and verify the obs pipeline +# --------------------------------------------------------------------------- + + +class TestEnvConstructionEndToEnd: + """Construct ``CartpoleCameraPresetsEnv`` for real and verify the obs pipeline. + + These tests catch wiring bugs that cfg-only tests miss: that the env class + correctly resolves the policy, bumps obs_space, allocates the buffer, runs the + buffer in ``_get_observations``, and resets it in ``_reset_idx``. + """ + + @pytest.mark.parametrize( + "presets,user_frame_stack,expected_frame_stack,expected_channels", + [ + # PhysX default (no presets): policy resolves to 1 → buffer skipped → 3 channels. + (frozenset(), -1, 1, 3), + # Newton + Warp: policy resolves to 2 → buffer active → 6 channels. + (frozenset({"newton_mjwarp", "newton_renderer"}), -1, 2, 6), + # User override (env.frame_stack=4) on Newton+Warp: policy is skipped → 12 channels. + (frozenset({"newton_mjwarp", "newton_renderer"}), 4, 4, 12), + # ``frame_stack=0`` is a synonym for "no stacking" → normalized to 1. + (frozenset(), 0, 1, 3), + # Explicit single-frame: ``frame_stack=1`` short-circuits the policy. + (frozenset(), 1, 1, 3), + ], + ) + def test_env_obs_shape_matches_policy(self, presets, user_frame_stack, expected_frame_stack, expected_channels): + # Build + resolve cfg; trim envs for test speed. + outer = CartpoleCameraPresetsEnvCfg() + env_cfg = resolve_presets(outer, selected=set(presets)) + env_cfg.scene.num_envs = 2 + env_cfg.frame_stack = user_frame_stack + + env = None + try: + env = CartpoleCameraPresetsEnv(cfg=env_cfg) + assert env.cfg.frame_stack == expected_frame_stack, ( + f"presets={presets} user_fs={user_frame_stack}: expected" + f" frame_stack={expected_frame_stack}, got {env.cfg.frame_stack}" + ) + # Reset and verify obs shape. + obs, _ = env.reset() + expected_shape = (env.num_envs, env_cfg.tiled_camera.height, env_cfg.tiled_camera.width, expected_channels) + assert obs["policy"].shape == expected_shape, ( + f"presets={presets}: reset obs shape {tuple(obs['policy'].shape)} != expected {expected_shape}" + ) + # Step once and confirm the shape persists. + import torch as _torch + + action = _torch.zeros(env.num_envs, 1, device=env.device) + obs, _, _, _, _ = env.step(action) + assert obs["policy"].shape == expected_shape, ( + f"presets={presets}: step obs shape {tuple(obs['policy'].shape)} != expected {expected_shape}" + ) + finally: + if env is not None: + env.close() + else: + # Mid-init failure left a SimulationContext singleton; clear it for the next case. + import contextlib + + import isaaclab.sim as sim_utils + + sim = sim_utils.SimulationContext.instance() + if sim is not None: + with contextlib.suppress(Exception): + sim.clear_instance() + + def test_buffer_ring_shift_e2e(self): + """Verify the buffer's ring shift is wired through the env's obs pipeline. + + Uses an identity that holds regardless of renderer behavior: the frame that was + newest at reset must appear at the oldest position after one step. This catches + slot-order bugs in the buffer's narrow+copy_ rebuild without depending on the + camera producing different content frame-to-frame. + """ + import torch as _torch + + outer = CartpoleCameraPresetsEnvCfg() + env_cfg = resolve_presets(outer, selected={"newton_mjwarp", "newton_renderer"}) + env_cfg.scene.num_envs = 2 + + env = None + try: + env = CartpoleCameraPresetsEnv(cfg=env_cfg) + assert env.cfg.frame_stack == 2, "Newton+Warp must auto-resolve to frame_stack=2 for this test" + + c = env_cfg.observation_space[-1] // env.cfg.frame_stack + obs, _ = env.reset() + reset_newest = obs["policy"][..., -c:].clone() + + action = _torch.zeros(env.num_envs, 1, device=env.device) + obs, _, _, _, _ = env.step(action) + step_oldest = obs["policy"][..., :c] + + assert _torch.allclose(step_oldest, reset_newest), ( + "Ring shift broken: the frame that was newest at reset did not appear at the oldest " + "position after one step." + ) + finally: + if env is not None: + env.close() + else: + import contextlib + + import isaaclab.sim as sim_utils + + sim = sim_utils.SimulationContext.instance() + if sim is not None: + with contextlib.suppress(Exception): + sim.clear_instance() From 3a3473665837378115373464bfd2a0e71b7fdaca Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Fri, 15 May 2026 22:07:53 -0700 Subject: [PATCH 083/133] Migrate camera/renderer/camera data to warp (#5578) # Description Migrates `Camera`, `CameraData`, and all renderer backends to warp-backed `ProxyArray`, consistent with the rest of IsaacLab's sensors (ContactSensor, RayCaster, IMU, etc.). `CameraData` fields (`pos_w`, `quat_w_world`, `intrinsic_matrices`, `output`) now return `ProxyArray` instead of `torch.Tensor`. Use `.torch` for a zero-copy tensor view or pass directly to warp kernels. `RenderBufferSpec.dtype` is now a warp dtype (e.g. `wp.float32`). The `ProxyArray` deprecation bridge means existing torch usage continues to work with a one-time `DeprecationWarning`. ## Type of change - Possible breaking change (existing functionality will not work without user modification) ## 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: Kelly Guo --- .../changelog.d/mh-warp_cam.minor.rst | 17 ++ .../isaaclab/renderers/base_renderer.py | 24 +- .../isaaclab/renderers/output_contract.py | 10 +- .../isaaclab/sensors/camera/camera.py | 37 ++-- .../isaaclab/sensors/camera/camera_data.py | 207 +++++++++++++----- .../multi_mesh_ray_caster_camera.py | 7 +- .../sensors/ray_caster/ray_caster_camera.py | 21 +- .../isaaclab/utils/warp/proxy_array.py | 18 ++ .../isaaclab/isaaclab/utils/warp/warp_math.py | 186 ++++++++++++++++ .../renderers/test_camera_output_contract.py | 30 +-- .../test_simulation_render_context.py | 11 +- source/isaaclab/test/sensors/test_camera.py | 152 +++++++------ .../test_multi_mesh_ray_caster_camera.py | 40 ++-- .../test/sensors/test_multi_tiled_camera.py | 53 ++--- .../test/sensors/test_ray_caster_camera.py | 47 ++-- .../test/sensors/test_tiled_camera.py | 4 +- .../changelog.d/mh-warp_cam.minor.rst | 8 + .../renderers/newton_warp_renderer.py | 106 ++++----- .../changelog.d/mh-warp_cam.minor.rst | 7 + .../isaaclab_ov/renderers/ovrtx_renderer.py | 85 +++---- .../test/test_ovrtx_renderer_contract.py | 13 +- .../changelog.d/mh-warp_cam.minor.rst | 7 + .../renderers/isaac_rtx_renderer.py | 51 ++--- .../changelog.d/mh-warp_cam.skip | 0 .../franka/stack_ik_rel_blueprint_env_cfg.py | 3 + .../test/rendering_test_utils.py | 7 +- .../test/test_rendering_registered_tasks.py | 6 +- .../test/test_shadow_hand_vision_presets.py | 3 +- .../test_visualizer_cartpole_integration.py | 4 +- 29 files changed, 766 insertions(+), 398 deletions(-) create mode 100644 source/isaaclab/changelog.d/mh-warp_cam.minor.rst create mode 100644 source/isaaclab/isaaclab/utils/warp/warp_math.py create mode 100644 source/isaaclab_newton/changelog.d/mh-warp_cam.minor.rst create mode 100644 source/isaaclab_ov/changelog.d/mh-warp_cam.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/mh-warp_cam.minor.rst create mode 100644 source/isaaclab_tasks/changelog.d/mh-warp_cam.skip diff --git a/source/isaaclab/changelog.d/mh-warp_cam.minor.rst b/source/isaaclab/changelog.d/mh-warp_cam.minor.rst new file mode 100644 index 000000000000..75a4c0450f31 --- /dev/null +++ b/source/isaaclab/changelog.d/mh-warp_cam.minor.rst @@ -0,0 +1,17 @@ +Changed +^^^^^^^ + +* Changed :class:`~isaaclab.sensors.camera.CameraData` to expose all sensor buffers as + :class:`~isaaclab.utils.warp.ProxyArray` instead of :class:`torch.Tensor`. The fields + :attr:`~isaaclab.sensors.camera.CameraData.pos_w` (``wp.vec3f``), + :attr:`~isaaclab.sensors.camera.CameraData.quat_w_world` (``wp.quatf``), + :attr:`~isaaclab.sensors.camera.CameraData.intrinsic_matrices` (``wp.mat33f``), and all + entries in :attr:`~isaaclab.sensors.camera.CameraData.output` are now backed by warp arrays. + Use ``.torch`` for a zero-copy :class:`torch.Tensor` view or ``.warp`` to pass the array + directly to a warp kernel. Existing code using these fields as tensors (indexing, arithmetic, + :func:`torch.testing.assert_close`, etc.) continues to work via the + :class:`~isaaclab.utils.warp.ProxyArray` deprecation bridge with a one-time + :class:`DeprecationWarning`. +* Updated :meth:`~isaaclab.renderers.BaseRenderer.set_outputs` and + :meth:`~isaaclab.renderers.BaseRenderer.update_camera` in :class:`~isaaclab.renderers.BaseRenderer` + to accept :class:`~isaaclab.utils.warp.ProxyArray` arguments instead of :class:`torch.Tensor`. diff --git a/source/isaaclab/isaaclab/renderers/base_renderer.py b/source/isaaclab/isaaclab/renderers/base_renderer.py index be0da6e1c116..316cf1778647 100644 --- a/source/isaaclab/isaaclab/renderers/base_renderer.py +++ b/source/isaaclab/isaaclab/renderers/base_renderer.py @@ -14,9 +14,8 @@ from .output_contract import RenderBufferKind, RenderBufferSpec if TYPE_CHECKING: - import torch - from isaaclab.sensors.camera.camera_data import CameraData + from isaaclab.utils.warp import ProxyArray class BaseRenderer(ABC): @@ -64,13 +63,15 @@ def create_render_data(self, spec: CameraRenderSpec) -> Any: pass @abstractmethod - def set_outputs(self, render_data: Any, output_data: dict[str, torch.Tensor]) -> None: + def set_outputs(self, render_data: Any, output_data: dict[str, ProxyArray]) -> None: """Store reference to output buffers for writing during render. Args: render_data: The render data object from :meth:`create_render_data`. output_data: Dictionary mapping output names (e.g. ``"rgb"``, ``"depth"``) - to pre-allocated tensors where rendered data will be written. + to pre-allocated :class:`~isaaclab.utils.warp.ProxyArray` wrappers where + rendered data will be written. Use ``.warp`` for the underlying warp array + or ``.torch`` for a zero-copy tensor view. """ pass @@ -84,15 +85,22 @@ def update_transforms(self) -> None: @abstractmethod def update_camera( - self, render_data: Any, positions: torch.Tensor, orientations: torch.Tensor, intrinsics: torch.Tensor + self, + render_data: Any, + positions: ProxyArray, + orientations: ProxyArray, + intrinsics: ProxyArray, ) -> None: """Update camera poses and intrinsics for the next render. Args: render_data: The render data object from :meth:`create_render_data`. - positions: Camera positions in world frame, shape ``(N, 3)``. - orientations: Camera orientations as quaternions (x, y, z, w), shape ``(N, 4)``. - intrinsics: Camera intrinsic matrices, shape ``(N, 3, 3)``. + positions: Camera positions in world frame. Shape ``(N,)``, dtype ``wp.vec3f``. + Use ``.torch`` for a ``(N, 3)`` tensor view. + orientations: Camera orientations as quaternions ``(x, y, z, w)``. Shape ``(N,)``, + dtype ``wp.quatf``. Use ``.torch`` for a ``(N, 4)`` tensor view. + intrinsics: Camera intrinsic matrices. Shape ``(N,)``, dtype ``wp.mat33f``. + Use ``.torch`` for a ``(N, 3, 3)`` tensor view. """ pass diff --git a/source/isaaclab/isaaclab/renderers/output_contract.py b/source/isaaclab/isaaclab/renderers/output_contract.py index bfa3fff41d8b..c033b76ab51e 100644 --- a/source/isaaclab/isaaclab/renderers/output_contract.py +++ b/source/isaaclab/isaaclab/renderers/output_contract.py @@ -14,10 +14,6 @@ from dataclasses import dataclass from enum import StrEnum -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import torch class RenderBufferKind(StrEnum): @@ -48,7 +44,7 @@ class RenderBufferSpec: """Per-pixel layout (channels + dtype) for one render buffer kind.""" channels: int - """Number of per-pixel channels (last dimension of the allocated tensor).""" + """Number of per-pixel channels (last dimension of the allocated warp array).""" - dtype: torch.dtype - """Torch dtype the renderer writes for this render buffer kind.""" + dtype: type + """Warp scalar dtype for the buffer (e.g. ``wp.float32``, ``wp.uint8``).""" diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 1dc05becae9b..319fcc27f22b 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -26,6 +26,7 @@ create_rotation_matrix_from_view, quat_from_matrix, ) +from isaaclab.utils.warp.warp_math import convert_camera_frame_orientation_convention_wp from ..sensor_base import SensorBase from .camera_data import CameraData, RenderBufferKind @@ -508,6 +509,7 @@ def _create_buffers(self): type(self._renderer).__name__, unsupported, ) + device_str = self._device if isinstance(self._device, str) else str(self._device) self._data = CameraData.allocate( data_types=known, height=self.cfg.height, @@ -517,11 +519,9 @@ def _create_buffers(self): supported_specs=specs, ) # Camera-frame state (pose / intrinsics) is owned by the camera, not - # the renderer: populate it on the freshly constructed ``CameraData``. - self._data.intrinsic_matrices = torch.zeros((self._view.count, 3, 3), device=self._device) + # the renderer: allocate warp buffers and populate them. + self._data.create_buffers(self._view.count, device_str) self._update_intrinsic_matrices(self._ALL_INDICES) - 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) self._update_poses(self._ALL_INDICES) self._renderer.set_outputs(self._render_data, self._data.output) @@ -551,11 +551,12 @@ def _update_intrinsic_matrices(self, env_ids: Sequence[int]): c_x = width * 0.5 c_y = height * 0.5 # create intrinsic matrix for depth linear - self._data.intrinsic_matrices[i, 0, 0] = f_x - self._data.intrinsic_matrices[i, 0, 2] = c_x - self._data.intrinsic_matrices[i, 1, 1] = f_y - self._data.intrinsic_matrices[i, 1, 2] = c_y - self._data.intrinsic_matrices[i, 2, 2] = 1 + intrinsics_t = self._data.intrinsic_matrices.torch + intrinsics_t[i, 0, 0] = f_x + intrinsics_t[i, 0, 2] = c_x + intrinsics_t[i, 1, 1] = f_y + intrinsics_t[i, 1, 2] = c_y + intrinsics_t[i, 2, 2] = 1 def _update_poses(self, env_ids: Sequence[int]): """Computes the pose of the camera in the world frame with ROS convention. @@ -570,14 +571,24 @@ 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 (returns ProxyArray, use .torch for tensor access) + # get the poses from the view (returns ProxyArray) 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_w, quat_w = self._view.get_world_poses(indices) - self._data.pos_w[env_ids] = pos_w.torch - self._data.quat_w_world[env_ids] = convert_camera_frame_orientation_convention( - quat_w.torch, origin="opengl", target="world" + self._data.pos_w.torch[env_ids] = pos_w.torch + + # get_world_poses() returns orientations as a flat 4-float, convert to wp.quatf typed array + quat_w_quatf = wp.array( + ptr=quat_w.warp.ptr, dtype=wp.quatf, shape=(quat_w.warp.shape[0],), device=quat_w.warp.device, copy=False + ) + convert_camera_frame_orientation_convention_wp( + src=quat_w_quatf, + dst=self._data.quat_w_world, + origin="opengl", + target="world", + indices=indices, + device=self._device, ) # 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_data.py b/source/isaaclab/isaaclab/sensors/camera/camera_data.py index 6ec5484531b2..a03b4a69e646 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera_data.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera_data.py @@ -5,70 +5,136 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Any import torch +import warp as wp # Re-exported as part of the public isaaclab.sensors.camera API from isaaclab.renderers.output_contract import RenderBufferKind, RenderBufferSpec -from isaaclab.utils.math import convert_camera_frame_orientation_convention +from isaaclab.utils.warp import ProxyArray +from isaaclab.utils.warp.warp_math import convert_camera_frame_orientation_convention_wp __all__ = ["CameraData", "RenderBufferKind", "RenderBufferSpec"] -@dataclass class CameraData: - """Data container for the camera sensor.""" + """Data container for the camera sensor. + + Public properties return :class:`~isaaclab.utils.warp.ProxyArray` wrappers. + Use ``.torch`` for a cached zero-copy :class:`torch.Tensor` view or + ``.warp`` for the underlying :class:`warp.array`. + """ + + def __init__(self): + # Warp arrays for pose / intrinsics — allocated in create_buffers() + self._pos_w_wp: wp.array | None = None + self._quat_w_world_wp: wp.array | None = None + self._intrinsic_matrices_wp: wp.array | None = None + # Pre-allocated output buffers for derived orientation properties + self._quat_w_ros_wp: wp.array | None = None + self._quat_w_opengl_wp: wp.array | None = None + + # ProxyArray wrappers — created in create_buffers() + self._pos_w_pa: ProxyArray | None = None + self._quat_w_world_pa: ProxyArray | None = None + self._intrinsic_matrices_pa: ProxyArray | None = None + self._quat_w_ros_pa: ProxyArray | None = None + self._quat_w_opengl_pa: ProxyArray | None = None + + # Output image buffers — allocated in allocate() + self._output: dict[str, ProxyArray] | None = None + + self.image_shape: tuple[int, int] | None = None + """A tuple containing (height, width) of the camera sensor.""" + + self.info: dict[str, Any] | None = None + """The retrieved sensor info with sensor types as key. + + This contains extra information provided by the sensor such as semantic segmentation label mapping, prim paths. + For semantic-based data, this corresponds to the ``"info"`` key in the output of the sensor. For other sensor + types, the info is empty. + """ ## # Frame state. ## - pos_w: torch.Tensor = None - """Position of the sensor origin in world frame, following ROS convention. + @property + def pos_w(self) -> ProxyArray: + """Position of the sensor origin in world frame [m], following ROS convention. - Shape is (N, 3) where N is the number of sensors. - """ + Shape is (N,), dtype ``wp.vec3f``. In torch this resolves to (N, 3), + where N is the number of sensors. Use ``.warp`` for the underlying + ``wp.array`` or ``.torch`` for a cached zero-copy ``torch.Tensor`` view. + """ + return self._pos_w_pa - quat_w_world: torch.Tensor = None - """Quaternion orientation `(x, y, z, w)` of the sensor origin in world frame, following the world coordinate frame + @property + def quat_w_world(self) -> ProxyArray: + """Quaternion orientation ``(x, y, z, w)`` of the sensor origin in world frame, + following the world coordinate frame convention. - .. note:: - World frame convention follows the camera aligned with forward axis +X and up axis +Z. + .. note:: + World frame convention follows the camera aligned with forward axis +X and up axis +Z. - Shape is (N, 4) where N is the number of sensors. - """ + Shape is (N,), dtype ``wp.quatf``. In torch this resolves to (N, 4), + where N is the number of sensors. Use ``.warp`` for the underlying + ``wp.array`` or ``.torch`` for a cached zero-copy ``torch.Tensor`` view. + """ + return self._quat_w_world_pa ## # Camera data ## - image_shape: tuple[int, int] = None - """A tuple containing (height, width) of the camera sensor.""" + @property + def intrinsic_matrices(self) -> ProxyArray: + """The intrinsic matrices for the camera. - intrinsic_matrices: torch.Tensor = None - """The intrinsic matrices for the camera. + Shape is (N,), dtype ``wp.mat33f``. In torch this resolves to (N, 3, 3), + where N is the number of sensors. Use ``.warp`` for the underlying + ``wp.array`` or ``.torch`` for a cached zero-copy ``torch.Tensor`` view. + """ + return self._intrinsic_matrices_pa - Shape is (N, 3, 3) where N is the number of sensors. - """ + @property + def output(self) -> dict[str, ProxyArray] | None: + """The retrieved sensor data with sensor types as key. - output: dict[str, torch.Tensor] = None - """The retrieved sensor data with sensor types as key. + Each value is a :class:`~isaaclab.utils.warp.ProxyArray` of shape + ``(N, H, W, C)`` where N is the number of views, H/W are image dimensions, + and C is the number of channels. Use ``.torch`` for a ``torch.Tensor`` view + or ``.warp`` for the underlying ``wp.array``. - The format of the data is available in the `Replicator Documentation`_. For semantic-based data, - this corresponds to the ``"data"`` key in the output of the sensor. + The format of the data is available in the `Replicator Documentation`_. For semantic-based data, + this corresponds to the ``"data"`` key in the output of the sensor. - .. _Replicator Documentation: https://docs.omniverse.nvidia.com/prod_extensions/prod_extensions/ext_replicator/annotators_details.html#annotator-output - """ + .. _Replicator Documentation: https://docs.omniverse.nvidia.com/prod_extensions/prod_extensions/ext_replicator/annotators_details.html#annotator-output + """ + return self._output - info: dict[str, Any] = None - """The retrieved sensor info with sensor types as key. + def create_buffers(self, num_views: int, device: str) -> None: + """Allocate warp arrays for pose and intrinsics and create their :class:`ProxyArray` wrappers. - This contains extra information provided by the sensor such as semantic segmentation label mapping, prim paths. - For semantic-based data, this corresponds to the ``"info"`` key in the output of the sensor. For other sensor - types, the info is empty. - """ + Called by :class:`~isaaclab.sensors.camera.Camera` after :meth:`allocate` to + populate the pose and intrinsics buffers. + + Args: + num_views: Number of camera views (batch dimension). + device: Device for tensor storage (e.g. ``"cuda:0"``). + """ + self._pos_w_wp = wp.zeros(num_views, dtype=wp.vec3f, device=device) + self._quat_w_world_wp = wp.zeros(num_views, dtype=wp.quatf, device=device) + self._intrinsic_matrices_wp = wp.zeros(num_views, dtype=wp.mat33f, device=device) + self._quat_w_ros_wp = wp.zeros(num_views, dtype=wp.quatf, device=device) + self._quat_w_opengl_wp = wp.zeros(num_views, dtype=wp.quatf, device=device) + + self._pos_w_pa = ProxyArray(self._pos_w_wp) + self._quat_w_world_pa = ProxyArray(self._quat_w_world_wp) + self._intrinsic_matrices_pa = ProxyArray(self._intrinsic_matrices_wp) + self._quat_w_ros_pa = ProxyArray(self._quat_w_ros_wp) + self._quat_w_opengl_pa = ProxyArray(self._quat_w_opengl_wp) @classmethod def allocate( @@ -80,11 +146,13 @@ def allocate( device: torch.device | str, supported_specs: dict[RenderBufferKind, RenderBufferSpec], ) -> CameraData: - """Build a :class:`CameraData` with output buffers pre-allocated. + """Build a :class:`CameraData` with output buffers pre-allocated as warp arrays. - Allocates one ``(num_views, height, width, channels)`` tensor per kind + Allocates one ``(num_views, height, width, channels)`` warp array per kind in the intersection of ``data_types`` and ``supported_specs``, using - the channels and dtype from each :class:`RenderBufferSpec`. + the channels and dtype from each :class:`RenderBufferSpec`. Each buffer is + wrapped in a :class:`~isaaclab.utils.warp.ProxyArray`; call ``.torch`` on + the result to obtain a zero-copy :class:`torch.Tensor` view. Args: data_types: Requested output names (typically :attr:`CameraCfg.data_types`). @@ -99,7 +167,8 @@ def allocate( Returns: A new :class:`CameraData` with :attr:`image_shape`, :attr:`output`, - and :attr:`info` populated; all other fields at their defaults. + and :attr:`info` populated; pose/intrinsic buffers must be created + separately via :meth:`create_buffers`. Raises: ValueError: If ``data_types`` contains names that are not members of @@ -114,7 +183,8 @@ def allocate( unknown.append(name) if unknown: raise ValueError(f"Unknown RenderBufferKind name(s): {unknown}. Expected members of RenderBufferKind.") - # rgb is exposed as a view into rgba when the renderer publishes both, + + # rgb is exposed as a strided view into rgba when the renderer publishes both, # so requesting either one allocates the shared rgba buffer. rgb_alias = ( RenderBufferKind.RGBA in supported_specs @@ -124,49 +194,68 @@ def allocate( if rgb_alias: requested.update({RenderBufferKind.RGB, RenderBufferKind.RGBA}) - buffers: dict[str, torch.Tensor] = {} + device_str = device if isinstance(device, str) else str(device) + + buffers: dict[str, ProxyArray] = {} for name, spec in supported_specs.items(): if name not in requested: continue if rgb_alias and name == RenderBufferKind.RGB: - continue - buffers[str(name)] = torch.zeros( - (num_views, height, width, spec.channels), - dtype=spec.dtype, - device=device, - ).contiguous() - if rgb_alias: - buffers[str(RenderBufferKind.RGB)] = buffers[str(RenderBufferKind.RGBA)][..., :3] + continue # created below as a strided view into rgba + wp_arr = wp.zeros((num_views, height, width, spec.channels), dtype=spec.dtype, device=device_str) + buffers[str(name)] = ProxyArray(wp_arr) - return cls( - image_shape=(height, width), - output=buffers, - info={name: None for name in buffers}, - ) + if rgb_alias: + # Zero-copy strided view into rgba: shape (N, H, W, 3), skipping the alpha channel. + # Byte strides for a contiguous (N, H, W, 4) uint8 array are (H*W*4, W*4, 4, 1). + # Using the same outer strides but limiting the last dim to 3 channels gives a + # non-contiguous view where each pixel reads RGB without the alpha byte. + rgba_wp = buffers[str(RenderBufferKind.RGBA)].warp + rgb_wp = wp.array( + ptr=rgba_wp.ptr, + shape=(num_views, height, width, 3), + strides=(height * width * 4, width * 4, 4, 1), + dtype=wp.uint8, + device=rgba_wp.device, + copy=False, + ) + buffers[str(RenderBufferKind.RGB)] = ProxyArray(rgb_wp) + + obj = cls() + obj.image_shape = (height, width) + obj._output = buffers + obj.info = {name: None for name in buffers} + return obj ## # Additional Frame orientation conventions ## @property - def quat_w_ros(self) -> torch.Tensor: - """Quaternion orientation `(x, y, z, w)` of the sensor origin in the world frame, following ROS convention. + def quat_w_ros(self) -> ProxyArray: + """Quaternion orientation ``(x, y, z, w)`` of the sensor origin in the world frame, following ROS convention. .. note:: ROS convention follows the camera aligned with forward axis +Z and up axis -Y. - Shape is (N, 4) where N is the number of sensors. + Shape is (N,), dtype ``wp.quatf``. In torch this resolves to (N, 4), + where N is the number of sensors. Use ``.warp`` for the underlying + ``wp.array`` or ``.torch`` for a cached zero-copy ``torch.Tensor`` view. """ - return convert_camera_frame_orientation_convention(self.quat_w_world, origin="world", target="ros") + convert_camera_frame_orientation_convention_wp(self._quat_w_world_wp, self._quat_w_ros_wp, "world", "ros") + return self._quat_w_ros_pa @property - def quat_w_opengl(self) -> torch.Tensor: - """Quaternion orientation `(x, y, z, w)` of the sensor origin in the world frame, following + def quat_w_opengl(self) -> ProxyArray: + """Quaternion orientation ``(x, y, z, w)`` of the sensor origin in the world frame, following Opengl / USD Camera convention. .. note:: OpenGL convention follows the camera aligned with forward axis -Z and up axis +Y. - Shape is (N, 4) where N is the number of sensors. + Shape is (N,), dtype ``wp.quatf``. In torch this resolves to (N, 4), + where N is the number of sensors. Use ``.warp`` for the underlying + ``wp.array`` or ``.torch`` for a cached zero-copy ``torch.Tensor`` view. """ - return convert_camera_frame_orientation_convention(self.quat_w_world, origin="world", target="opengl") + convert_camera_frame_orientation_convention_wp(self._quat_w_world_wp, self._quat_w_opengl_wp, "world", "opengl") + return self._quat_w_opengl_pa 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 a14c16f3d0d9..a868d17c7848 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 @@ -124,9 +124,6 @@ def _initialize_rays_impl(self): 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) - # 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) @@ -184,8 +181,8 @@ def _update_ray_infos(self, env_mask: wp.array): pos_w, quat_w, self._offset_pos[env_ids], self._offset_quat[env_ids] ) # 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.pos_w.torch[env_ids] = pos_w + self._data.quat_w_world.torch[env_ids] = quat_w self._quat_w_wp_torch[env_ids] = quat_w # Rotate local ray starts and directions into world frame using full camera orientation 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 a9b7239b8991..650dfee54ac3 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py @@ -16,6 +16,7 @@ import isaaclab.utils.math as math_utils from isaaclab.sensors.camera import CameraData +from isaaclab.utils.warp import ProxyArray from isaaclab.utils.warp.kernels import raycast_mesh_masked_kernel from .kernels import ( @@ -552,16 +553,13 @@ def _create_buffers(self): 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 - # -- pose of the cameras - 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) - # -- intrinsic matrix - self._data.intrinsic_matrices = torch.zeros((self._view.count, 3, 3), device=self._device) - self._data.intrinsic_matrices[:, 2, 2] = 1.0 + # -- pose and intrinsics as warp-backed ProxyArrays + device_str = self._device if isinstance(self._device, str) else str(self._device) + self._data.create_buffers(self._view.count, device_str) + self._data.intrinsic_matrices.torch[:, 2, 2] = 1.0 self._data.image_shape = self.image_shape - # -- output data - # create the buffers to store the annotator data. - self._data.output = {} + # -- output data as warp-backed ProxyArrays + output = {} self._data.info = {name: None for name in self.cfg.data_types} for name in self.cfg.data_types: if name in ["distance_to_image_plane", "distance_to_camera"]: @@ -570,8 +568,9 @@ def _create_buffers(self): shape = (self.cfg.pattern_cfg.height, self.cfg.pattern_cfg.width, 3) else: raise ValueError(f"Received unknown data type: {name}. Please check the configuration.") - # allocate tensor to store the data - self._data.output[name] = torch.zeros((self._view.count, *shape), device=self._device) + wp_arr = wp.zeros((self._view.count, *shape), dtype=wp.float32, device=device_str) + output[name] = ProxyArray(wp_arr) + self._data._output = output def _compute_intrinsic_matrices(self): """Computes the intrinsic matrices for the camera based on the config provided.""" diff --git a/source/isaaclab/isaaclab/utils/warp/proxy_array.py b/source/isaaclab/isaaclab/utils/warp/proxy_array.py index 912e44798877..2a56c9bebc4b 100644 --- a/source/isaaclab/isaaclab/utils/warp/proxy_array.py +++ b/source/isaaclab/isaaclab/utils/warp/proxy_array.py @@ -202,6 +202,24 @@ def __array_interface__(self): """ return self._warp.__array_interface__ + # ------------------------------------------------------------------ + # Attribute forwarding (deprecation bridge — delegates to .torch) + # ------------------------------------------------------------------ + + def __getattr__(self, name: str): + """Forward unknown attribute access to the torch view (deprecation bridge). + + Called only when normal attribute lookup fails (i.e. the attribute is not + defined on :class:`ProxyArray` itself), so explicit properties such as + ``shape``, ``dtype``, ``device``, ``warp``, and ``torch`` are unaffected. + + This allows tensor instance methods (``float()``, ``clone()``, ``cpu()``, + ``permute()``, etc.) to be called on a :class:`ProxyArray` without an + explicit ``.torch`` accessor, emitting a one-time :class:`DeprecationWarning`. + """ + self._warn_implicit() + return getattr(self.torch, name) + # ------------------------------------------------------------------ # Indexing (deprecation bridge — delegates to .torch) # ------------------------------------------------------------------ diff --git a/source/isaaclab/isaaclab/utils/warp/warp_math.py b/source/isaaclab/isaaclab/utils/warp/warp_math.py new file mode 100644 index 000000000000..52d4e708f858 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/warp/warp_math.py @@ -0,0 +1,186 @@ +# 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 and helpers for camera-related math operations. + +These replace equivalent torch functions on the per-frame hot path, operating +directly on warp arrays without torch round-trips. +""" + +from __future__ import annotations + +from typing import Literal + +import warp as wp + +# Camera orientation convention conversion +# +# Every pair of (origin, target) conventions is equivalent to a single +# right-multiplication by a constant unit quaternion: +# +# q_out[i] = q_in[i] * q_const +# +# Derivations (xyzw): +# opengl ↔ ros : 180° around X → (1, 0, 0, 0) (self-inverse) +# world → opengl : Rx(+90°)·Ry(−90°) → (0.5, −0.5, −0.5, 0.5) +# opengl → world : inverse of above → (−0.5, 0.5, 0.5, 0.5) +# ros → world : compose ros→gl→world → (0.5, −0.5, 0.5, 0.5) +# world → ros : inverse of above → (−0.5, 0.5, −0.5, 0.5) + +_CAMERA_ORIENTATION_CONST: dict[tuple[str, str], wp.quatf] = { + ("opengl", "ros"): wp.quatf(1.0, 0.0, 0.0, 0.0), + ("ros", "opengl"): wp.quatf(1.0, 0.0, 0.0, 0.0), + ("world", "opengl"): wp.quatf(0.5, -0.5, -0.5, 0.5), + ("opengl", "world"): wp.quatf(-0.5, 0.5, 0.5, 0.5), + ("ros", "world"): wp.quatf(0.5, -0.5, 0.5, 0.5), + ("world", "ros"): wp.quatf(-0.5, 0.5, -0.5, 0.5), +} + + +# TODO: Optimize these kernels with tiled ops and use wp.static +@wp.kernel +def _convert_camera_orientation_all_kernel( + src: wp.array(dtype=wp.quatf), + dst: wp.array(dtype=wp.quatf), + q_const: wp.quatf, +): + """Apply constant-quaternion convention conversion to every element.""" + i = wp.tid() + dst[i] = src[i] * q_const + + +@wp.kernel +def _convert_camera_orientation_indexed_kernel( + src: wp.array(dtype=wp.quatf), + dst: wp.array(dtype=wp.quatf), + indices: wp.array(dtype=wp.int32), + q_const: wp.quatf, +): + """Apply constant-quaternion convention conversion to indexed elements. + + Reads ``src[i]`` and writes to ``dst[indices[i]]``. Use this for partial + camera updates (e.g. environment resets targeting a subset of cameras). + """ + i = wp.tid() + dst[indices[i]] = src[i] * q_const + + +def convert_camera_frame_orientation_convention_wp( + src: wp.array, + dst: wp.array, + origin: Literal["opengl", "ros", "world"], + target: Literal["opengl", "ros", "world"], + indices: wp.array | None = None, + device: str | None = None, +) -> None: + """Convert camera-frame quaternion orientations between conventions using a warp kernel. + + Replaces :func:`~isaaclab.utils.math.convert_camera_frame_orientation_convention` on + the per-frame hot path. All six convention pairs collapse to a single quaternion + right-multiplication by a pre-computed constant — no matrix round-trip, no torch. + + The operation is **in-place on** ``dst``: + - Without ``indices``: ``dst[i] = src[i] * q_const`` for all i. + - With ``indices``: ``dst[indices[i]] = src[i] * q_const`` for each i. + + Args: + src: Source quaternions ``(x, y, z, w)``. Shape ``(N,)``, dtype ``wp.quatf``. + dst: Destination quaternion array to write into. Shape ``(M,)``, dtype ``wp.quatf``. + ``M >= N`` when ``indices`` is provided; ``M == N`` otherwise. + origin: Source convention (``"opengl"``, ``"ros"``, or ``"world"``). + target: Target convention (``"opengl"``, ``"ros"``, or ``"world"``). + indices: Optional warp int32 array of shape ``(N,)`` selecting which slots of + ``dst`` to write. If ``None`` all N elements are written sequentially. + device: Warp device string. Defaults to ``src.device``. + """ + if origin == target: + if indices is None: + wp.copy(dst, src) + else: + # scatter copy: dst[indices[i]] = src[i] + wp.launch( + _convert_camera_orientation_indexed_kernel, + dim=indices.shape[0], + inputs=[src, dst, indices, wp.quatf(0.0, 0.0, 0.0, 1.0)], + device=device or src.device, + ) + return + + q_const = _CAMERA_ORIENTATION_CONST[(origin, target)] + dev = device or src.device + + if indices is None: + wp.launch( + _convert_camera_orientation_all_kernel, + dim=src.shape[0], + inputs=[src, dst, q_const], + device=dev, + ) + else: + wp.launch( + _convert_camera_orientation_indexed_kernel, + dim=indices.shape[0], + inputs=[src, dst, indices, q_const], + device=dev, + ) + + +@wp.kernel +def _clamp_depth_to_inf_kernel( + buf: wp.array(dtype=wp.float32, ndim=4), + max_range: float, +): + """Replace values above ``max_range`` with ``+inf``.""" + n, h, w, c = wp.tid() + v = buf[n, h, w, c] + if v > max_range: + buf[n, h, w, c] = wp.inf + + +@wp.kernel +def _replace_inf_kernel( + buf: wp.array(dtype=wp.float32, ndim=4), + replacement: float, +): + """Replace ``+inf`` values with ``replacement``.""" + n, h, w, c = wp.tid() + if wp.isinf(buf[n, h, w, c]): + buf[n, h, w, c] = replacement + + +def clamp_depth_to_inf_wp(buf: wp.array, max_range: float, device: str | None = None) -> None: + """Replace depth values above ``max_range`` with ``+inf`` using a warp kernel. + + Replaces ``t[t > max_range] = torch.inf`` on the hot path. + + Args: + buf: Depth buffer. Shape ``(N, H, W, C)``, dtype ``wp.float32``. + max_range: Depth values strictly greater than this are set to ``+inf``. + device: Warp device string. Defaults to ``buf.device``. + """ + wp.launch( + _clamp_depth_to_inf_kernel, + dim=buf.shape, + inputs=[buf, float(max_range)], + device=device or buf.device, + ) + + +def replace_inf_depth_wp(buf: wp.array, replacement: float, device: str | None = None) -> None: + """Replace ``+inf`` depth values with ``replacement`` using a warp kernel. + + Replaces ``t[torch.isinf(t)] = value`` on the hot path. + + Args: + buf: Depth buffer. Shape ``(N, H, W, C)``, dtype ``wp.float32``. + replacement: Value to write where ``+inf`` was found (e.g. ``0.0`` or ``max_range``). + device: Warp device string. Defaults to ``buf.device``. + """ + wp.launch( + _replace_inf_kernel, + dim=buf.shape, + inputs=[buf, float(replacement)], + device=device or buf.device, + ) diff --git a/source/isaaclab/test/renderers/test_camera_output_contract.py b/source/isaaclab/test/renderers/test_camera_output_contract.py index 2d6087d29708..6c0c7dedaf6d 100644 --- a/source/isaaclab/test/renderers/test_camera_output_contract.py +++ b/source/isaaclab/test/renderers/test_camera_output_contract.py @@ -8,7 +8,7 @@ import warnings import pytest -import torch +import warp as wp pytest.importorskip("isaaclab_physx") @@ -148,10 +148,10 @@ def test_camera_data_allocates_supported_subset_and_aliases_rgb(): """CameraData allocates the intersection of requested + supported and aliases rgb into rgba.""" cfg = _make_camera_cfg(["rgb", "rgba", "depth"]) specs = { - RenderBufferKind.RGBA: RenderBufferSpec(4, torch.uint8), - RenderBufferKind.RGB: RenderBufferSpec(3, torch.uint8), - RenderBufferKind.DEPTH: RenderBufferSpec(1, torch.float32), - RenderBufferKind.NORMALS: RenderBufferSpec(3, torch.float32), + RenderBufferKind.RGBA: RenderBufferSpec(4, wp.uint8), + RenderBufferKind.RGB: RenderBufferSpec(3, wp.uint8), + RenderBufferKind.DEPTH: RenderBufferSpec(1, wp.float32), + RenderBufferKind.NORMALS: RenderBufferSpec(3, wp.float32), } data = CameraData.allocate( data_types=cfg.data_types, height=8, width=16, num_views=2, device="cpu", supported_specs=specs @@ -159,10 +159,10 @@ def test_camera_data_allocates_supported_subset_and_aliases_rgb(): assert set(data.output.keys()) == {"rgba", "rgb", "depth"} assert data.output["rgba"].shape == (2, 8, 16, 4) - assert data.output["rgba"].dtype == torch.uint8 + assert data.output["rgba"].dtype == wp.uint8 assert data.output["depth"].shape == (2, 8, 16, 1) - assert data.output["depth"].dtype == torch.float32 - assert data.output["rgb"].data_ptr() == data.output["rgba"].data_ptr() + assert data.output["depth"].dtype == wp.float32 + assert data.output["rgb"].warp.ptr == data.output["rgba"].warp.ptr assert data.image_shape == (8, 16) assert data.info == {"rgba": None, "rgb": None, "depth": None} @@ -171,8 +171,8 @@ def test_camera_data_drops_requested_types_not_in_supported_specs(): """Requested types absent from supported_specs are absent from data.output.""" cfg = _make_camera_cfg(["rgb", "normals"]) specs = { - RenderBufferKind.RGBA: RenderBufferSpec(4, torch.uint8), - RenderBufferKind.RGB: RenderBufferSpec(3, torch.uint8), + RenderBufferKind.RGBA: RenderBufferSpec(4, wp.uint8), + RenderBufferKind.RGB: RenderBufferSpec(3, wp.uint8), } data = CameraData.allocate( data_types=cfg.data_types, height=4, width=4, num_views=1, device="cpu", supported_specs=specs @@ -196,8 +196,8 @@ def test_camera_data_no_arg_construction_yields_empty_container(): def test_camera_data_segmentation_dtype_follows_supported_spec(): """CameraData consumes the layout dtype declared by the renderer spec.""" cfg = _make_camera_cfg(["instance_segmentation_fast"]) - raw_specs = {RenderBufferKind.INSTANCE_SEGMENTATION_FAST: RenderBufferSpec(1, torch.int32)} - colorized_specs = {RenderBufferKind.INSTANCE_SEGMENTATION_FAST: RenderBufferSpec(4, torch.uint8)} + raw_specs = {RenderBufferKind.INSTANCE_SEGMENTATION_FAST: RenderBufferSpec(1, wp.int32)} + colorized_specs = {RenderBufferKind.INSTANCE_SEGMENTATION_FAST: RenderBufferSpec(4, wp.uint8)} raw = CameraData.allocate( data_types=cfg.data_types, height=4, width=4, num_views=1, device="cpu", supported_specs=raw_specs @@ -206,15 +206,15 @@ def test_camera_data_segmentation_dtype_follows_supported_spec(): data_types=cfg.data_types, height=4, width=4, num_views=1, device="cpu", supported_specs=colorized_specs ) - assert raw.output["instance_segmentation_fast"].dtype == torch.int32 + assert raw.output["instance_segmentation_fast"].dtype == wp.int32 assert raw.output["instance_segmentation_fast"].shape == (1, 4, 4, 1) - assert colorized.output["instance_segmentation_fast"].dtype == torch.uint8 + assert colorized.output["instance_segmentation_fast"].dtype == wp.uint8 assert colorized.output["instance_segmentation_fast"].shape == (1, 4, 4, 4) def test_camera_data_allocate_raises_on_unknown_name(): """An unknown data_types name raises ValueError naming the offender.""" - supported_specs = {RenderBufferKind.RGBA: RenderBufferSpec(4, torch.uint8)} + supported_specs = {RenderBufferKind.RGBA: RenderBufferSpec(4, wp.uint8)} with pytest.raises(ValueError) as exc_info: CameraData.allocate( data_types=["not_a_real_type"], diff --git a/source/isaaclab/test/renderers/test_simulation_render_context.py b/source/isaaclab/test/renderers/test_simulation_render_context.py index 905643eefed1..4b32544eab92 100644 --- a/source/isaaclab/test/renderers/test_simulation_render_context.py +++ b/source/isaaclab/test/renderers/test_simulation_render_context.py @@ -12,7 +12,6 @@ from unittest.mock import patch import pytest -import torch from isaaclab.renderers.base_renderer import BaseRenderer from isaaclab.renderers.output_contract import RenderBufferKind, RenderBufferSpec @@ -55,7 +54,7 @@ def prepare_stage(self, stage: Any, num_envs: int) -> None: def create_render_data(self, spec: Any) -> Any: return object() - def set_outputs(self, render_data: Any, output_data: dict[str, torch.Tensor]) -> None: + def set_outputs(self, render_data: Any, output_data: Any) -> None: pass def update_transforms(self) -> None: @@ -64,13 +63,7 @@ def update_transforms(self) -> None: if self._event_log is not None: self._event_log.append("ut") - def update_camera( - self, - render_data: Any, - positions: torch.Tensor, - orientations: torch.Tensor, - intrinsics: torch.Tensor, - ) -> None: + def update_camera(self, render_data: Any, positions: Any, orientations: Any, intrinsics: Any) -> None: pass def render(self, render_data: Any) -> None: diff --git a/source/isaaclab/test/sensors/test_camera.py b/source/isaaclab/test/sensors/test_camera.py index 99c93fec7323..6d09bf6082a1 100644 --- a/source/isaaclab/test/sensors/test_camera.py +++ b/source/isaaclab/test/sensors/test_camera.py @@ -22,6 +22,7 @@ import pytest import scipy.spatial.transform as tf import torch +import warp as wp import omni.replicator.core as rep from pxr import Gf, Usd, UsdGeom @@ -38,6 +39,19 @@ QUAT_OPENGL = (0.17591988, 0.42470818, 0.82047324, 0.33985113) QUAT_WORLD = (-0.27984815, -0.1159169, 0.88047623, -0.3647052) + +def _assert_quat_close(actual, expected, **kwargs): + """Assert quaternions match while allowing the equivalent negated representation.""" + if hasattr(actual, "torch"): + actual = actual.torch + if hasattr(expected, "torch"): + expected = expected.torch + actual = torch.as_tensor(actual) + expected = torch.as_tensor(expected, dtype=actual.dtype, device=actual.device) + expected = torch.where((actual * expected).sum(dim=-1, keepdim=True) < 0.0, -expected, expected) + torch.testing.assert_close(actual, expected, **kwargs) + + # NOTE: setup and teardown are own function to allow calling them in the tests # resolutions @@ -105,11 +119,11 @@ def test_camera_init(setup_sim_camera): assert isinstance(camera._sensor_prims[0], UsdGeom.Camera) # Check buffers that exist and have correct shapes - assert camera.data.pos_w.shape == (1, 3) - assert camera.data.quat_w_ros.shape == (1, 4) - assert camera.data.quat_w_world.shape == (1, 4) - assert camera.data.quat_w_opengl.shape == (1, 4) - assert camera.data.intrinsic_matrices.shape == (1, 3, 3) + assert camera.data.pos_w.torch.shape == (1, 3) + assert camera.data.quat_w_ros.torch.shape == (1, 4) + assert camera.data.quat_w_world.torch.shape == (1, 4) + assert camera.data.quat_w_opengl.torch.shape == (1, 4) + assert camera.data.intrinsic_matrices.torch.shape == (1, 3, 3) assert camera.data.image_shape == (camera_cfg.height, camera_cfg.width) assert camera.data.info == {camera_cfg.data_types[0]: None} @@ -194,9 +208,9 @@ def test_camera_init_offset(setup_sim_camera): # check if transform correctly set in output np.testing.assert_allclose(camera_ros.data.pos_w[0].cpu().numpy(), cam_cfg_offset_ros.offset.pos, rtol=1e-5) - np.testing.assert_allclose(camera_ros.data.quat_w_ros[0].cpu().numpy(), QUAT_ROS, rtol=1e-5) - np.testing.assert_allclose(camera_ros.data.quat_w_opengl[0].cpu().numpy(), QUAT_OPENGL, rtol=1e-5) - np.testing.assert_allclose(camera_ros.data.quat_w_world[0].cpu().numpy(), QUAT_WORLD, rtol=1e-5) + _assert_quat_close(camera_ros.data.quat_w_ros[0], QUAT_ROS, rtol=1e-5, atol=1e-5) + _assert_quat_close(camera_ros.data.quat_w_opengl[0], QUAT_OPENGL, rtol=1e-5, atol=1e-5) + _assert_quat_close(camera_ros.data.quat_w_world[0], QUAT_WORLD, rtol=1e-5, atol=1e-5) def test_multi_camera_init(setup_sim_camera): @@ -326,8 +340,8 @@ def test_camera_set_world_poses(setup_sim_camera): camera.set_world_poses(position.clone(), orientation.clone(), convention="world") # check if transform correctly set in output - torch.testing.assert_close(camera.data.pos_w, position) - torch.testing.assert_close(camera.data.quat_w_world, orientation) + torch.testing.assert_close(camera.data.pos_w.torch, position) + torch.testing.assert_close(camera.data.quat_w_world.torch, orientation) def test_camera_set_world_poses_from_view(setup_sim_camera): @@ -348,8 +362,8 @@ def test_camera_set_world_poses_from_view(setup_sim_camera): camera.set_world_poses_from_view(eyes.clone(), targets.clone()) # check if transform correctly set in output - torch.testing.assert_close(camera.data.pos_w, eyes) - torch.testing.assert_close(camera.data.quat_w_ros, quat_ros_gt) + torch.testing.assert_close(camera.data.pos_w.torch, eyes) + _assert_quat_close(camera.data.quat_w_ros.torch, quat_ros_gt) def test_intrinsic_matrix(setup_sim_camera): @@ -536,17 +550,17 @@ def test_camera_resolution_all_colorize(setup_sim_camera): # access image data and compare dtype output = camera.data.output - assert output["rgb"].dtype == torch.uint8 - assert output["rgba"].dtype == torch.uint8 - assert output["albedo"].dtype == torch.uint8 - assert output["depth"].dtype == torch.float - assert output["distance_to_camera"].dtype == torch.float - assert output["distance_to_image_plane"].dtype == torch.float - assert output["normals"].dtype == torch.float - assert output["motion_vectors"].dtype == torch.float - assert output["semantic_segmentation"].dtype == torch.uint8 - assert output["instance_segmentation_fast"].dtype == torch.uint8 - assert output["instance_id_segmentation_fast"].dtype == torch.uint8 + assert output["rgb"].dtype == wp.uint8 + assert output["rgba"].dtype == wp.uint8 + assert output["albedo"].dtype == wp.uint8 + assert output["depth"].dtype == wp.float32 + assert output["distance_to_camera"].dtype == wp.float32 + assert output["distance_to_image_plane"].dtype == wp.float32 + assert output["normals"].dtype == wp.float32 + assert output["motion_vectors"].dtype == wp.float32 + assert output["semantic_segmentation"].dtype == wp.uint8 + assert output["instance_segmentation_fast"].dtype == wp.uint8 + assert output["instance_id_segmentation_fast"].dtype == wp.uint8 def test_camera_resolution_no_colorize(setup_sim_camera): @@ -597,17 +611,17 @@ def test_camera_resolution_no_colorize(setup_sim_camera): # access image data and compare dtype output = camera.data.output - assert output["rgb"].dtype == torch.uint8 - assert output["rgba"].dtype == torch.uint8 - assert output["albedo"].dtype == torch.uint8 - assert output["depth"].dtype == torch.float - assert output["distance_to_camera"].dtype == torch.float - assert output["distance_to_image_plane"].dtype == torch.float - assert output["normals"].dtype == torch.float - assert output["motion_vectors"].dtype == torch.float - assert output["semantic_segmentation"].dtype == torch.int32 - assert output["instance_segmentation_fast"].dtype == torch.int32 - assert output["instance_id_segmentation_fast"].dtype == torch.int32 + assert output["rgb"].dtype == wp.uint8 + assert output["rgba"].dtype == wp.uint8 + assert output["albedo"].dtype == wp.uint8 + assert output["depth"].dtype == wp.float32 + assert output["distance_to_camera"].dtype == wp.float32 + assert output["distance_to_image_plane"].dtype == wp.float32 + assert output["normals"].dtype == wp.float32 + assert output["motion_vectors"].dtype == wp.float32 + assert output["semantic_segmentation"].dtype == wp.int32 + assert output["instance_segmentation_fast"].dtype == wp.int32 + assert output["instance_id_segmentation_fast"].dtype == wp.int32 def test_camera_large_resolution_all_colorize(setup_sim_camera): @@ -661,17 +675,17 @@ def test_camera_large_resolution_all_colorize(setup_sim_camera): # access image data and compare dtype output = camera.data.output - assert output["rgb"].dtype == torch.uint8 - assert output["rgba"].dtype == torch.uint8 - assert output["albedo"].dtype == torch.uint8 - assert output["depth"].dtype == torch.float - assert output["distance_to_camera"].dtype == torch.float - assert output["distance_to_image_plane"].dtype == torch.float - assert output["normals"].dtype == torch.float - assert output["motion_vectors"].dtype == torch.float - assert output["semantic_segmentation"].dtype == torch.uint8 - assert output["instance_segmentation_fast"].dtype == torch.uint8 - assert output["instance_id_segmentation_fast"].dtype == torch.uint8 + assert output["rgb"].dtype == wp.uint8 + assert output["rgba"].dtype == wp.uint8 + assert output["albedo"].dtype == wp.uint8 + assert output["depth"].dtype == wp.float32 + assert output["distance_to_camera"].dtype == wp.float32 + assert output["distance_to_image_plane"].dtype == wp.float32 + assert output["normals"].dtype == wp.float32 + assert output["motion_vectors"].dtype == wp.float32 + assert output["semantic_segmentation"].dtype == wp.uint8 + assert output["instance_segmentation_fast"].dtype == wp.uint8 + assert output["instance_id_segmentation_fast"].dtype == wp.uint8 def test_camera_resolution_rgb_only(setup_sim_camera): @@ -693,7 +707,7 @@ def test_camera_resolution_rgb_only(setup_sim_camera): output = camera.data.output assert output["rgb"].shape == hw_3c_shape # access image data and compare dtype - assert output["rgb"].dtype == torch.uint8 + assert output["rgb"].dtype == wp.uint8 def test_camera_resolution_rgba_only(setup_sim_camera): @@ -715,7 +729,7 @@ def test_camera_resolution_rgba_only(setup_sim_camera): output = camera.data.output assert output["rgba"].shape == hw_4c_shape # access image data and compare dtype - assert output["rgba"].dtype == torch.uint8 + assert output["rgba"].dtype == wp.uint8 def test_camera_resolution_albedo_only(setup_sim_camera): @@ -737,7 +751,7 @@ def test_camera_resolution_albedo_only(setup_sim_camera): output = camera.data.output assert output["albedo"].shape == hw_4c_shape # access image data and compare dtype - assert output["albedo"].dtype == torch.uint8 + assert output["albedo"].dtype == wp.uint8 @pytest.mark.parametrize( @@ -763,7 +777,7 @@ def test_camera_resolution_simple_shading_only(setup_sim_camera, data_type): output = camera.data.output assert output[data_type].shape == hw_3c_shape # access image data and compare dtype - assert output[data_type].dtype == torch.uint8 + assert output[data_type].dtype == wp.uint8 def test_camera_resolution_depth_only(setup_sim_camera): @@ -785,7 +799,7 @@ def test_camera_resolution_depth_only(setup_sim_camera): output = camera.data.output assert output["depth"].shape == hw_1c_shape # access image data and compare dtype - assert output["depth"].dtype == torch.float + assert output["depth"].dtype == wp.float32 def test_sensor_print(setup_sim_camera): @@ -847,11 +861,11 @@ def test_camera_multi_regex_init(setup_camera_device, device): assert camera._sensor_prims[1].GetPath().pathString == "/World/Origin_1/CameraSensor" assert isinstance(camera._sensor_prims[0], UsdGeom.Camera) - assert camera.data.pos_w.shape == (num_cameras, 3) - assert camera.data.quat_w_ros.shape == (num_cameras, 4) - assert camera.data.quat_w_world.shape == (num_cameras, 4) - assert camera.data.quat_w_opengl.shape == (num_cameras, 4) - assert camera.data.intrinsic_matrices.shape == (num_cameras, 3, 3) + assert camera.data.pos_w.torch.shape == (num_cameras, 3) + assert camera.data.quat_w_ros.torch.shape == (num_cameras, 4) + assert camera.data.quat_w_world.torch.shape == (num_cameras, 4) + assert camera.data.quat_w_opengl.torch.shape == (num_cameras, 4) + assert camera.data.intrinsic_matrices.torch.shape == (num_cameras, 3, 3) assert camera.data.image_shape == (camera_cfg.height, camera_cfg.width) for _ in range(10): @@ -928,17 +942,17 @@ def test_camera_all_annotators(setup_camera_device, device): output = camera.data.output info = camera.data.info - assert output["rgb"].dtype == torch.uint8 - assert output["rgba"].dtype == torch.uint8 - assert output["albedo"].dtype == torch.uint8 - assert output["depth"].dtype == torch.float - assert output["distance_to_camera"].dtype == torch.float - assert output["distance_to_image_plane"].dtype == torch.float - assert output["normals"].dtype == torch.float - assert output["motion_vectors"].dtype == torch.float - assert output["semantic_segmentation"].dtype == torch.uint8 - assert output["instance_segmentation_fast"].dtype == torch.uint8 - assert output["instance_id_segmentation_fast"].dtype == torch.uint8 + assert output["rgb"].dtype == wp.uint8 + assert output["rgba"].dtype == wp.uint8 + assert output["albedo"].dtype == wp.uint8 + assert output["depth"].dtype == wp.float32 + assert output["distance_to_camera"].dtype == wp.float32 + assert output["distance_to_image_plane"].dtype == wp.float32 + assert output["normals"].dtype == wp.float32 + assert output["motion_vectors"].dtype == wp.float32 + assert output["semantic_segmentation"].dtype == wp.uint8 + assert output["instance_segmentation_fast"].dtype == wp.uint8 + assert output["instance_id_segmentation_fast"].dtype == wp.uint8 assert isinstance(info["semantic_segmentation"], dict) assert isinstance(info["instance_segmentation_fast"], dict) assert isinstance(info["instance_id_segmentation_fast"], dict) @@ -970,7 +984,7 @@ def test_camera_segmentation_non_colorize(setup_camera_device, device): for seg_type in camera_cfg.data_types: assert camera.data.output[seg_type].shape == (num_cameras, camera_cfg.height, camera_cfg.width, 1) - assert camera.data.output[seg_type].dtype == torch.int32 + assert camera.data.output[seg_type].dtype == wp.int32 assert isinstance(camera.data.info[seg_type], dict) del camera @@ -1001,7 +1015,7 @@ def test_camera_normals_unit_length(setup_camera_device, device): norms = torch.linalg.norm(im_data, dim=-1) assert torch.allclose(norms, torch.ones_like(norms), atol=1e-9) - assert camera.data.output["normals"].dtype == torch.float + assert camera.data.output["normals"].dtype == wp.float32 del camera @@ -1096,7 +1110,7 @@ def __init__(self, cfg=None): self.cfg = cfg def supported_output_types(self): - return {RenderBufferKind.RGBA: RenderBufferSpec(4, torch.uint8)} + return {RenderBufferKind.RGBA: RenderBufferSpec(4, wp.uint8)} def prepare_stage(self, stage, num_envs): pass 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 8657c938c691..0319bf80ef1a 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 @@ -38,6 +38,18 @@ QUAT_WORLD = [-0.27984815, -0.1159169, 0.88047623, -0.3647052] +def _assert_quat_close(actual, expected, **kwargs): + """Assert quaternions match while allowing the equivalent negated representation.""" + if hasattr(actual, "torch"): + actual = actual.torch + if hasattr(expected, "torch"): + expected = expected.torch + actual = torch.as_tensor(actual) + expected = torch.as_tensor(expected, dtype=actual.dtype, device=actual.device) + expected = torch.where((actual * expected).sum(dim=-1, keepdim=True) < 0.0, -expected, expected) + torch.testing.assert_close(actual, expected, **kwargs) + + @pytest.fixture(scope="function") def setup_simulation(): """Fixture to set up and tear down the simulation environment.""" @@ -132,11 +144,11 @@ def test_camera_init(setup_simulation): # Check if camera is initialized assert camera.is_initialized # Check buffers that exists and have correct shapes - assert camera.data.pos_w.shape == (1, 3) - assert camera.data.quat_w_ros.shape == (1, 4) - assert camera.data.quat_w_world.shape == (1, 4) - assert camera.data.quat_w_opengl.shape == (1, 4) - assert camera.data.intrinsic_matrices.shape == (1, 3, 3) + assert camera.data.pos_w.torch.shape == (1, 3) + assert camera.data.quat_w_ros.torch.shape == (1, 4) + assert camera.data.quat_w_world.torch.shape == (1, 4) + assert camera.data.quat_w_opengl.torch.shape == (1, 4) + assert camera.data.intrinsic_matrices.torch.shape == (1, 3, 3) assert camera.data.image_shape == (camera_cfg.pattern_cfg.height, camera_cfg.pattern_cfg.width) assert camera.data.info == {camera_cfg.data_types[0]: None} # Simulate physics @@ -272,8 +284,8 @@ def test_camera_set_world_poses(setup_simulation): camera.set_world_poses(position.clone(), orientation.clone(), convention="world") # check if transform correctly set in output - torch.testing.assert_close(camera.data.pos_w, position) - torch.testing.assert_close(camera.data.quat_w_world, orientation) + torch.testing.assert_close(camera.data.pos_w.torch, position) + torch.testing.assert_close(camera.data.quat_w_world.torch, orientation) del camera @@ -295,8 +307,8 @@ def test_camera_set_world_poses_from_view(setup_simulation): camera.set_world_poses_from_view(eyes.clone(), targets.clone()) # check if transform correctly set in output - torch.testing.assert_close(camera.data.pos_w, eyes) - torch.testing.assert_close(camera.data.quat_w_ros, quat_ros_gt) + torch.testing.assert_close(camera.data.pos_w.torch, eyes) + _assert_quat_close(camera.data.quat_w_ros.torch, quat_ros_gt) del camera @@ -325,7 +337,7 @@ def test_intrinsic_matrix(setup_simulation, height, width): # update camera camera.update(dt) # Check that matrix is correct - torch.testing.assert_close(rs_intrinsic_matrix, camera.data.intrinsic_matrices) + torch.testing.assert_close(rs_intrinsic_matrix, camera.data.intrinsic_matrices.torch) del camera @@ -420,7 +432,7 @@ def test_output_equal_to_usdcamera(setup_simulation, data_types): # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( camera_usd.data.output[data_type][..., :3], - camera_warp.data.output[data_type], + camera_warp.data.output[data_type].torch, rtol=1e-5, atol=1e-4, ) @@ -501,7 +513,7 @@ def test_output_equal_to_usdcamera_offset(setup_simulation): # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( camera_usd.data.output["normals"][..., :3], - camera_warp.data.output["normals"], + camera_warp.data.output["normals"].torch, rtol=1e-5, atol=1e-4, ) @@ -577,7 +589,7 @@ def test_output_equal_to_usdcamera_prim_offset(setup_simulation): # check if pos and orientation are correct torch.testing.assert_close(camera_warp.data.pos_w[0], camera_usd.data.pos_w[0]) - torch.testing.assert_close(camera_warp.data.quat_w_ros[0], camera_usd.data.quat_w_ros[0]) + _assert_quat_close(camera_warp.data.quat_w_ros[0], camera_usd.data.quat_w_ros[0]) # check image data torch.testing.assert_close( @@ -597,7 +609,7 @@ def test_output_equal_to_usdcamera_prim_offset(setup_simulation): # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( camera_usd.data.output["normals"][..., :3], - camera_warp.data.output["normals"], + camera_warp.data.output["normals"].torch, rtol=1e-5, atol=1e-4, ) diff --git a/source/isaaclab/test/sensors/test_multi_tiled_camera.py b/source/isaaclab/test/sensors/test_multi_tiled_camera.py index 113524cfe934..5188f5a897ef 100644 --- a/source/isaaclab/test/sensors/test_multi_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_multi_tiled_camera.py @@ -21,6 +21,7 @@ import numpy as np import pytest import torch +import warp as wp from flaky import flaky import omni.replicator.core as rep @@ -101,11 +102,11 @@ def test_multi_tiled_camera_init(setup_camera): for camera in tiled_cameras: # Check buffers that exists and have correct shapes - assert camera.data.pos_w.shape == (num_cameras_per_tiled_camera, 3) - assert camera.data.quat_w_ros.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.quat_w_world.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.quat_w_opengl.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.intrinsic_matrices.shape == (num_cameras_per_tiled_camera, 3, 3) + assert camera.data.pos_w.torch.shape == (num_cameras_per_tiled_camera, 3) + assert camera.data.quat_w_ros.torch.shape == (num_cameras_per_tiled_camera, 4) + assert camera.data.quat_w_world.torch.shape == (num_cameras_per_tiled_camera, 4) + assert camera.data.quat_w_opengl.torch.shape == (num_cameras_per_tiled_camera, 4) + assert camera.data.intrinsic_matrices.torch.shape == (num_cameras_per_tiled_camera, 3, 3) assert camera.data.image_shape == (camera.cfg.height, camera.cfg.width) # Simulate physics @@ -193,11 +194,11 @@ def test_all_annotators_multi_tiled_camera(setup_camera): for camera in tiled_cameras: # Check buffers that exists and have correct shapes - assert camera.data.pos_w.shape == (num_cameras_per_tiled_camera, 3) - assert camera.data.quat_w_ros.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.quat_w_world.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.quat_w_opengl.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.intrinsic_matrices.shape == (num_cameras_per_tiled_camera, 3, 3) + assert camera.data.pos_w.torch.shape == (num_cameras_per_tiled_camera, 3) + assert camera.data.quat_w_ros.torch.shape == (num_cameras_per_tiled_camera, 4) + assert camera.data.quat_w_world.torch.shape == (num_cameras_per_tiled_camera, 4) + assert camera.data.quat_w_opengl.torch.shape == (num_cameras_per_tiled_camera, 4) + assert camera.data.intrinsic_matrices.torch.shape == (num_cameras_per_tiled_camera, 3, 3) assert camera.data.image_shape == (camera.cfg.height, camera.cfg.width) # Simulate physics @@ -234,17 +235,17 @@ def test_all_annotators_multi_tiled_camera(setup_camera): # access image data and compare dtype output = camera.data.output info = camera.data.info - assert output["rgb"].dtype == torch.uint8 - assert output["rgba"].dtype == torch.uint8 - assert output["albedo"].dtype == torch.uint8 - assert output["depth"].dtype == torch.float - assert output["distance_to_camera"].dtype == torch.float - assert output["distance_to_image_plane"].dtype == torch.float - assert output["normals"].dtype == torch.float - assert output["motion_vectors"].dtype == torch.float - assert output["semantic_segmentation"].dtype == torch.uint8 - assert output["instance_segmentation_fast"].dtype == torch.uint8 - assert output["instance_id_segmentation_fast"].dtype == torch.uint8 + assert output["rgb"].dtype == wp.uint8 + assert output["rgba"].dtype == wp.uint8 + assert output["albedo"].dtype == wp.uint8 + assert output["depth"].dtype == wp.float32 + assert output["distance_to_camera"].dtype == wp.float32 + assert output["distance_to_image_plane"].dtype == wp.float32 + assert output["normals"].dtype == wp.float32 + assert output["motion_vectors"].dtype == wp.float32 + assert output["semantic_segmentation"].dtype == wp.uint8 + assert output["instance_segmentation_fast"].dtype == wp.uint8 + assert output["instance_id_segmentation_fast"].dtype == wp.uint8 assert isinstance(info["semantic_segmentation"], dict) assert isinstance(info["instance_segmentation_fast"], dict) assert isinstance(info["instance_id_segmentation_fast"], dict) @@ -289,11 +290,11 @@ def test_different_resolution_multi_tiled_camera(setup_camera): for camera in tiled_cameras: # Check buffers that exists and have correct shapes - assert camera.data.pos_w.shape == (num_cameras_per_tiled_camera, 3) - assert camera.data.quat_w_ros.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.quat_w_world.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.quat_w_opengl.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.intrinsic_matrices.shape == (num_cameras_per_tiled_camera, 3, 3) + assert camera.data.pos_w.torch.shape == (num_cameras_per_tiled_camera, 3) + assert camera.data.quat_w_ros.torch.shape == (num_cameras_per_tiled_camera, 4) + assert camera.data.quat_w_world.torch.shape == (num_cameras_per_tiled_camera, 4) + assert camera.data.quat_w_opengl.torch.shape == (num_cameras_per_tiled_camera, 4) + assert camera.data.intrinsic_matrices.torch.shape == (num_cameras_per_tiled_camera, 3, 3) assert camera.data.image_shape == (camera.cfg.height, camera.cfg.width) # Simulate physics diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index cc10b092a806..d8ac47e95a60 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -38,6 +38,19 @@ QUAT_OPENGL = [0.17591988, 0.42470818, 0.82047324, 0.33985113] QUAT_WORLD = [-0.27984815, -0.1159169, 0.88047623, -0.3647052] + +def _assert_quat_close(actual, expected, **kwargs): + """Assert quaternions match while allowing the equivalent negated representation.""" + if hasattr(actual, "torch"): + actual = actual.torch + if hasattr(expected, "torch"): + expected = expected.torch + actual = torch.as_tensor(actual) + expected = torch.as_tensor(expected, dtype=actual.dtype, device=actual.device) + expected = torch.where((actual * expected).sum(dim=-1, keepdim=True) < 0.0, -expected, expected) + torch.testing.assert_close(actual, expected, **kwargs) + + DEBUG_PLOTS = False @@ -110,11 +123,11 @@ def test_camera_init(setup_sim): # Check if camera is initialized assert camera.is_initialized # Check buffers that exist and have correct shapes - assert camera.data.pos_w.shape == (1, 3) - assert camera.data.quat_w_ros.shape == (1, 4) - assert camera.data.quat_w_world.shape == (1, 4) - assert camera.data.quat_w_opengl.shape == (1, 4) - assert camera.data.intrinsic_matrices.shape == (1, 3, 3) + assert camera.data.pos_w.torch.shape == (1, 3) + assert camera.data.quat_w_ros.torch.shape == (1, 4) + assert camera.data.quat_w_world.torch.shape == (1, 4) + assert camera.data.quat_w_opengl.torch.shape == (1, 4) + assert camera.data.intrinsic_matrices.torch.shape == (1, 3, 3) assert camera.data.image_shape == (camera_cfg.pattern_cfg.height, camera_cfg.pattern_cfg.width) assert camera.data.info == {camera_cfg.data_types[0]: None} # Simulate physics @@ -290,9 +303,9 @@ def test_camera_init_offset(setup_sim): # check if transform correctly set in output np.testing.assert_allclose(camera_ros.data.pos_w[0].cpu().numpy(), cam_cfg_offset_ros.offset.pos, rtol=1e-5) - np.testing.assert_allclose(camera_ros.data.quat_w_ros[0].cpu().numpy(), QUAT_ROS, rtol=1e-5) - np.testing.assert_allclose(camera_ros.data.quat_w_opengl[0].cpu().numpy(), QUAT_OPENGL, rtol=1e-5) - np.testing.assert_allclose(camera_ros.data.quat_w_world[0].cpu().numpy(), QUAT_WORLD, rtol=1e-5) + _assert_quat_close(camera_ros.data.quat_w_ros[0], QUAT_ROS, rtol=1e-5, atol=1e-5) + _assert_quat_close(camera_ros.data.quat_w_opengl[0], QUAT_OPENGL, rtol=1e-5, atol=1e-5) + _assert_quat_close(camera_ros.data.quat_w_world[0], QUAT_WORLD, rtol=1e-5, atol=1e-5) @pytest.mark.isaacsim_ci @@ -401,8 +414,8 @@ def test_camera_set_world_poses(setup_sim): camera.set_world_poses(position.clone(), orientation.clone(), convention="world") # check if transform correctly set in output - torch.testing.assert_close(camera.data.pos_w, position) - torch.testing.assert_close(camera.data.quat_w_world, orientation) + torch.testing.assert_close(camera.data.pos_w.torch, position) + torch.testing.assert_close(camera.data.quat_w_world.torch, orientation) @pytest.mark.isaacsim_ci @@ -421,8 +434,8 @@ def test_camera_set_world_poses_from_view(setup_sim): camera.set_world_poses_from_view(eyes.clone(), targets.clone()) # check if transform correctly set in output - torch.testing.assert_close(camera.data.pos_w, eyes) - torch.testing.assert_close(camera.data.quat_w_ros, quat_ros_gt) + torch.testing.assert_close(camera.data.pos_w.torch, eyes) + _assert_quat_close(camera.data.quat_w_ros, quat_ros_gt) @pytest.mark.isaacsim_ci @@ -447,7 +460,7 @@ def test_intrinsic_matrix(setup_sim): # update camera camera.update(dt) # Check that matrix is correct - torch.testing.assert_close(rs_intrinsic_matrix, camera.data.intrinsic_matrices) + torch.testing.assert_close(rs_intrinsic_matrix, camera.data.intrinsic_matrices.torch) @pytest.mark.isaacsim_ci @@ -543,7 +556,7 @@ def test_output_equal_to_usdcamera(setup_sim): # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( camera_usd.data.output["normals"][..., :3], - camera_warp.data.output["normals"], + camera_warp.data.output["normals"].torch, rtol=1e-5, atol=1e-4, ) @@ -619,7 +632,7 @@ def test_output_equal_to_usdcamera_offset(setup_sim): # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( camera_usd.data.output["normals"][..., :3], - camera_warp.data.output["normals"], + camera_warp.data.output["normals"].torch, rtol=1e-5, atol=1e-4, ) @@ -693,7 +706,7 @@ def test_output_equal_to_usdcamera_prim_offset(setup_sim): # check if pos and orientation are correct torch.testing.assert_close(camera_warp.data.pos_w[0], camera_usd.data.pos_w[0]) - torch.testing.assert_close(camera_warp.data.quat_w_ros[0], camera_usd.data.quat_w_ros[0]) + _assert_quat_close(camera_warp.data.quat_w_ros[0], camera_usd.data.quat_w_ros[0]) # check image data torch.testing.assert_close( @@ -713,7 +726,7 @@ def test_output_equal_to_usdcamera_prim_offset(setup_sim): # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( camera_usd.data.output["normals"][..., :3], - camera_warp.data.output["normals"], + camera_warp.data.output["normals"].torch, rtol=1e-5, atol=1e-4, ) diff --git a/source/isaaclab/test/sensors/test_tiled_camera.py b/source/isaaclab/test/sensors/test_tiled_camera.py index 4ce62cd5336f..47c5aee61d66 100644 --- a/source/isaaclab/test/sensors/test_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_tiled_camera.py @@ -132,8 +132,8 @@ def test_tiled_camera_basic_functionality(setup_camera, device): assert isinstance(camera._sensor_prims[0], UsdGeom.Camera) # Check buffers that exists and have correct shapes - assert camera.data.pos_w.shape == (1, 3) - assert camera.data.intrinsic_matrices.shape == (1, 3, 3) + assert camera.data.pos_w.torch.shape == (1, 3) + assert camera.data.intrinsic_matrices.torch.shape == (1, 3, 3) assert camera.data.image_shape == (camera_cfg.height, camera_cfg.width) # Simulate physics diff --git a/source/isaaclab_newton/changelog.d/mh-warp_cam.minor.rst b/source/isaaclab_newton/changelog.d/mh-warp_cam.minor.rst new file mode 100644 index 000000000000..7955215beba6 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/mh-warp_cam.minor.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Updated :class:`~isaaclab_newton.renderers.NewtonWarpRenderer` to accept + :class:`~isaaclab.utils.warp.ProxyArray` in :meth:`set_outputs` and :meth:`update_camera`, + matching the updated :class:`~isaaclab.renderers.BaseRenderer` interface. Output buffers are + reinterpreted directly from the ProxyArray's underlying warp array, removing the previous + :func:`warp.from_torch` conversion path. 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 fbccc3da4137..fe7db9806cf7 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py @@ -18,13 +18,14 @@ from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec from isaaclab.renderers.camera_render_spec import CameraRenderSpec from isaaclab.sim import SimulationContext -from isaaclab.utils.math import convert_camera_frame_orientation_convention +from isaaclab.utils.warp.warp_math import convert_camera_frame_orientation_convention_wp from ..physics.newton_manager import NewtonManager from .newton_warp_renderer_cfg import NewtonWarpRendererCfg if TYPE_CHECKING: from isaaclab.sensors.camera.camera_data import CameraData + from isaaclab.utils.warp import ProxyArray logger = logging.getLogger(__name__) @@ -33,6 +34,17 @@ class RenderData: # Back-compat alias for callers of ``RenderData.OutputNames``. OutputNames = RenderBufferKind + # Maps each supported RenderBufferKind to (CameraOutputs field name, Newton warp dtype). + # Newton reinterprets the allocated buffer memory: e.g. RGBA is allocated as (N,H,W,4) uint8 + # but the Newton sensor API consumes it as (world_count,1,H,W) uint32 (same bytes, packed view). + _OUTPUT_MAP: dict[str, tuple[str, type]] = { + str(RenderBufferKind.RGBA): ("color_image", wp.uint32), + str(RenderBufferKind.ALBEDO): ("albedo_image", wp.uint32), + str(RenderBufferKind.DEPTH): ("depth_image", wp.float32), + str(RenderBufferKind.NORMALS): ("normals_image", wp.vec3f), + str(RenderBufferKind.INSTANCE_SEGMENTATION_FAST): ("instance_segmentation_image", wp.uint32), + } + @dataclass class CameraOutputs: color_image: wp.array(dtype=wp.uint32, ndim=4) = None @@ -52,22 +64,21 @@ def __init__(self, newton_sensor: newton.sensors.SensorTiledCamera, spec: Camera self.width = getattr(spec.cfg, "width", 100) self.height = getattr(spec.cfg, "height", 100) - def set_outputs(self, output_data: dict[str, torch.Tensor]): - for output_name, tensor_data in output_data.items(): - if output_name == RenderBufferKind.RGBA: - self.outputs.color_image = self._from_torch(tensor_data, dtype=wp.uint32) - elif output_name == RenderBufferKind.ALBEDO: - self.outputs.albedo_image = self._from_torch(tensor_data, dtype=wp.uint32) - elif output_name == RenderBufferKind.DEPTH: - self.outputs.depth_image = self._from_torch(tensor_data, dtype=wp.float32) - elif output_name == RenderBufferKind.NORMALS: - self.outputs.normals_image = self._from_torch(tensor_data, dtype=wp.vec3f) - elif output_name == RenderBufferKind.INSTANCE_SEGMENTATION_FAST: - self.outputs.instance_segmentation_image = self._from_torch(tensor_data, dtype=wp.uint32) - elif output_name == RenderBufferKind.RGB: - pass - else: - logger.warning(f"NewtonWarpRenderer - output type {output_name} is not yet supported") + def set_outputs(self, output_data: dict[str, ProxyArray]): + shape = (self.newton_sensor.model.world_count, self.num_cameras, self.height, self.width) + for output_name, proxy in output_data.items(): + mapping = self._OUTPUT_MAP.get(output_name) + if mapping is None: + if output_name != str(RenderBufferKind.RGB): + logger.warning(f"NewtonWarpRenderer - output type {output_name} is not yet supported") + continue + field_name, dtype = mapping + wp_arr = proxy.warp + setattr( + self.outputs, + field_name, + wp.array(ptr=wp_arr.ptr, dtype=dtype, shape=shape, device=wp_arr.device, copy=False), + ) def get_output(self, output_name: str) -> wp.array: if output_name == RenderBufferKind.RGBA: @@ -82,9 +93,14 @@ def get_output(self, output_name: str) -> wp.array: return self.outputs.instance_segmentation_image return None - def update(self, positions: torch.Tensor, orientations: torch.Tensor, intrinsics: torch.Tensor): - converted_orientations = convert_camera_frame_orientation_convention( - orientations, origin="world", target="opengl" + def update(self, positions: ProxyArray, orientations: ProxyArray, intrinsics: ProxyArray): + converted_wp = wp.empty_like(orientations) + convert_camera_frame_orientation_convention_wp( + src=orientations, + dst=converted_wp, + origin="world", + target="opengl", + device=self.newton_sensor.model.device, ) self.camera_transforms = wp.empty( @@ -93,36 +109,18 @@ def update(self, positions: torch.Tensor, orientations: torch.Tensor, intrinsics wp.launch( RenderData._update_transforms, self.newton_sensor.model.world_count, - [positions, converted_orientations, self.camera_transforms], + [positions, converted_wp, self.camera_transforms], device=self.newton_sensor.model.device, ) if self.camera_rays is None: - first_focal_length = intrinsics[:, 1, 1][0:1] + first_focal_length = intrinsics.torch[:, 1, 1][0:1] fov_radians_all = 2.0 * torch.atan(self.height / (2.0 * first_focal_length)) self.camera_rays = self.newton_sensor.utils.compute_pinhole_camera_rays( self.width, self.height, wp.from_torch(fov_radians_all, dtype=wp.float32) ) - def _from_torch(self, tensor: torch.Tensor, dtype) -> wp.array: - proxy_array = wp.from_torch(tensor) - if tensor.is_contiguous(): - return wp.array( - ptr=proxy_array.ptr, - dtype=dtype, - shape=(self.newton_sensor.model.world_count, self.num_cameras, self.height, self.width), - device=proxy_array.device, - copy=False, - ) - - logger.warning("NewtonWarpRenderer - torch output array is non-contiguous") - return wp.zeros( - (self.newton_sensor.model.world_count, self.num_cameras, self.height, self.width), - dtype=dtype, - device=proxy_array.device, - ) - @wp.kernel def _update_transforms( positions: wp.array(dtype=wp.vec3f), @@ -192,16 +190,14 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: """Publish the per-output layout this Newton Warp backend writes. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.supported_output_types`.""" seg_spec = ( - RenderBufferSpec(4, torch.uint8) - if self.cfg.colorize_instance_segmentation - else RenderBufferSpec(1, torch.int32) + RenderBufferSpec(4, wp.uint8) if self.cfg.colorize_instance_segmentation else RenderBufferSpec(1, wp.int32) ) return { - RenderBufferKind.RGBA: RenderBufferSpec(4, torch.uint8), - RenderBufferKind.RGB: RenderBufferSpec(3, torch.uint8), - RenderBufferKind.ALBEDO: RenderBufferSpec(4, torch.uint8), - RenderBufferKind.DEPTH: RenderBufferSpec(1, torch.float32), - RenderBufferKind.NORMALS: RenderBufferSpec(3, torch.float32), + RenderBufferKind.RGBA: RenderBufferSpec(4, wp.uint8), + RenderBufferKind.RGB: RenderBufferSpec(3, wp.uint8), + RenderBufferKind.ALBEDO: RenderBufferSpec(4, wp.uint8), + RenderBufferKind.DEPTH: RenderBufferSpec(1, wp.float32), + RenderBufferKind.NORMALS: RenderBufferSpec(3, wp.float32), RenderBufferKind.INSTANCE_SEGMENTATION_FAST: seg_spec, } @@ -215,7 +211,7 @@ def create_render_data(self, spec: CameraRenderSpec) -> RenderData: See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.create_render_data`.""" return RenderData(self.newton_sensor, spec) - def set_outputs(self, render_data: RenderData, output_data: dict[str, torch.Tensor]): + def set_outputs(self, render_data: RenderData, output_data: dict[str, ProxyArray]): """Store output buffers. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.set_outputs`.""" render_data.set_outputs(output_data) @@ -227,7 +223,11 @@ def update_transforms(self): NewtonManager.update_visualization_state() def update_camera( - self, render_data: RenderData, positions: torch.Tensor, orientations: torch.Tensor, intrinsics: torch.Tensor + self, + render_data: RenderData, + positions: ProxyArray, + orientations: ProxyArray, + intrinsics: ProxyArray, ): """Update camera poses and intrinsics. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.update_camera`.""" @@ -264,9 +264,9 @@ def read_output(self, render_data: RenderData, camera_data: CameraData) -> None: continue image_data = render_data.get_output(output_name) if image_data is not None: - output_data = camera_data.output[output_name] - if image_data.ptr != output_data.data_ptr(): - wp.copy(wp.from_torch(output_data), image_data) + output_wp = camera_data.output[output_name].warp + if image_data.ptr != output_wp.ptr: + wp.copy(output_wp, image_data) def cleanup(self, render_data: RenderData | None): """Release resources. No-op for Newton Warp. diff --git a/source/isaaclab_ov/changelog.d/mh-warp_cam.minor.rst b/source/isaaclab_ov/changelog.d/mh-warp_cam.minor.rst new file mode 100644 index 000000000000..7cca220862f1 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/mh-warp_cam.minor.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* Updated :class:`~isaaclab_ov.renderers.OVRTXRenderer` to accept + :class:`~isaaclab.utils.warp.ProxyArray` in :meth:`set_outputs` and :meth:`update_camera`, + matching the updated :class:`~isaaclab.renderers.BaseRenderer` interface. Output buffers are + accessed via their underlying warp array directly. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index f00a5b61af2f..4535fd62e478 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -45,7 +45,7 @@ from isaaclab.cloner.cloner_utils import is_homogeneous from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec from isaaclab.sim import SimulationContext -from isaaclab.utils.math import convert_camera_frame_orientation_convention +from isaaclab.utils.warp.warp_math import convert_camera_frame_orientation_convention_wp from .ovrtx_renderer_cfg import OVRTXRendererCfg from .ovrtx_renderer_kernels import ( @@ -65,6 +65,7 @@ if TYPE_CHECKING: from isaaclab.sensors.camera.camera_data import CameraData + from isaaclab.utils.warp import ProxyArray from isaaclab.renderers.camera_render_spec import CameraRenderSpec @@ -136,16 +137,16 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: """Publish the per-output layout this OVRTX backend writes. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.supported_output_types`.""" return { - RenderBufferKind.RGBA: RenderBufferSpec(4, torch.uint8), - RenderBufferKind.RGB: RenderBufferSpec(3, torch.uint8), - RenderBufferKind.ALBEDO: RenderBufferSpec(4, torch.uint8), - RenderBufferKind.SIMPLE_SHADING_CONSTANT_DIFFUSE: RenderBufferSpec(3, torch.uint8), - RenderBufferKind.SIMPLE_SHADING_DIFFUSE_MDL: RenderBufferSpec(3, torch.uint8), - RenderBufferKind.SIMPLE_SHADING_FULL_MDL: RenderBufferSpec(3, torch.uint8), - RenderBufferKind.SEMANTIC_SEGMENTATION: RenderBufferSpec(4, torch.uint8), - RenderBufferKind.DEPTH: RenderBufferSpec(1, torch.float32), - RenderBufferKind.DISTANCE_TO_IMAGE_PLANE: RenderBufferSpec(1, torch.float32), - RenderBufferKind.DISTANCE_TO_CAMERA: RenderBufferSpec(1, torch.float32), + RenderBufferKind.RGBA: RenderBufferSpec(4, wp.uint8), + RenderBufferKind.RGB: RenderBufferSpec(3, wp.uint8), + RenderBufferKind.ALBEDO: RenderBufferSpec(4, wp.uint8), + RenderBufferKind.SIMPLE_SHADING_CONSTANT_DIFFUSE: RenderBufferSpec(3, wp.uint8), + RenderBufferKind.SIMPLE_SHADING_DIFFUSE_MDL: RenderBufferSpec(3, wp.uint8), + RenderBufferKind.SIMPLE_SHADING_FULL_MDL: RenderBufferSpec(3, wp.uint8), + RenderBufferKind.SEMANTIC_SEGMENTATION: RenderBufferSpec(4, wp.uint8), + RenderBufferKind.DEPTH: RenderBufferSpec(1, wp.float32), + RenderBufferKind.DISTANCE_TO_IMAGE_PLANE: RenderBufferSpec(1, wp.float32), + RenderBufferKind.DISTANCE_TO_CAMERA: RenderBufferSpec(1, wp.float32), } @property @@ -387,44 +388,19 @@ def create_render_data(self, spec: CameraRenderSpec) -> OVRTXRenderData: self._initialize_from_spec(spec) return OVRTXRenderData(spec, self._device) - # Map torch dtypes to their warp counterparts for zero-copy wrapping. - _TORCH_TO_WP_DTYPE: dict[torch.dtype, Any] = { - torch.uint8: wp.uint8, - torch.float32: wp.float32, - torch.int32: wp.int32, - } + def set_outputs(self, render_data: OVRTXRenderData, output_data: dict[str, ProxyArray]) -> None: + """Register pre-allocated warp output buffers for rendering. - def set_outputs(self, render_data: OVRTXRenderData, output_data: dict[str, torch.Tensor]) -> None: - """Wrap caller-owned torch output tensors as zero-copy warp arrays. - - Aliased views over a contiguous sibling (e.g. ``rgb`` over ``rgba``) are - skipped; any other non-contiguous tensor raises ``ValueError``. + Each :class:`~isaaclab.utils.warp.ProxyArray` already carries the correct warp + dtype from :meth:`~isaaclab.sensors.camera.CameraData.allocate`; store + the underlying warp array directly. ``rgb`` is excluded because it is a + non-contiguous strided view into ``rgba`` and is updated automatically. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.set_outputs`. """ - render_data.warp_buffers = {} - for name, tensor in output_data.items(): - if not tensor.is_contiguous(): - if tensor.data_ptr() in {t.data_ptr() for t in output_data.values() if t.is_contiguous()}: - continue - raise ValueError( - f"OVRTXRenderer.set_outputs: output '{name}' is non-contiguous and is not an" - " alias of a contiguous output tensor; cannot wrap as a zero-copy warp array." - ) - wp_dtype = self._TORCH_TO_WP_DTYPE.get(tensor.dtype) - if wp_dtype is None: - raise ValueError( - f"OVRTXRenderer.set_outputs: unsupported torch dtype {tensor.dtype} for output" - f" '{name}'. Add it to OVRTXRenderer._TORCH_TO_WP_DTYPE." - ) - torch_array = wp.from_torch(tensor) - render_data.warp_buffers[name] = wp.array( - ptr=torch_array.ptr, - dtype=wp_dtype, - shape=tuple(tensor.shape), - device=torch_array.device, - copy=False, - ) + render_data.warp_buffers = { + name: proxy.warp for name, proxy in output_data.items() if name != str(RenderBufferKind.RGB) + } def update_transforms(self) -> None: """Sync physics objects to OVRTX.""" @@ -455,20 +431,25 @@ def update_transforms(self) -> None: def update_camera( self, render_data: OVRTXRenderData, - positions: torch.Tensor, - orientations: torch.Tensor, - intrinsics: torch.Tensor, + positions: ProxyArray, + orientations: ProxyArray, + intrinsics: ProxyArray, ) -> None: """Update camera transforms in OVRTX binding.""" num_envs = positions.shape[0] - camera_quats_opengl = convert_camera_frame_orientation_convention(orientations, origin="world", target="opengl") - camera_positions_wp = wp.from_torch(positions.contiguous(), dtype=wp.vec3) - camera_orientations_wp = wp.from_torch(camera_quats_opengl.contiguous(), dtype=wp.quatf) + converted_wp = wp.empty(num_envs, dtype=wp.quatf, device=self._device) + convert_camera_frame_orientation_convention_wp( + src=orientations.warp, + dst=converted_wp, + origin="world", + target="opengl", + device=self._device, + ) camera_transforms = wp.zeros(num_envs, dtype=wp.mat44d, device=self._device) wp.launch( kernel=create_camera_transforms_kernel, dim=num_envs, - inputs=[camera_positions_wp, camera_orientations_wp, camera_transforms], + inputs=[positions, converted_wp, camera_transforms], device=self._device, ) if self._camera_binding is not None: diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index b6c590a54a54..505b08d256d3 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -7,6 +7,7 @@ import pytest import torch +import warp as wp pytest.importorskip("isaaclab_ov") pytest.importorskip("ovrtx") @@ -58,14 +59,12 @@ def test_ovrtx_supported_output_types_key_set(): RenderBufferKind.DISTANCE_TO_IMAGE_PLANE, RenderBufferKind.DISTANCE_TO_CAMERA, } - assert specs[RenderBufferKind.RGBA] == RenderBufferSpec(4, torch.uint8) - assert specs[RenderBufferKind.DEPTH] == RenderBufferSpec(1, torch.float32) + assert specs[RenderBufferKind.RGBA] == RenderBufferSpec(4, wp.uint8) + assert specs[RenderBufferKind.DEPTH] == RenderBufferSpec(1, wp.float32) def test_ovrtx_set_outputs_wraps_caller_torch_zero_copy(): - """OVRTXRenderer.set_outputs publishes warp views over the caller's torch storage.""" - import warp as wp - + """OVRTXRenderer.set_outputs publishes warp views over the caller's warp storage.""" renderer = OVRTXRenderer(OVRTXRendererCfg()) if not torch.cuda.is_available(): @@ -85,8 +84,8 @@ def test_ovrtx_set_outputs_wraps_caller_torch_zero_copy(): renderer.set_outputs(render_data, data.output) assert set(render_data.warp_buffers.keys()) >= {"rgba", "depth"} - assert render_data.warp_buffers["rgba"].ptr == wp.from_torch(data.output["rgba"]).ptr - assert render_data.warp_buffers["depth"].ptr == wp.from_torch(data.output["depth"]).ptr + assert render_data.warp_buffers["rgba"].ptr == data.output["rgba"].warp.ptr + assert render_data.warp_buffers["depth"].ptr == data.output["depth"].warp.ptr assert "rgb" not in render_data.warp_buffers diff --git a/source/isaaclab_physx/changelog.d/mh-warp_cam.minor.rst b/source/isaaclab_physx/changelog.d/mh-warp_cam.minor.rst new file mode 100644 index 000000000000..dce71a6e2326 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/mh-warp_cam.minor.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* Updated :class:`~isaaclab_physx.renderers.IsaacRtxRenderer` to accept + :class:`~isaaclab.utils.warp.ProxyArray` in :meth:`set_outputs` and :meth:`update_camera`, + matching the updated :class:`~isaaclab.renderers.BaseRenderer` interface. Output buffers are + accessed via ``.warp`` directly, avoiding intermediate :func:`warp.from_torch` conversions. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index 242ac3729d0b..4e04188cd4e2 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -14,7 +14,6 @@ from typing import TYPE_CHECKING, Any import numpy as np -import torch import warp as wp from packaging import version @@ -25,6 +24,7 @@ from isaaclab.renderers.camera_render_spec import CameraRenderSpec from isaaclab.utils.version import get_isaac_sim_version from isaaclab.utils.warp.kernels import reshape_tiled_image +from isaaclab.utils.warp.warp_math import clamp_depth_to_inf_wp, replace_inf_depth_wp from .isaac_rtx_renderer_utils import ensure_isaac_rtx_render_update, ensure_rtx_hydra_engine_attached @@ -32,6 +32,7 @@ if TYPE_CHECKING: from isaaclab.sensors.camera.camera_data import CameraData + from isaaclab.utils.warp import ProxyArray from .isaac_rtx_renderer_cfg import IsaacRtxRendererCfg @@ -72,7 +73,7 @@ class IsaacRtxRenderData: annotators: dict[str, Any] render_product_paths: list[str] - output_data: dict[str, torch.Tensor] | None = None + output_data: dict[str, ProxyArray] | None = None spec: CameraRenderSpec | None = None renderer_info: dict[str, Any] = field(default_factory=dict) @@ -107,19 +108,19 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: specs: dict[RenderBufferKind, RenderBufferSpec] = { # Replicator's native layout for color output is rgba/uint8; # ``Camera`` aliases ``rgb`` as a view into ``rgba`` storage. - RenderBufferKind.RGBA: RenderBufferSpec(4, torch.uint8), - RenderBufferKind.RGB: RenderBufferSpec(3, torch.uint8), - RenderBufferKind.DEPTH: RenderBufferSpec(1, torch.float32), - RenderBufferKind.DISTANCE_TO_IMAGE_PLANE: RenderBufferSpec(1, torch.float32), - RenderBufferKind.DISTANCE_TO_CAMERA: RenderBufferSpec(1, torch.float32), - RenderBufferKind.NORMALS: RenderBufferSpec(3, torch.float32), - RenderBufferKind.MOTION_VECTORS: RenderBufferSpec(2, torch.float32), + RenderBufferKind.RGBA: RenderBufferSpec(4, wp.uint8), + RenderBufferKind.RGB: RenderBufferSpec(3, wp.uint8), + RenderBufferKind.DEPTH: RenderBufferSpec(1, wp.float32), + RenderBufferKind.DISTANCE_TO_IMAGE_PLANE: RenderBufferSpec(1, wp.float32), + RenderBufferKind.DISTANCE_TO_CAMERA: RenderBufferSpec(1, wp.float32), + RenderBufferKind.NORMALS: RenderBufferSpec(3, wp.float32), + RenderBufferKind.MOTION_VECTORS: RenderBufferSpec(2, wp.float32), } if sim_major >= 6: - specs[RenderBufferKind.ALBEDO] = RenderBufferSpec(4, torch.uint8) + specs[RenderBufferKind.ALBEDO] = RenderBufferSpec(4, wp.uint8) for shading_type in SIMPLE_SHADING_MODES: - specs[RenderBufferKind(shading_type)] = RenderBufferSpec(3, torch.uint8) + specs[RenderBufferKind(shading_type)] = RenderBufferSpec(3, wp.uint8) seg_specs = ( (RenderBufferKind.SEMANTIC_SEGMENTATION, self.cfg.colorize_semantic_segmentation), @@ -127,7 +128,7 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: (RenderBufferKind.INSTANCE_ID_SEGMENTATION_FAST, self.cfg.colorize_instance_id_segmentation), ) for name, colorize in seg_specs: - specs[name] = RenderBufferSpec(4, torch.uint8) if colorize else RenderBufferSpec(1, torch.int32) + specs[name] = RenderBufferSpec(4, wp.uint8) if colorize else RenderBufferSpec(1, wp.int32) return specs @@ -277,7 +278,7 @@ def _resolve_simple_shading_mode(self, spec: CameraRenderSpec) -> int | None: ) return SIMPLE_SHADING_MODES[requested[0]] - def set_outputs(self, render_data: IsaacRtxRenderData, output_data: dict[str, torch.Tensor]): + def set_outputs(self, render_data: IsaacRtxRenderData, output_data: dict[str, ProxyArray]): """Store reference to output buffers for writing during render. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.set_outputs`.""" render_data.output_data = output_data @@ -290,9 +291,9 @@ def update_transforms(self) -> None: def update_camera( self, render_data: IsaacRtxRenderData, - positions: torch.Tensor, - orientations: torch.Tensor, - intrinsics: torch.Tensor, + positions: ProxyArray, + orientations: ProxyArray, + intrinsics: ProxyArray, ): """No-op for Replicator - uses USD camera prims directly. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.update_camera`.""" @@ -364,37 +365,37 @@ def tiling_grid_shape(): if data_type in SIMPLE_SHADING_MODES: tiled_data_buffer = tiled_data_buffer[:, :, :3].contiguous() + # Get the warp array since the kernel is overloaded for specific types + buf_wp = output_data[data_type].warp wp.launch( kernel=reshape_tiled_image, dim=(view_count, cfg.height, cfg.width), inputs=[ tiled_data_buffer.flatten(), - wp.from_torch(output_data[data_type]), - *list(output_data[data_type].shape[1:]), + buf_wp, + *list(buf_wp.shape[1:]), num_tiles_x, ], device=device, ) - # alias rgb as first 3 channels of rgba - if data_type == "rgba" and "rgb" in cfg.data_types: - output_data["rgb"] = output_data["rgba"][..., :3] + # rgb is a strided warp view into rgba set up in CameraData.allocate(); + # no per-frame alias assignment needed. # NOTE: The `distance_to_camera` annotator returns the distance to the camera optical center. # However, the replicator depth clipping is applied w.r.t. to the image plane which may result # in values larger than the clipping range in the output. We apply an additional clipping to # ensure values are within the clipping range for all the annotators. if data_type == "distance_to_camera": - output_data[data_type][output_data[data_type] > cfg.spawn.clipping_range[1]] = torch.inf + clamp_depth_to_inf_wp(buf_wp, cfg.spawn.clipping_range[1], device=device) # apply defined clipping behavior if ( data_type in ("distance_to_camera", "distance_to_image_plane", "depth") and self.cfg.depth_clipping_behavior != "none" ): - output_data[data_type][torch.isinf(output_data[data_type])] = ( - 0.0 if self.cfg.depth_clipping_behavior == "zero" else cfg.spawn.clipping_range[1] - ) + replacement = 0.0 if self.cfg.depth_clipping_behavior == "zero" else cfg.spawn.clipping_range[1] + replace_inf_depth_wp(buf_wp, replacement, device=device) def read_output(self, render_data: IsaacRtxRenderData, camera_data: CameraData) -> None: """Populate per-output metadata collected during render(). Pixel data already written in render(). diff --git a/source/isaaclab_tasks/changelog.d/mh-warp_cam.skip b/source/isaaclab_tasks/changelog.d/mh-warp_cam.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_blueprint_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_blueprint_env_cfg.py index e123a25604bb..c96701e23ee6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_blueprint_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_blueprint_env_cfg.py @@ -87,6 +87,9 @@ def image( dir_path, _ = os.path.split(image_path) if dir_path: os.makedirs(dir_path, exist_ok=True) + # output may be a ProxyArray (Warp-backed); extract torch.Tensor before dtype check + if not isinstance(images, torch.Tensor): + images = images.torch if images.dtype == torch.uint8: images = images.float() / 255.0 # Get total successful episodes diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index 1c80f668e749..d4fbf368ea7c 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -14,6 +14,8 @@ import torch from PIL import Image, ImageChops +from isaaclab.utils.warp import ProxyArray + # Directory containing golden images. _GOLDEN_IMAGES_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "golden_images") @@ -592,7 +594,7 @@ def validate_camera_outputs( test_name: str, physics_backend: str, renderer: str, - camera_outputs: dict[str, torch.Tensor], + camera_outputs: dict[str, ProxyArray], max_different_pixels_percentage: float, comparison_scores: list[dict], ) -> None: @@ -605,7 +607,8 @@ def validate_camera_outputs( ssim_threshold = _SSIM_THRESHOLD_BY_ENV_NAME.get(test_name, _SSIM_THRESHOLD) failed_data_types = {} - for data_type, tensor in camera_outputs.items(): + for data_type, output in camera_outputs.items(): + tensor = output if isinstance(output, torch.Tensor) else output.torch condition = torch.logical_or(torch.isinf(tensor), torch.isnan(tensor)) corrected = torch.where(condition, torch.zeros_like(tensor), tensor) max_val = corrected.max() diff --git a/source/isaaclab_tasks/test/test_rendering_registered_tasks.py b/source/isaaclab_tasks/test/test_rendering_registered_tasks.py index f1b208f4d727..94eb6e07232a 100644 --- a/source/isaaclab_tasks/test/test_rendering_registered_tasks.py +++ b/source/isaaclab_tasks/test/test_rendering_registered_tasks.py @@ -50,7 +50,11 @@ def _collect_camera_outputs(env: object) -> dict[str, dict[str, torch.Tensor]]: if not isinstance(output, dict): continue - tensor_output = {k: v for k, v in output.items() if isinstance(v, torch.Tensor) and v.numel() > 0} + tensor_output = {} + for data_type, value in output.items(): + tensor = value if isinstance(value, torch.Tensor) else value.torch + if tensor.numel() > 0: + tensor_output[data_type] = tensor if tensor_output: outputs[name] = tensor_output diff --git a/source/isaaclab_tasks/test/test_shadow_hand_vision_presets.py b/source/isaaclab_tasks/test/test_shadow_hand_vision_presets.py index 5faf370f1cdb..ccec60c3fa46 100644 --- a/source/isaaclab_tasks/test/test_shadow_hand_vision_presets.py +++ b/source/isaaclab_tasks/test/test_shadow_hand_vision_presets.py @@ -415,7 +415,8 @@ def test_camera_renders_not_empty(render_correctness_env): label = f"{physics}-{renderer_preset}+{camera_preset}" camera_output = env._tiled_camera.data.output assert len(camera_output) > 0, f"[{label}] Camera produced no output tensors at all." - for dt, tensor in camera_output.items(): + for dt, output in camera_output.items(): + tensor = output.torch finite = torch.where(torch.isinf(tensor), torch.zeros_like(tensor), tensor) # import pdb; pdb.set_trace() assert finite.max() > 0.2, ( diff --git a/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py index 42d1368dcebf..79bad6ea1459 100644 --- a/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py +++ b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py @@ -341,9 +341,9 @@ def _step_until_non_black_camera(env, actions: torch.Tensor, *, max_steps: int = 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 + last_rgb = rgb.torch try: - _assert_non_black_tensor(rgb) + _assert_non_black_tensor(rgb.torch) return except AssertionError: continue From 58f633c7b0dfce04ecf16e78657c9356d9f2825a Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 06:02:17 +0000 Subject: [PATCH 084/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 5.2.1 → 5.3.0 - isaaclab_newton: 0.9.1 → 0.10.0 - isaaclab_ov: 0.1.9 → 0.2.0 - isaaclab_physx: 0.7.1 → 0.8.0 - isaaclab_tasks: 1.6.0 → 1.7.0 - isaaclab_teleop: 0.3.11 → 0.4.0 --- .../isaaclab/changelog.d/docker-non-root.rst | 5 - .../changelog.d/huidongc-ovrtx-cloning.rst | 5 - ...jichuanh-newton-v120-stable-bump.minor.rst | 14 --- .../jichuanh-pink-ik-left-hand-nan.rst | 16 --- .../jmart-frame-stacking.minor.rst | 8 -- ...lakhi-fix-windows-inline-comment-docs.skip | 0 .../changelog.d/mh-uv_train.minor.rst | 13 -- .../changelog.d/mh-warp_cam.minor.rst | 17 --- .../changelog.d/pbarejko-debugging.rst | 8 -- .../stevfeng-fix-converter-usd-path.minor.rst | 39 ------ .../isaaclab/changelog.d/xul-determinism.rst | 5 - source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 115 ++++++++++++++++++ .../jichuanh-newton-replicate-tendon-fix.rst | 14 --- ...jichuanh-newton-v120-stable-bump.minor.rst | 6 - .../changelog.d/jmart-cubric-abi.rst | 7 -- .../changelog.d/mh-warp_cam.minor.rst | 8 -- .../changelog.d/pbarejko-debugging.skip | 0 source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 39 ++++++ .../changelog.d/fix-ovrtx-device-mismatch.rst | 7 -- .../changelog.d/huidongc-ovrtx-cloning.rst | 16 --- .../changelog.d/mh-warp_cam.minor.rst | 7 -- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 29 +++++ .../jichuanh-newton-v120-stable-bump.rst | 5 - .../changelog.d/mh-warp_cam.minor.rst | 7 -- .../changelog.d/pbarejko-debugging.skip | 0 source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 14 +++ .../antoiner-fix-rsl-rl-export-ci.skip | 0 .../jichuanh-preset-cli-basic.minor.rst | 19 --- .../jmart-frame-stacking.minor.rst | 8 -- .../changelog.d/mh-warp_cam.skip | 0 .../proth-fix-skrl-drone-arl-states-input.rst | 10 -- .../changelog.d/xul-determinism.skip | 0 source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 40 ++++++ .../rwiltz-docs-rtx-minimal-renderer.rst | 12 -- .../rwiltz-xcr-replay-agent.minor.rst | 60 --------- source/isaaclab_teleop/config/extension.toml | 2 +- source/isaaclab_teleop/docs/CHANGELOG.rst | 74 +++++++++++ 42 files changed, 317 insertions(+), 322 deletions(-) delete mode 100644 source/isaaclab/changelog.d/docker-non-root.rst delete mode 100644 source/isaaclab/changelog.d/huidongc-ovrtx-cloning.rst delete mode 100644 source/isaaclab/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst delete mode 100644 source/isaaclab/changelog.d/jichuanh-pink-ik-left-hand-nan.rst delete mode 100644 source/isaaclab/changelog.d/jmart-frame-stacking.minor.rst delete mode 100644 source/isaaclab/changelog.d/klakhi-fix-windows-inline-comment-docs.skip delete mode 100644 source/isaaclab/changelog.d/mh-uv_train.minor.rst delete mode 100644 source/isaaclab/changelog.d/mh-warp_cam.minor.rst delete mode 100644 source/isaaclab/changelog.d/pbarejko-debugging.rst delete mode 100644 source/isaaclab/changelog.d/stevfeng-fix-converter-usd-path.minor.rst delete mode 100644 source/isaaclab/changelog.d/xul-determinism.rst delete mode 100644 source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst delete mode 100644 source/isaaclab_newton/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/jmart-cubric-abi.rst delete mode 100644 source/isaaclab_newton/changelog.d/mh-warp_cam.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/pbarejko-debugging.skip delete mode 100644 source/isaaclab_ov/changelog.d/fix-ovrtx-device-mismatch.rst delete mode 100644 source/isaaclab_ov/changelog.d/huidongc-ovrtx-cloning.rst delete mode 100644 source/isaaclab_ov/changelog.d/mh-warp_cam.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/jichuanh-newton-v120-stable-bump.rst delete mode 100644 source/isaaclab_physx/changelog.d/mh-warp_cam.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/pbarejko-debugging.skip delete mode 100644 source/isaaclab_rl/changelog.d/antoiner-fix-rsl-rl-export-ci.skip delete mode 100644 source/isaaclab_tasks/changelog.d/jichuanh-preset-cli-basic.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/jmart-frame-stacking.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/mh-warp_cam.skip delete mode 100644 source/isaaclab_tasks/changelog.d/proth-fix-skrl-drone-arl-states-input.rst delete mode 100644 source/isaaclab_tasks/changelog.d/xul-determinism.skip delete mode 100644 source/isaaclab_teleop/changelog.d/rwiltz-docs-rtx-minimal-renderer.rst delete mode 100644 source/isaaclab_teleop/changelog.d/rwiltz-xcr-replay-agent.minor.rst diff --git a/source/isaaclab/changelog.d/docker-non-root.rst b/source/isaaclab/changelog.d/docker-non-root.rst deleted file mode 100644 index d2c61f8fbd85..000000000000 --- a/source/isaaclab/changelog.d/docker-non-root.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Changed Isaac Lab Docker images to run as the non-root ``isaaclab`` user by default. Use an explicit - container user override when root access is required. diff --git a/source/isaaclab/changelog.d/huidongc-ovrtx-cloning.rst b/source/isaaclab/changelog.d/huidongc-ovrtx-cloning.rst deleted file mode 100644 index 807ef951cc29..000000000000 --- a/source/isaaclab/changelog.d/huidongc-ovrtx-cloning.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added :func:`~isaaclab.cloner.cloner_utils.is_homogeneous` to detect whether a :class:`~isaaclab.cloner.ClonePlan` - assigns every environment from every source (a homogeneous clone mask). diff --git a/source/isaaclab/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst b/source/isaaclab/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst deleted file mode 100644 index 68898a3d0522..000000000000 --- a/source/isaaclab/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst +++ /dev/null @@ -1,14 +0,0 @@ -Changed -^^^^^^^ - -* Bumped the ``newton[sim]`` pin from ``v1.2.0rc2`` to ``v1.2.0`` - (stable) across :mod:`isaaclab_newton`, :mod:`isaaclab_physx` - (``[newton]`` extra), :mod:`isaaclab_visualizers` (3×), and - ``tools/wheel_builder/res/python_packages.toml``. Upstream release - notes: `newton-physics/newton v1.2.0 - `_. -* No IsaacLab-side ``mujoco`` / ``mujoco-warp`` pin change — the - transitive ``mjwarp`` bump flows in through ``newton[sim]`` since - `isaac-sim/IsaacLab#5566 - `_ dropped the - explicit pins. diff --git a/source/isaaclab/changelog.d/jichuanh-pink-ik-left-hand-nan.rst b/source/isaaclab/changelog.d/jichuanh-pink-ik-left-hand-nan.rst deleted file mode 100644 index aa3f2bb62e70..000000000000 --- a/source/isaaclab/changelog.d/jichuanh-pink-ik-left-hand-nan.rst +++ /dev/null @@ -1,16 +0,0 @@ -Fixed -^^^^^ - -* Fixed ``calculate_rotation_error`` in - ``source/isaaclab/test/controllers/test_pink_ik.py`` composing rotation matrices - with element-wise ``*`` instead of matrix multiplication ``@`` — a latent bug - from `isaac-sim/IsaacLab#3149 - `_ that surfaced as NaN after - `isaac-sim/IsaacLab#5609 - `_ added the unit-norm guard to - ``quat_from_matrix``. -* Made ``test_pink_ik`` deterministic by seeding the env (``env_cfg.seed = 42``) - in ``create_test_env``. -* Loosened the G1 Pink IK rotation tolerance from ``0.030`` rad to ``0.100`` rad - in ``pink_ik_g1_test_configs.json`` to accommodate G1's intentionally smooth IK - tuning (slower-converging than GR1T2). GR1T2 tolerance unchanged at ``0.020`` rad. diff --git a/source/isaaclab/changelog.d/jmart-frame-stacking.minor.rst b/source/isaaclab/changelog.d/jmart-frame-stacking.minor.rst deleted file mode 100644 index 33b022bc7a96..000000000000 --- a/source/isaaclab/changelog.d/jmart-frame-stacking.minor.rst +++ /dev/null @@ -1,8 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab.envs.mdp.observations.stacked_image`, a stateful - :class:`~isaaclab.managers.ManagerTermBase` that channel-stacks the last ``N`` frames - from a camera sensor. Manager-based environments can reference it in observation cfg - to add explicit temporal information for camera-based RL tasks whose renderer doesn't - supply implicit temporal data (e.g., Newton Warp). diff --git a/source/isaaclab/changelog.d/klakhi-fix-windows-inline-comment-docs.skip b/source/isaaclab/changelog.d/klakhi-fix-windows-inline-comment-docs.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab/changelog.d/mh-uv_train.minor.rst b/source/isaaclab/changelog.d/mh-uv_train.minor.rst deleted file mode 100644 index 15f4e48a780a..000000000000 --- a/source/isaaclab/changelog.d/mh-uv_train.minor.rst +++ /dev/null @@ -1,13 +0,0 @@ -Added -^^^^^ - -* Added unified ``train`` and ``play`` console-script entry points (``isaaclab.cli:train`` - and ``isaaclab.cli:play``) that dispatch to a library-specific implementation via - ``--rl_library``. Supported libraries are ``rsl_rl``, ``rl_games``, ``skrl``, ``sb3``, - and ``rlinf``. -* Added refactored per-library train/play scripts under - ``scripts/reinforcement_learning/`` with a shared ``common.dispatch_library_entrypoint`` - helper, replacing the previous standalone per-library scripts. -* Added experimental ``uv run`` workflow allowing ``uv run train`` and ``uv run play`` - directly from the repository root without manual environment setup. See - :ref:`uv-run-training` for usage. diff --git a/source/isaaclab/changelog.d/mh-warp_cam.minor.rst b/source/isaaclab/changelog.d/mh-warp_cam.minor.rst deleted file mode 100644 index 75a4c0450f31..000000000000 --- a/source/isaaclab/changelog.d/mh-warp_cam.minor.rst +++ /dev/null @@ -1,17 +0,0 @@ -Changed -^^^^^^^ - -* Changed :class:`~isaaclab.sensors.camera.CameraData` to expose all sensor buffers as - :class:`~isaaclab.utils.warp.ProxyArray` instead of :class:`torch.Tensor`. The fields - :attr:`~isaaclab.sensors.camera.CameraData.pos_w` (``wp.vec3f``), - :attr:`~isaaclab.sensors.camera.CameraData.quat_w_world` (``wp.quatf``), - :attr:`~isaaclab.sensors.camera.CameraData.intrinsic_matrices` (``wp.mat33f``), and all - entries in :attr:`~isaaclab.sensors.camera.CameraData.output` are now backed by warp arrays. - Use ``.torch`` for a zero-copy :class:`torch.Tensor` view or ``.warp`` to pass the array - directly to a warp kernel. Existing code using these fields as tensors (indexing, arithmetic, - :func:`torch.testing.assert_close`, etc.) continues to work via the - :class:`~isaaclab.utils.warp.ProxyArray` deprecation bridge with a one-time - :class:`DeprecationWarning`. -* Updated :meth:`~isaaclab.renderers.BaseRenderer.set_outputs` and - :meth:`~isaaclab.renderers.BaseRenderer.update_camera` in :class:`~isaaclab.renderers.BaseRenderer` - to accept :class:`~isaaclab.utils.warp.ProxyArray` arguments instead of :class:`torch.Tensor`. diff --git a/source/isaaclab/changelog.d/pbarejko-debugging.rst b/source/isaaclab/changelog.d/pbarejko-debugging.rst deleted file mode 100644 index fe3d24743788..000000000000 --- a/source/isaaclab/changelog.d/pbarejko-debugging.rst +++ /dev/null @@ -1,8 +0,0 @@ -Fixed -^^^^^ - -* Fixed a startup crash in :class:`~isaaclab.app.AppLauncher` when launching with a CUDA device. - Setting the current torch CUDA device used to happen before ``SimulationApp`` was created, which - imported ``torch`` (and transitively NumPy/OpenBLAS) prior to Kit's platform-info fork. On systems - where OpenBLAS's at-fork handlers were not yet safe, that fork could crash. The - ``torch.cuda.set_device`` call is now deferred until after ``SimulationApp`` starts. diff --git a/source/isaaclab/changelog.d/stevfeng-fix-converter-usd-path.minor.rst b/source/isaaclab/changelog.d/stevfeng-fix-converter-usd-path.minor.rst deleted file mode 100644 index 148af70ffc84..000000000000 --- a/source/isaaclab/changelog.d/stevfeng-fix-converter-usd-path.minor.rst +++ /dev/null @@ -1,39 +0,0 @@ -Added -^^^^^ -* Added :attr:`~isaaclab.sim.converters.UrdfConverterCfg.ros_package_paths`, - :attr:`~isaaclab.sim.converters.UrdfConverterCfg.robot_type`, - :attr:`~isaaclab.sim.converters.UrdfConverterCfg.run_asset_transformer`, - :attr:`~isaaclab.sim.converters.UrdfConverterCfg.run_multi_physics_conversion`, and - :attr:`~isaaclab.sim.converters.UrdfConverterCfg.debug_mode` config fields that mirror the - new :class:`isaacsim.asset.importer.urdf.URDFImporterConfig` options. -* Extended :attr:`~isaaclab.sim.converters.UrdfConverterCfg.collision_type` to accept - ``"Bounding Sphere"`` and ``"Bounding Cube"`` in addition to the existing ``"Convex Hull"`` - and ``"Convex Decomposition"`` values. -* Added :attr:`~isaaclab.sim.converters.MjcfConverterCfg.fix_base`, - :attr:`~isaaclab.sim.converters.MjcfConverterCfg.link_density`, - :attr:`~isaaclab.sim.converters.MjcfConverterCfg.robot_type`, - :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_gain_type`, - :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_bias_type`, - :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_gain_prm`, - :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_bias_prm`, - :attr:`~isaaclab.sim.converters.MjcfConverterCfg.run_asset_transformer`, - :attr:`~isaaclab.sim.converters.MjcfConverterCfg.run_multi_physics_conversion`, and - :attr:`~isaaclab.sim.converters.MjcfConverterCfg.debug_mode` config fields that mirror the - new :class:`isaacsim.asset.importer.mjcf.MJCFImporterConfig` options. - -Changed -^^^^^^^ -* Refactored :class:`~isaaclab.sim.converters.UrdfConverter` to delegate the full conversion - pipeline to :class:`isaacsim.asset.importer.urdf.URDFImporter` / - :class:`isaacsim.asset.importer.urdf.URDFImporterConfig`. The duplicated IsaacLab - implementations of ``_apply_fix_base``, ``_apply_link_density``, ``_apply_joint_drives``, - ``_set_drive_type_on_joints``, ``_set_target_type_on_joints``, ``_set_drive_gains_on_joints``, - and ``_fix_articulation_root_for_fixed_base`` have been removed and replaced with a thin - translation layer that maps :class:`~isaaclab.sim.converters.UrdfConverterCfg` onto the - Isaac Sim importer config. All behaviour is preserved. -* Updated :class:`~isaaclab.sim.converters.MjcfConverter` to forward the full set of - :class:`isaacsim.asset.importer.mjcf.MJCFImporterConfig` options to the Isaac Sim importer. - -Removed -^^^^^^^ -* Removed :func:`~isaaclab.sim.converters.urdf_utils.merge_fixed_joints` as it is now handled by the Isaac Sim URDF importer. diff --git a/source/isaaclab/changelog.d/xul-determinism.rst b/source/isaaclab/changelog.d/xul-determinism.rst deleted file mode 100644 index 25a873669e75..000000000000 --- a/source/isaaclab/changelog.d/xul-determinism.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added ``--deterministic`` flag to :class:`~isaaclab.app.app_launcher.AppLauncher` so training and - rendering runs can opt into RTX/RTPT carb settings for more reproducible output after startup. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 43ef3f9bcb02..e18554b4fec5 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 = "5.2.1" +version = "5.3.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 1a5b444d68a9..f8453b05a663 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,121 @@ Changelog --------- +5.3.0 (2026-05-16) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added unified ``train`` and ``play`` console-script entry points (``isaaclab.cli:train`` + and ``isaaclab.cli:play``) that dispatch to a library-specific implementation via + ``--rl_library``. Supported libraries are ``rsl_rl``, ``rl_games``, ``skrl``, ``sb3``, + and ``rlinf``. +* Added refactored per-library train/play scripts under + ``scripts/reinforcement_learning/`` with a shared ``common.dispatch_library_entrypoint`` + helper, replacing the previous standalone per-library scripts. +* Added experimental ``uv run`` workflow allowing ``uv run train`` and ``uv run play`` + directly from the repository root without manual environment setup. See + :ref:`uv-run-training` for usage. +* Added :attr:`~isaaclab.sim.converters.UrdfConverterCfg.ros_package_paths`, + :attr:`~isaaclab.sim.converters.UrdfConverterCfg.robot_type`, + :attr:`~isaaclab.sim.converters.UrdfConverterCfg.run_asset_transformer`, + :attr:`~isaaclab.sim.converters.UrdfConverterCfg.run_multi_physics_conversion`, and + :attr:`~isaaclab.sim.converters.UrdfConverterCfg.debug_mode` config fields that mirror the + new :class:`isaacsim.asset.importer.urdf.URDFImporterConfig` options. +* Extended :attr:`~isaaclab.sim.converters.UrdfConverterCfg.collision_type` to accept + ``"Bounding Sphere"`` and ``"Bounding Cube"`` in addition to the existing ``"Convex Hull"`` + and ``"Convex Decomposition"`` values. +* Added :attr:`~isaaclab.sim.converters.MjcfConverterCfg.fix_base`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.link_density`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.robot_type`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_gain_type`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_bias_type`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_gain_prm`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.override_bias_prm`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.run_asset_transformer`, + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.run_multi_physics_conversion`, and + :attr:`~isaaclab.sim.converters.MjcfConverterCfg.debug_mode` config fields that mirror the + new :class:`isaacsim.asset.importer.mjcf.MJCFImporterConfig` options. +* Added ``--deterministic`` flag to :class:`~isaaclab.app.app_launcher.AppLauncher` so training and + rendering runs can opt into RTX/RTPT carb settings for more reproducible output after startup. +* Added :func:`~isaaclab.cloner.cloner_utils.is_homogeneous` to detect whether a :class:`~isaaclab.cloner.ClonePlan` + assigns every environment from every source (a homogeneous clone mask). +* Added :class:`~isaaclab.envs.mdp.observations.stacked_image`, a stateful + :class:`~isaaclab.managers.ManagerTermBase` that channel-stacks the last ``N`` frames + from a camera sensor. Manager-based environments can reference it in observation cfg + to add explicit temporal information for camera-based RL tasks whose renderer doesn't + supply implicit temporal data (e.g., Newton Warp). + +Changed +^^^^^^^ + +* Refactored :class:`~isaaclab.sim.converters.UrdfConverter` to delegate the full conversion + pipeline to :class:`isaacsim.asset.importer.urdf.URDFImporter` / + :class:`isaacsim.asset.importer.urdf.URDFImporterConfig`. The duplicated IsaacLab + implementations of ``_apply_fix_base``, ``_apply_link_density``, ``_apply_joint_drives``, + ``_set_drive_type_on_joints``, ``_set_target_type_on_joints``, ``_set_drive_gains_on_joints``, + and ``_fix_articulation_root_for_fixed_base`` have been removed and replaced with a thin + translation layer that maps :class:`~isaaclab.sim.converters.UrdfConverterCfg` onto the + Isaac Sim importer config. All behaviour is preserved. +* Updated :class:`~isaaclab.sim.converters.MjcfConverter` to forward the full set of + :class:`isaacsim.asset.importer.mjcf.MJCFImporterConfig` options to the Isaac Sim importer. +* Bumped the ``newton[sim]`` pin from ``v1.2.0rc2`` to ``v1.2.0`` + (stable) across :mod:`isaaclab_newton`, :mod:`isaaclab_physx` + (``[newton]`` extra), :mod:`isaaclab_visualizers` (3×), and + ``tools/wheel_builder/res/python_packages.toml``. Upstream release + notes: `newton-physics/newton v1.2.0 + `_. +* No IsaacLab-side ``mujoco`` / ``mujoco-warp`` pin change — the + transitive ``mjwarp`` bump flows in through ``newton[sim]`` since + `isaac-sim/IsaacLab#5566 + `_ dropped the + explicit pins. +* Changed Isaac Lab Docker images to run as the non-root ``isaaclab`` user by default. Use an explicit + container user override when root access is required. +* Changed :class:`~isaaclab.sensors.camera.CameraData` to expose all sensor buffers as + :class:`~isaaclab.utils.warp.ProxyArray` instead of :class:`torch.Tensor`. The fields + :attr:`~isaaclab.sensors.camera.CameraData.pos_w` (``wp.vec3f``), + :attr:`~isaaclab.sensors.camera.CameraData.quat_w_world` (``wp.quatf``), + :attr:`~isaaclab.sensors.camera.CameraData.intrinsic_matrices` (``wp.mat33f``), and all + entries in :attr:`~isaaclab.sensors.camera.CameraData.output` are now backed by warp arrays. + Use ``.torch`` for a zero-copy :class:`torch.Tensor` view or ``.warp`` to pass the array + directly to a warp kernel. Existing code using these fields as tensors (indexing, arithmetic, + :func:`torch.testing.assert_close`, etc.) continues to work via the + :class:`~isaaclab.utils.warp.ProxyArray` deprecation bridge with a one-time + :class:`DeprecationWarning`. +* Updated :meth:`~isaaclab.renderers.BaseRenderer.set_outputs` and + :meth:`~isaaclab.renderers.BaseRenderer.update_camera` in :class:`~isaaclab.renderers.BaseRenderer` + to accept :class:`~isaaclab.utils.warp.ProxyArray` arguments instead of :class:`torch.Tensor`. + +Removed +^^^^^^^ + +* Removed :func:`~isaaclab.sim.converters.urdf_utils.merge_fixed_joints` as it is now handled by the Isaac Sim URDF importer. + +Fixed +^^^^^ + +* Fixed a startup crash in :class:`~isaaclab.app.AppLauncher` when launching with a CUDA device. + Setting the current torch CUDA device used to happen before ``SimulationApp`` was created, which + imported ``torch`` (and transitively NumPy/OpenBLAS) prior to Kit's platform-info fork. On systems + where OpenBLAS's at-fork handlers were not yet safe, that fork could crash. The + ``torch.cuda.set_device`` call is now deferred until after ``SimulationApp`` starts. +* Fixed ``calculate_rotation_error`` in + ``source/isaaclab/test/controllers/test_pink_ik.py`` composing rotation matrices + with element-wise ``*`` instead of matrix multiplication ``@`` — a latent bug + from `isaac-sim/IsaacLab#3149 + `_ that surfaced as NaN after + `isaac-sim/IsaacLab#5609 + `_ added the unit-norm guard to + ``quat_from_matrix``. +* Made ``test_pink_ik`` deterministic by seeding the env (``env_cfg.seed = 42``) + in ``create_test_env``. +* Loosened the G1 Pink IK rotation tolerance from ``0.030`` rad to ``0.100`` rad + in ``pink_ik_g1_test_configs.json`` to accommodate G1's intentionally smooth IK + tuning (slower-converging than GR1T2). GR1T2 tolerance unchanged at ``0.020`` rad. + + 5.2.1 (2026-05-15) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst b/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst deleted file mode 100644 index 12a62ab4d414..000000000000 --- a/source/isaaclab_newton/changelog.d/jichuanh-newton-replicate-tendon-fix.rst +++ /dev/null @@ -1,14 +0,0 @@ -Fixed -^^^^^ - -* Fixed per-environment string identifiers (e.g. ``mujoco:tendon_label``) - keeping the source proto path after replication. - :func:`~isaaclab_newton.cloner.newton_replicate._rename_builder_labels` - now also walks string-typed custom-attribute columns whose frequency - declares a ``references="world"`` companion, rewriting their per-row - source-path prefix to the destination world root in the same pass that - handles built-in label arrays. Adds ``constraint_mimic`` and - ``equality_constraint`` to that built-in pass for completeness. The - prefix match uses a path-separator boundary so a source path that is a - string prefix of another (e.g. ``/Sources/protoA`` vs - ``/Sources/protoAB``) does not cross-contaminate during the rename. diff --git a/source/isaaclab_newton/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst b/source/isaaclab_newton/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst deleted file mode 100644 index e802ace0df12..000000000000 --- a/source/isaaclab_newton/changelog.d/jichuanh-newton-v120-stable-bump.minor.rst +++ /dev/null @@ -1,6 +0,0 @@ -Changed -^^^^^^^ - -* Bumped the ``newton[sim]`` pin from ``v1.2.0rc2`` to ``v1.2.0`` - (stable). Upstream release notes: `newton-physics/newton v1.2.0 - `_. diff --git a/source/isaaclab_newton/changelog.d/jmart-cubric-abi.rst b/source/isaaclab_newton/changelog.d/jmart-cubric-abi.rst deleted file mode 100644 index d2310ee82a48..000000000000 --- a/source/isaaclab_newton/changelog.d/jmart-cubric-abi.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Added runtime verification of the ``omni::cubric::IAdapter`` interface - version in :mod:`~isaaclab_newton.physics._cubric` as defense-in-depth - against future ABI shifts. The shim falls back to the CPU path on - major-version mismatch or older-minor. diff --git a/source/isaaclab_newton/changelog.d/mh-warp_cam.minor.rst b/source/isaaclab_newton/changelog.d/mh-warp_cam.minor.rst deleted file mode 100644 index 7955215beba6..000000000000 --- a/source/isaaclab_newton/changelog.d/mh-warp_cam.minor.rst +++ /dev/null @@ -1,8 +0,0 @@ -Changed -^^^^^^^ - -* Updated :class:`~isaaclab_newton.renderers.NewtonWarpRenderer` to accept - :class:`~isaaclab.utils.warp.ProxyArray` in :meth:`set_outputs` and :meth:`update_camera`, - matching the updated :class:`~isaaclab.renderers.BaseRenderer` interface. Output buffers are - reinterpreted directly from the ProxyArray's underlying warp array, removing the previous - :func:`warp.from_torch` conversion path. diff --git a/source/isaaclab_newton/changelog.d/pbarejko-debugging.skip b/source/isaaclab_newton/changelog.d/pbarejko-debugging.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index ba4905bfd73e..ee048a6bd0f0 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.9.1" +version = "0.10.0" # 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 01bcae178b7b..2f30eb8aaf9c 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,45 @@ Changelog --------- +0.10.0 (2026-05-16) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added runtime verification of the ``omni::cubric::IAdapter`` interface + version in :mod:`~isaaclab_newton.physics._cubric` as defense-in-depth + against future ABI shifts. The shim falls back to the CPU path on + major-version mismatch or older-minor. + +Changed +^^^^^^^ + +* Bumped the ``newton[sim]`` pin from ``v1.2.0rc2`` to ``v1.2.0`` + (stable). Upstream release notes: `newton-physics/newton v1.2.0 + `_. +* Updated :class:`~isaaclab_newton.renderers.NewtonWarpRenderer` to accept + :class:`~isaaclab.utils.warp.ProxyArray` in :meth:`set_outputs` and :meth:`update_camera`, + matching the updated :class:`~isaaclab.renderers.BaseRenderer` interface. Output buffers are + reinterpreted directly from the ProxyArray's underlying warp array, removing the previous + :func:`warp.from_torch` conversion path. + +Fixed +^^^^^ + +* Fixed per-environment string identifiers (e.g. ``mujoco:tendon_label``) + keeping the source proto path after replication. + :func:`~isaaclab_newton.cloner.newton_replicate._rename_builder_labels` + now also walks string-typed custom-attribute columns whose frequency + declares a ``references="world"`` companion, rewriting their per-row + source-path prefix to the destination world root in the same pass that + handles built-in label arrays. Adds ``constraint_mimic`` and + ``equality_constraint`` to that built-in pass for completeness. The + prefix match uses a path-separator boundary so a source path that is a + string prefix of another (e.g. ``/Sources/protoA`` vs + ``/Sources/protoAB``) does not cross-contaminate during the rename. + + 0.9.1 (2026-05-15) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/changelog.d/fix-ovrtx-device-mismatch.rst b/source/isaaclab_ov/changelog.d/fix-ovrtx-device-mismatch.rst deleted file mode 100644 index d589cc8ad543..000000000000 --- a/source/isaaclab_ov/changelog.d/fix-ovrtx-device-mismatch.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed :class:`OVRTXRenderer` crash on multi-GPU systems when ``sim.device`` - is not ``cuda:0``. All Warp kernel launches, buffer allocations, and OVRTX - ``binding.map()`` calls now use the device from :class:`CameraRenderSpec` - instead of hardcoded defaults. diff --git a/source/isaaclab_ov/changelog.d/huidongc-ovrtx-cloning.rst b/source/isaaclab_ov/changelog.d/huidongc-ovrtx-cloning.rst deleted file mode 100644 index f549ebd0a5de..000000000000 --- a/source/isaaclab_ov/changelog.d/huidongc-ovrtx-cloning.rst +++ /dev/null @@ -1,16 +0,0 @@ -Fixed -^^^^^ - -* Fixed cloned environments disappearing from tiled camera output if - :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.use_ovrtx_cloning` is set to ``True``, - by correcting scene-partition attribute creation on env roots and cameras. - -Changed -^^^^^^^ - -* Renamed the ``use_cloning`` field on :class:`~isaaclab_ov.renderers.OVRTXRendererCfg` to ``use_ovrtx_cloning``. - Changed its default value to ``True``. This will bring notable speedup for the total startup time (Launch to Train), - esp. for large-scale env setups. On Isaac-Dexsuite-Kuka-Allegro-Lift-v0 with 1024 env clones, the total startup time - dropped from ~78s to ~43s. Note that if ``use_ovrtx_cloning`` is enabled but the env setup is heterogeneous, the - OVRTX renderer will disable the internal cloning path and logs a warning, exporting the full multi-environment stage - instead (same effect as setting ``use_ovrtx_cloning`` to ``False`` for that run). diff --git a/source/isaaclab_ov/changelog.d/mh-warp_cam.minor.rst b/source/isaaclab_ov/changelog.d/mh-warp_cam.minor.rst deleted file mode 100644 index 7cca220862f1..000000000000 --- a/source/isaaclab_ov/changelog.d/mh-warp_cam.minor.rst +++ /dev/null @@ -1,7 +0,0 @@ -Changed -^^^^^^^ - -* Updated :class:`~isaaclab_ov.renderers.OVRTXRenderer` to accept - :class:`~isaaclab.utils.warp.ProxyArray` in :meth:`set_outputs` and :meth:`update_camera`, - matching the updated :class:`~isaaclab.renderers.BaseRenderer` interface. Output buffers are - accessed via their underlying warp array directly. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 540638e401ca..82b495924d3c 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.1.9" +version = "0.2.0" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index a4846a27bee8..5f9a405381bf 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,35 @@ Changelog --------- +0.2.0 (2026-05-16) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Renamed the ``use_cloning`` field on :class:`~isaaclab_ov.renderers.OVRTXRendererCfg` to ``use_ovrtx_cloning``. + Changed its default value to ``True``. This will bring notable speedup for the total startup time (Launch to Train), + esp. for large-scale env setups. On Isaac-Dexsuite-Kuka-Allegro-Lift-v0 with 1024 env clones, the total startup time + dropped from ~78s to ~43s. Note that if ``use_ovrtx_cloning`` is enabled but the env setup is heterogeneous, the + OVRTX renderer will disable the internal cloning path and logs a warning, exporting the full multi-environment stage + instead (same effect as setting ``use_ovrtx_cloning`` to ``False`` for that run). +* Updated :class:`~isaaclab_ov.renderers.OVRTXRenderer` to accept + :class:`~isaaclab.utils.warp.ProxyArray` in :meth:`set_outputs` and :meth:`update_camera`, + matching the updated :class:`~isaaclab.renderers.BaseRenderer` interface. Output buffers are + accessed via their underlying warp array directly. + +Fixed +^^^^^ + +* Fixed :class:`OVRTXRenderer` crash on multi-GPU systems when ``sim.device`` + is not ``cuda:0``. All Warp kernel launches, buffer allocations, and OVRTX + ``binding.map()`` calls now use the device from :class:`CameraRenderSpec` + instead of hardcoded defaults. +* Fixed cloned environments disappearing from tiled camera output if + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.use_ovrtx_cloning` is set to ``True``, + by correcting scene-partition attribute creation on env roots and cameras. + + 0.1.9 (2026-05-14) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/changelog.d/jichuanh-newton-v120-stable-bump.rst b/source/isaaclab_physx/changelog.d/jichuanh-newton-v120-stable-bump.rst deleted file mode 100644 index 6451e97bccd2..000000000000 --- a/source/isaaclab_physx/changelog.d/jichuanh-newton-v120-stable-bump.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Bumped the optional ``[newton]`` extra to ``v1.2.0`` (stable) so the - pin matches :mod:`isaaclab_newton`. diff --git a/source/isaaclab_physx/changelog.d/mh-warp_cam.minor.rst b/source/isaaclab_physx/changelog.d/mh-warp_cam.minor.rst deleted file mode 100644 index dce71a6e2326..000000000000 --- a/source/isaaclab_physx/changelog.d/mh-warp_cam.minor.rst +++ /dev/null @@ -1,7 +0,0 @@ -Changed -^^^^^^^ - -* Updated :class:`~isaaclab_physx.renderers.IsaacRtxRenderer` to accept - :class:`~isaaclab.utils.warp.ProxyArray` in :meth:`set_outputs` and :meth:`update_camera`, - matching the updated :class:`~isaaclab.renderers.BaseRenderer` interface. Output buffers are - accessed via ``.warp`` directly, avoiding intermediate :func:`warp.from_torch` conversions. diff --git a/source/isaaclab_physx/changelog.d/pbarejko-debugging.skip b/source/isaaclab_physx/changelog.d/pbarejko-debugging.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 7b3903e18087..df613c5d9e9d 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.7.1" +version = "0.8.0" # 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 c344fb19b0ea..bda522208327 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,20 @@ Changelog --------- +0.8.0 (2026-05-16) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Bumped the optional ``[newton]`` extra to ``v1.2.0`` (stable) so the + pin matches :mod:`isaaclab_newton`. +* Updated :class:`~isaaclab_physx.renderers.IsaacRtxRenderer` to accept + :class:`~isaaclab.utils.warp.ProxyArray` in :meth:`set_outputs` and :meth:`update_camera`, + matching the updated :class:`~isaaclab.renderers.BaseRenderer` interface. Output buffers are + accessed via ``.warp`` directly, avoiding intermediate :func:`warp.from_torch` conversions. + + 0.7.1 (2026-05-15) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_rl/changelog.d/antoiner-fix-rsl-rl-export-ci.skip b/source/isaaclab_rl/changelog.d/antoiner-fix-rsl-rl-export-ci.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_tasks/changelog.d/jichuanh-preset-cli-basic.minor.rst b/source/isaaclab_tasks/changelog.d/jichuanh-preset-cli-basic.minor.rst deleted file mode 100644 index 1215d4a24299..000000000000 --- a/source/isaaclab_tasks/changelog.d/jichuanh-preset-cli-basic.minor.rst +++ /dev/null @@ -1,19 +0,0 @@ -Added -^^^^^ - -* Added :class:`isaaclab_tasks.utils.preset_target.PresetTarget` -- closed enum - of typed preset categories (``PHYSICS``, ``RENDERER``, ``DOMAIN``). -* Added :func:`isaaclab_tasks.utils.preset_cli.setup_preset_cli` -- a typed - selection layer over the ``presets=`` Hydra-decorator preset flow. - Recognizes three Hydra-style tokens (``physics=NAME``, ``renderer=NAME``, - ``presets=NAME[,...]``) and folds them into the existing token. When - ``--task=X`` is given alongside ``--help``, lists the - :class:`~isaaclab_tasks.utils.hydra.PresetCfg` variants present in the - task's env_cfg, bucketed by typed target. - -Changed -^^^^^^^ - -* Changed :mod:`isaaclab_tasks.utils.hydra` to source legacy preset aliases - from :meth:`~isaaclab_tasks.utils.preset_target.PresetTarget.all_legacy_aliases` - instead of a local literal dict. diff --git a/source/isaaclab_tasks/changelog.d/jmart-frame-stacking.minor.rst b/source/isaaclab_tasks/changelog.d/jmart-frame-stacking.minor.rst deleted file mode 100644 index 0750e3fe1c70..000000000000 --- a/source/isaaclab_tasks/changelog.d/jmart-frame-stacking.minor.rst +++ /dev/null @@ -1,8 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_tasks.direct.cartpole.cartpole_camera_presets_env.CartpoleCameraPresetsEnv`, - a subclass of :class:`~isaaclab_tasks.direct.cartpole.cartpole_camera_env.CartpoleCameraEnv` that - wires :class:`~isaaclab.utils.buffers.CircularBuffer` into the ``Isaac-Cartpole-Camera-Presets-Direct-v0`` - task. ``frame_stack`` defaults to ``2`` for the Newton + Warp combo and ``1`` otherwise; - CLI overrides via ``env.frame_stack=N`` are respected. diff --git a/source/isaaclab_tasks/changelog.d/mh-warp_cam.skip b/source/isaaclab_tasks/changelog.d/mh-warp_cam.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_tasks/changelog.d/proth-fix-skrl-drone-arl-states-input.rst b/source/isaaclab_tasks/changelog.d/proth-fix-skrl-drone-arl-states-input.rst deleted file mode 100644 index 99be9d9990cc..000000000000 --- a/source/isaaclab_tasks/changelog.d/proth-fix-skrl-drone-arl-states-input.rst +++ /dev/null @@ -1,10 +0,0 @@ -Fixed -^^^^^ - -* Fixed ``AttributeError: 'NoneType' object has no attribute 'shape'`` raised - when instantiating skrl PPO models for the ``Isaac-TrackPositionNoObstacles-ARL-Robot-1-*`` - and ``Isaac-Navigation-3DObstacles-ARL-Robot-1-*`` tasks. The drone-ARL skrl - configs used ``input: STATES`` for both policy and value networks, which - skrl 2.0 resolves against ``state_space`` (``None`` for single-agent - environments). Updated the configs to use ``input: OBSERVATIONS`` to match - the rest of the single-agent skrl configs in IsaacLab. diff --git a/source/isaaclab_tasks/changelog.d/xul-determinism.skip b/source/isaaclab_tasks/changelog.d/xul-determinism.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index f8894daa24b6..302713bfed18 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.6.0" +version = "1.7.0" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 5f61ba12b099..5f420bea4610 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,46 @@ Changelog --------- +1.7.0 (2026-05-16) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`isaaclab_tasks.utils.preset_target.PresetTarget` -- closed enum + of typed preset categories (``PHYSICS``, ``RENDERER``, ``DOMAIN``). +* Added :func:`isaaclab_tasks.utils.preset_cli.setup_preset_cli` -- a typed + selection layer over the ``presets=`` Hydra-decorator preset flow. + Recognizes three Hydra-style tokens (``physics=NAME``, ``renderer=NAME``, + ``presets=NAME[,...]``) and folds them into the existing token. When + ``--task=X`` is given alongside ``--help``, lists the + :class:`~isaaclab_tasks.utils.hydra.PresetCfg` variants present in the + task's env_cfg, bucketed by typed target. +* Added :class:`~isaaclab_tasks.direct.cartpole.cartpole_camera_presets_env.CartpoleCameraPresetsEnv`, + a subclass of :class:`~isaaclab_tasks.direct.cartpole.cartpole_camera_env.CartpoleCameraEnv` that + wires :class:`~isaaclab.utils.buffers.CircularBuffer` into the ``Isaac-Cartpole-Camera-Presets-Direct-v0`` + task. ``frame_stack`` defaults to ``2`` for the Newton + Warp combo and ``1`` otherwise; + CLI overrides via ``env.frame_stack=N`` are respected. + +Changed +^^^^^^^ + +* Changed :mod:`isaaclab_tasks.utils.hydra` to source legacy preset aliases + from :meth:`~isaaclab_tasks.utils.preset_target.PresetTarget.all_legacy_aliases` + instead of a local literal dict. + +Fixed +^^^^^ + +* Fixed ``AttributeError: 'NoneType' object has no attribute 'shape'`` raised + when instantiating skrl PPO models for the ``Isaac-TrackPositionNoObstacles-ARL-Robot-1-*`` + and ``Isaac-Navigation-3DObstacles-ARL-Robot-1-*`` tasks. The drone-ARL skrl + configs used ``input: STATES`` for both policy and value networks, which + skrl 2.0 resolves against ``state_space`` (``None`` for single-agent + environments). Updated the configs to use ``input: OBSERVATIONS`` to match + the rest of the single-agent skrl configs in IsaacLab. + + 1.6.0 (2026-05-14) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_teleop/changelog.d/rwiltz-docs-rtx-minimal-renderer.rst b/source/isaaclab_teleop/changelog.d/rwiltz-docs-rtx-minimal-renderer.rst deleted file mode 100644 index 7e0e4da5190e..000000000000 --- a/source/isaaclab_teleop/changelog.d/rwiltz-docs-rtx-minimal-renderer.rst +++ /dev/null @@ -1,12 +0,0 @@ -Added -^^^^^ - -* Expanded the **Optimize XR Performance** documentation with guidance for - lower-spec GPUs and complex scenes: a walkthrough for switching the - Isaac Lab viewport to the RTX - Minimal renderer (including the - ``DistantLight``-only lighting limitation), notes on the - ``sim.dt`` / ``sim.render_interval`` trade-off, a description of the - XR **Resolution Multiplier** slider for trading image sharpness for GPU - headroom, guidance on ``RetargetingExecutionConfig`` (sync vs pipelined - modes and ``DeadlinePacingConfig.safety_margin_s``), and a CloudXR - frame-pacing diagnostic note. See :ref:`isaac-teleop-performance`. diff --git a/source/isaaclab_teleop/changelog.d/rwiltz-xcr-replay-agent.minor.rst b/source/isaaclab_teleop/changelog.d/rwiltz-xcr-replay-agent.minor.rst deleted file mode 100644 index 828709832ac7..000000000000 --- a/source/isaaclab_teleop/changelog.d/rwiltz-xcr-replay-agent.minor.rst +++ /dev/null @@ -1,60 +0,0 @@ -Added -^^^^^ - -* Added ``scripts/environments/teleoperation/teleop_replay_agent.py``, a - non-interactive entry point used by CI to replay captured teleop sessions - against an Isaac Lab environment, plus a small internal - ``isaaclab_teleop.automation`` subpackage backing it. Replaces the runtime - patch the ``teleop-cicd`` pipeline previously applied to - ``teleop_se3_agent.py``. - -Fixed -^^^^^ - -* Fixed ``teleop_replay_agent.py`` driving the robot toward the world origin - for the duration of ``--replay_start_delay_s``. The legacy - :class:`~isaaclab.devices.openxr.OpenXRDevice` returns a default zero pose - while the OpenXR runtime is silent, so calling ``env.step()`` during the - start-delay window fed the Pink IK garbage targets and corrupted the robot - pose long before real hand-tracking data flowed. The agent now registers - ``"START"`` / ``"STOP"`` callbacks on the device -- the same path - ``record_demos.py`` uses -- and only steps the env once the XCR replay - dispatches the recorded ``"start"`` message through Kit's OpenXR message - bus. -* Fixed ``teleop_replay_agent.py`` hanging the CI process when the XCR - replay driver coroutine raised before reaching ``post_quit``. The - previously discarded :class:`asyncio.Future` is now retained and a done - callback logs the failure with traceback and asks Kit to quit so the - host process exits cleanly. -* Fixed ``teleop_replay_agent.py`` leaking the USD stage when device - construction or environment setup raised. ``env.close()`` now runs from a - ``try/finally`` block so cleanup happens on every exit path. -* Fixed ``teleop_replay_agent.py`` producing a frozen-arms / hands-only - symptom during replay. Kit's ``teleop_command`` message bus drains - queued events as a batch when the AR profile is enabled, so the - recorded user's STOP gesture would fire within milliseconds of START - and gate ``env.step()`` off again before Pink IK had time to converge. - The replay agent now subscribes only to ``"START"``: replay is one-shot - and the only valid termination is the driver's ``post_quit``. -* Aligned ``teleop_replay_agent.py``'s pre-loop reset sequence with - ``record_demos.py`` -- ``env.sim.reset()`` then ``env.reset()`` then - ``teleop_interface.reset()`` -- so the hard physics reinit re-binds the - articulation tensor views that - :meth:`~isaaclab.controllers.pink_ik.PinkIKController.compute` reads - from each step. -* Cleared :attr:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.TerminationsCfg.success` - in the replay env config so a successful replay does not snap the robot - back to its initial pose mid-loop. - -Changed -^^^^^^^ - -* Added :paramref:`~isaaclab_teleop.automation.XcrReplayConfig.max_replay_duration_s` - (default: ``3600``) so the completion-poll loop in - :func:`~isaaclab_teleop.automation.start_xcr_replay` is bounded. If - Kit's :mod:`xcr_player` ever fails to clear its private playback - subscription, the coroutine now returns instead of spinning forever. -* Stored the :class:`omni.kit.xr.core.recorder._xr_xcr.XCRReplayAPI` - instance in a local variable inside - :func:`~isaaclab_teleop.automation.start_xcr_replay` so it stays alive - for the lifetime of the replay coroutine. diff --git a/source/isaaclab_teleop/config/extension.toml b/source/isaaclab_teleop/config/extension.toml index 876f53ffd43c..b4e568e6ae27 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.11" +version = "0.4.0" # Description title = "Isaac Lab Teleop" diff --git a/source/isaaclab_teleop/docs/CHANGELOG.rst b/source/isaaclab_teleop/docs/CHANGELOG.rst index 295bc656a903..8e4b2c58cd48 100644 --- a/source/isaaclab_teleop/docs/CHANGELOG.rst +++ b/source/isaaclab_teleop/docs/CHANGELOG.rst @@ -1,6 +1,80 @@ Changelog --------- +0.4.0 (2026-05-16) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``scripts/environments/teleoperation/teleop_replay_agent.py``, a + non-interactive entry point used by CI to replay captured teleop sessions + against an Isaac Lab environment, plus a small internal + ``isaaclab_teleop.automation`` subpackage backing it. Replaces the runtime + patch the ``teleop-cicd`` pipeline previously applied to + ``teleop_se3_agent.py``. +* Expanded the **Optimize XR Performance** documentation with guidance for + lower-spec GPUs and complex scenes: a walkthrough for switching the + Isaac Lab viewport to the RTX - Minimal renderer (including the + ``DistantLight``-only lighting limitation), notes on the + ``sim.dt`` / ``sim.render_interval`` trade-off, a description of the + XR **Resolution Multiplier** slider for trading image sharpness for GPU + headroom, guidance on ``RetargetingExecutionConfig`` (sync vs pipelined + modes and ``DeadlinePacingConfig.safety_margin_s``), and a CloudXR + frame-pacing diagnostic note. See :ref:`isaac-teleop-performance`. + +Changed +^^^^^^^ + +* Added :paramref:`~isaaclab_teleop.automation.XcrReplayConfig.max_replay_duration_s` + (default: ``3600``) so the completion-poll loop in + :func:`~isaaclab_teleop.automation.start_xcr_replay` is bounded. If + Kit's :mod:`xcr_player` ever fails to clear its private playback + subscription, the coroutine now returns instead of spinning forever. +* Stored the :class:`omni.kit.xr.core.recorder._xr_xcr.XCRReplayAPI` + instance in a local variable inside + :func:`~isaaclab_teleop.automation.start_xcr_replay` so it stays alive + for the lifetime of the replay coroutine. + +Fixed +^^^^^ + +* Fixed ``teleop_replay_agent.py`` driving the robot toward the world origin + for the duration of ``--replay_start_delay_s``. The legacy + :class:`~isaaclab.devices.openxr.OpenXRDevice` returns a default zero pose + while the OpenXR runtime is silent, so calling ``env.step()`` during the + start-delay window fed the Pink IK garbage targets and corrupted the robot + pose long before real hand-tracking data flowed. The agent now registers + ``"START"`` / ``"STOP"`` callbacks on the device -- the same path + ``record_demos.py`` uses -- and only steps the env once the XCR replay + dispatches the recorded ``"start"`` message through Kit's OpenXR message + bus. +* Fixed ``teleop_replay_agent.py`` hanging the CI process when the XCR + replay driver coroutine raised before reaching ``post_quit``. The + previously discarded :class:`asyncio.Future` is now retained and a done + callback logs the failure with traceback and asks Kit to quit so the + host process exits cleanly. +* Fixed ``teleop_replay_agent.py`` leaking the USD stage when device + construction or environment setup raised. ``env.close()`` now runs from a + ``try/finally`` block so cleanup happens on every exit path. +* Fixed ``teleop_replay_agent.py`` producing a frozen-arms / hands-only + symptom during replay. Kit's ``teleop_command`` message bus drains + queued events as a batch when the AR profile is enabled, so the + recorded user's STOP gesture would fire within milliseconds of START + and gate ``env.step()`` off again before Pink IK had time to converge. + The replay agent now subscribes only to ``"START"``: replay is one-shot + and the only valid termination is the driver's ``post_quit``. +* Aligned ``teleop_replay_agent.py``'s pre-loop reset sequence with + ``record_demos.py`` -- ``env.sim.reset()`` then ``env.reset()`` then + ``teleop_interface.reset()`` -- so the hard physics reinit re-binds the + articulation tensor views that + :meth:`~isaaclab.controllers.pink_ik.PinkIKController.compute` reads + from each step. +* Cleared :attr:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.TerminationsCfg.success` + in the replay env config so a successful replay does not snap the robot + back to its initial pose mid-loop. + + 0.3.11 (2026-05-12) ~~~~~~~~~~~~~~~~~~~ From 6e3482dd39491797a3608c55f0225ca2ae66bf5e Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Sat, 16 May 2026 08:41:15 +0200 Subject: [PATCH 085/133] [OVPHYSX] Articulation rewrite (data class + asset class + kernels) (#5459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Drastic rewrite of OVPhysX `Articulation` and `ArticulationData` so they follow the same shape as the post-refactor OVPhysX `RigidObject` from #5426, with the API surface mirroring `Newton Articulation` and behavior parity with `PhysX Articulation`. Single-PR atomic rewrite, clean break (no deprecation aliases for OVPhysX-introduced renames; framework-inherited deprecated shims kept). The OVPhysX articulation diverged significantly from the rest of the framework conventions. This PR brings it back in line: same docstring template, same section ordering, same naming, same internal patterns, same lifecycle. > [!IMPORTANT] > **Stacked on #5426** (`[OVPHYSX] RigidObject + RigidObjectData asset`). Review only the 16 articulation-specific commits at the tip of this branch — every commit before that lands via #5426. Once #5426 merges to `develop`, this PR will rebase cleanly onto `develop`. Fixes # (none — internal refactor; no associated issue) ## Architectural changes **OVPhysX RigidObject is the design template.** It has navigated the hybrid OVPhysX surface — Newton-style mask+index dual API + PhysX-style CPU-only bindings via pinned-host staging à la #5329 + pull-to-refresh `binding.read(target)`: - Eager `TimestampedBufferWarp` allocation in `_create_buffers` (single source of truth — no `_invalidate_caches` / `_ensure_*_buffers` machinery). - Pinned-host CPU staging buffers for every CPU-only binding (mass, COM, inertia, all DOF properties). - `_binding_read` / `_binding_write` / `_stage_to_pinned_cpu` helpers route CPU-only types through pinned-host memory. - Every public property returns a `ProxyArray` (warp + torch dual view); raw `wp.array` for one-shot config buffers. - Counts and names (`num_instances`, `num_bodies`, `num_joints`, `body_names`, `joint_names`, ...) demoted from `@property` to plain instance attributes. - Dual mask+index API on every writer/setter (`*_index` accepts partial data; `*_mask` accepts full data with a `wp.bool` mask). - All `write_*` / `set_*` parameters are kwarg-only after `*,`. No positional. **No `full_data` flag anywhere.** - The deprecated `_write_body_state` plumbing layer is removed; deprecated state-writer shims (`write_root_state_to_sim`, etc.) call the public `write_*_to_sim_index` methods directly, mirroring RigidObject. **Articulation-specific surface** mirrors Newton 1-to-1: joint-state writers, joint-property writers (CPU-only), body-property setters (multi-body shape), joint-command target setters, external-wrench setters via `WrenchComposer`, fixed/spatial tendon setters, deprecated state-writer shims, full actuator pipeline (`compute`, `_apply_actuator_model`, `_process_actuators_cfg`). ## Files changed - `source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py` — full rewrite (~3863 lines, matches Newton). - `source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py` — full rewrite (~2504 lines). - `source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py` — gained 6 articulation kernels migrated from the stop-gap `kernels_old.py` (now deleted): `_compose_root_com_pose`, `_compute_heading`, `_copy_first_body`, `_projected_gravity`, `_world_vel_to_body_ang`, `_world_vel_to_body_lin`. Plus 2 new joint-property kernels (`write_joint_position_limit_to_buffer_index/mask` for trailing-dim-2 limits, `write_joint_friction_to_buffer_index/mask` for the broadcast-coefficient pattern). - `source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels_old.py` — deleted. - `source/isaaclab_ovphysx/test/assets/test_articulation.py` — verbatim PhysX test mirror (~210 parametrizations) with PhysX-internal `root_view.X` assertions adapted to the OVPhysX bindings dict and `omni.physx.scripts`-dependent tests xfailed; mirrors the precedent from the RigidObject test mirror in #5426. ## Type of change - Breaking change (existing functionality will not work without user modification — OVPhysX is at `0.2.x`, clean break is acceptable per semver-on-0.x; no deprecation aliases for OVPhysX-introduced renames). - Code modernization / refactor. ## Validation Three layers, run on **GPU and CPU separately** (the wheel's process-global device-mode lock makes a single invocation lock to one device): 1. **Real-backend port** — `test_articulation.py` (verbatim PhysX mirror). `./scripts/run_ovphysx.sh -m pytest -k 'cuda:0'` and `... -k 'cpu'`. Expected end state: each pass shows ` passed, xfailed, 0 failed`. Every xfail carries a `reason` pointing at the wheel-gaps spec. 2. **Cross-backend interface** — `source/isaaclab/test/assets/test_articulation_iface.py` will gain an `ovphysx` backend, mirroring the rigid-object iface treatment from #5426. 3. **API consistency audit** — per-method side-by-side checklist comparing Newton, RigidObject (post-refactor), and the rewritten Articulation; verifies method name, kwarg-only signature, parameter order, return type, docstring template, section-header placement. ## Status Active triage — not yet ready for review. - ✅ Implementation complete (all writers, setters, properties, lifecycle, actuator pipeline). - ✅ Initial GPU root-cause bug fixed: `_read_transform_binding` now routes `BODY_COM_POSE` through `_binding_read` so the wheel's CPU-only-binding device check is satisfied on a GPU sim. - ✅ Verbatim PhysX-internals assertions (`root_view.max_dofs == shared_metatype.dof_count`, `link_paths[0]` round-trip) adapted to the OVPhysX bindings dict — they now check `binding.shape[1] == num_joints / num_bodies` for each per-DOF / per-link binding. - 🔄 In-flight: tendon-init device-routing bug. `_read_initial_properties` reads `FIXED_TENDON_*` / `SPATIAL_TENDON_*` via numpy assuming CPU residency, but the wheel exposes them as GPU-resident (consistent with PhysX's `set_fixed_tendon_properties` not cloning to CPU). Plan is to remove tendon types from `_CPU_ONLY_TYPES` and read them directly into the sim-device buffer. - ⏳ Pending: cross-backend `test_articulation_iface.py` extension, API consistency audit, CHANGELOG + version bump (`0.2.x → 0.3.0`). ## 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 - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works (the verbatim PhysX test mirror is the contract; bug-fixing in progress) - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file (deferred to final-pass commit) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Kelly Guo Co-authored-by: Kelly Guo --- docs/conf.py | 1 + .../antoiner-feat-ovphysx_articulation.skip | 4 + .../test/assets/test_articulation_iface.py | 15 +- ...toiner-feat-ovphysx_articulation.major.rst | 109 + .../assets/articulation/articulation.py | 4687 +++++++++++------ .../assets/articulation/articulation_data.py | 1601 ++++-- .../assets/articulation/kernels.py | 239 +- .../isaaclab_ovphysx/assets/kernels.py | 268 + .../physics/ovphysx_manager.py | 1 + .../isaaclab_ovphysx/tensor_types.py | 15 +- .../test/assets/test_articulation.py | 2511 ++++++++- .../test/assets/test_articulation_data.py | 58 - .../test/assets/test_articulation_helpers.py | 140 + .../antoiner-feat-ovphysx_articulation.rst | 10 + .../allegro_hand/allegro_hand_env_cfg.py | 21 + 15 files changed, 7421 insertions(+), 2259 deletions(-) create mode 100644 source/isaaclab/changelog.d/antoiner-feat-ovphysx_articulation.skip create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation.major.rst delete mode 100644 source/isaaclab_ovphysx/test/assets/test_articulation_data.py create mode 100644 source/isaaclab_ovphysx/test/assets/test_articulation_helpers.py create mode 100644 source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_articulation.rst diff --git a/docs/conf.py b/docs/conf.py index bcc355812afa..941b8c844da8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -170,6 +170,7 @@ "omni.client", "omni.physx", "omni.physics", + "ovphysx", "usdrt", "pxr.PhysxSchema", "pxr.PhysicsSchemaTools", diff --git a/source/isaaclab/changelog.d/antoiner-feat-ovphysx_articulation.skip b/source/isaaclab/changelog.d/antoiner-feat-ovphysx_articulation.skip new file mode 100644 index 000000000000..358e54a6b3dc --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-feat-ovphysx_articulation.skip @@ -0,0 +1,4 @@ +Test-only: update the OVPhysX iface test factory in +``source/isaaclab/test/assets/test_articulation_iface.py`` to match the +simplified :class:`isaaclab_ovphysx.assets.ArticulationData` constructor +signature. No isaaclab core API change. diff --git a/source/isaaclab/test/assets/test_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index 8dcc2b0ebc43..8dbf19291c80 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -287,21 +287,20 @@ def create_ovphysx_articulation( object.__setattr__(articulation, "_num_fixed_tendons", num_fixed_tendons) object.__setattr__(articulation, "_num_spatial_tendons", num_spatial_tendons) - # Create ArticulationData + # Create ArticulationData; counts come from the bindings, names are set after. data = OvPhysxArticulationData(mock_bindings.bindings, device) - data._num_instances = num_instances - data._num_joints = num_joints - data._num_bodies = num_bodies - data._num_fixed_tendons = num_fixed_tendons - data._num_spatial_tendons = num_spatial_tendons - data._is_fixed_base = False data.body_names = body_names data.joint_names = joint_names data.fixed_tendon_names = fixed_tendon_names data.spatial_tendon_names = spatial_tendon_names - data._create_buffers() + data._is_fixed_base = False object.__setattr__(articulation, "_data", data) + # Allocate the articulation-side index/mask caches and wrench buffer that + # _initialize_impl would normally populate. Wrench composers created here + # are immediately overwritten by the mocks below. + articulation._create_buffers() + # Wrench composers mock_inst_wrench = MockWrenchComposer(articulation) mock_perm_wrench = MockWrenchComposer(articulation) diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation.major.rst new file mode 100644 index 000000000000..24913760aa9b --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation.major.rst @@ -0,0 +1,109 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.assets.Articulation` and + :class:`~isaaclab_ovphysx.assets.ArticulationData` for multi-DOF articulated + robots against the OVPhysX backend, satisfying the + :class:`~isaaclab.assets.BaseArticulation` and + :class:`~isaaclab.assets.BaseArticulationData` contracts. Public surface + matches the PhysX/Newton conventions: kwarg-only ``write_*_to_sim_index`` / + ``write_*_to_sim_mask`` writers and ``set_*_index`` / ``set_*_mask`` setters + for root state, joint state, joint properties, body properties, joint + command targets, fixed/spatial tendon properties, and external wrenches via + :attr:`~isaaclab_ovphysx.assets.Articulation.instantaneous_wrench_composer` + / :attr:`~isaaclab_ovphysx.assets.Articulation.permanent_wrench_composer`. + The full IsaacLab actuator pipeline (``compute`` / + ``_apply_actuator_model`` / ``_process_actuators_cfg``) is implemented on + top of the wheel's ``DOF_ACTUATION_FORCE`` / + ``DOF_POSITION_TARGET`` / ``DOF_VELOCITY_TARGET`` bindings. +* Added articulation-specific Warp kernels in + :mod:`isaaclab_ovphysx.assets.articulation.kernels`: soft-limit refresh, + default-joint-pos clamp, friction-component scatter (index + mask + variants). Six articulation kernels were also folded into the shared + :mod:`isaaclab_ovphysx.assets.kernels` module for reuse with + :class:`~isaaclab_ovphysx.assets.RigidObject` and + :class:`~isaaclab_ovphysx.assets.RigidObjectCollection`. +* Added init-time validation in + :meth:`~isaaclab_ovphysx.assets.Articulation._initialize_impl` that raises + ``RuntimeError`` when ``cfg.prim_path`` resolves to no + ``UsdPhysics.ArticulationRootAPI`` prim or to multiple roots, and + ``ValueError`` (via :meth:`_validate_cfg`) when any default joint + position is outside ``[lower, upper]`` or any default joint velocity + exceeds the per-joint maximum. Mirrors the PhysX backend. +* Added support for ``cfg.articulation_root_prim_path`` in + :meth:`~isaaclab_ovphysx.assets.Articulation._initialize_impl`: when the + user supplies an explicit subpath the binding pattern is extended + directly instead of running the auto-discovery walk, and a + ``RuntimeError`` is raised when the resulting expression resolves to no + prim in the USD stage. + +Changed +^^^^^^^ + +* **Breaking:** Renamed ``Articulation`` write/set methods to the dual + ``*_index`` / ``*_mask`` form and dropped the legacy ``full_data`` + flag. Index methods accept partial data shaped + ``(len(env_ids), len(joint_or_body_ids), ...)``; mask methods accept + full-shape data and a ``wp.bool`` mask. All keyword-only arguments live + after ``*,``; no positional fall-through. Migration: replace + ``write_X_to_sim(..., from_mask=True)`` with ``write_X_to_sim_mask(..., mask=...)``. +* **Breaking:** Removed the ``_write_body_state`` plumbing layer. + Deprecated state-writer shims (``write_root_state_to_sim``, + ``write_root_com_state_to_sim``, ``write_root_link_state_to_sim``, + joint-state equivalents) now call the public ``write_*_to_sim_index`` + methods directly. Behaviour is preserved. +* Changed ``Articulation.root_view`` to return the per-tensor-type bindings + dict (``self._bindings``). The OVPhysX wheel does not expose a single + ``ArticulationView`` object; callers that previously walked + ``root_view.shared_metatype`` / ``root_view.max_dofs`` should read from + :attr:`~isaaclab_ovphysx.assets.Articulation.num_joints` / + :attr:`~isaaclab_ovphysx.assets.Articulation.num_bodies` / + :attr:`~isaaclab_ovphysx.assets.Articulation.body_names` / + :attr:`~isaaclab_ovphysx.assets.Articulation.joint_names` instead. +* Changed every ``ArticulationData`` public property to return a + :class:`~isaaclab.utils.ProxyArray` (warp + torch dual view); raw + ``wp.array`` is reserved for one-shot config buffers. Eager + ``TimestampedBufferWarp`` allocation in :meth:`_create_buffers` makes + every buffer a single source of truth — no + ``_invalidate_caches`` / ``_ensure_*_buffers`` machinery. +* Changed ``Articulation`` body and DOF property writers to honor the + wheel's actual binding device. Tensor-type membership in + :data:`isaaclab_ovphysx.tensor_types._CPU_ONLY_TYPES` now reflects what + the wheel exposes: ``BODY_MASS``, ``BODY_COM_POSE``, ``BODY_INERTIA``, + ``DOF_STIFFNESS``, ``DOF_DAMPING``, ``DOF_LIMIT``, ``DOF_MAX_VELOCITY``, + ``DOF_MAX_FORCE``, ``DOF_ARMATURE``, ``DOF_FRICTION_PROPERTIES`` are + CPU-only (write goes through pinned-host staging); fixed and spatial + tendon bindings write directly from sim-device buffers. +* Changed :meth:`~isaaclab_ovphysx.assets.Articulation.write_joint_friction_coefficient_to_sim_index` + / ``_mask`` to accept ``joint_dynamic_friction_coeff`` and + ``joint_viscous_friction_coeff`` keyword arguments (each + ``float | torch.Tensor | wp.array | None``). ``None`` preserves the + existing component on the wheel; matches the PhysX backend. +* Changed :meth:`~isaaclab_ovphysx.assets.Articulation.write_joint_position_limit_to_sim_index` + / ``_mask`` to clamp ``default_joint_pos`` and refresh + ``soft_joint_pos_limits`` when the new hard limits invalidate the + defaults, matching the PhysX backend (with a + ``warn_limit_violation`` log). +* Changed every fixed/spatial tendon ``set_*_index`` / ``set_*_mask`` setter + to accept a scalar :class:`float` for the value argument; broadcast is + materialized via :meth:`_broadcast_scalar_to_2d`. Mirrors PhysX. +* Implemented the previously stubbed + :meth:`~isaaclab_ovphysx.assets.Articulation.write_fixed_tendon_properties_to_sim_index` + / ``_mask`` and + :meth:`~isaaclab_ovphysx.assets.Articulation.write_spatial_tendon_properties_to_sim_index` + / ``_mask``: each iterates the per-tensor bindings since the OVPhysX + wheel has no batch ``set_*_tendon_properties`` setter. + +Removed +^^^^^^^ + +* **Breaking:** Removed the ``full_data`` keyword-argument from every + ``Articulation`` ``*_index`` writer/setter. Index methods now strictly + accept partial data; full-data callers should use the matching + ``*_mask`` overload. +* Removed the stop-gap :mod:`isaaclab_ovphysx.assets.kernels_old` module; + the six articulation kernels it housed + (``_compose_root_com_pose``, ``_compute_heading``, ``_copy_first_body``, + ``_projected_gravity``, ``_world_vel_to_body_ang``, + ``_world_vel_to_body_lin``) are now in + :mod:`isaaclab_ovphysx.assets.kernels`. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py index 351467cb6164..fdb836fc08b9 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py @@ -3,51 +3,90 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Articulation implementation backed by ovphysx TensorBindingsAPI.""" +# Flag for pyright to ignore type errors in this file. +# pyright: reportPrivateUsage=false from __future__ import annotations import logging import re +import warnings from collections.abc import Sequence -from typing import TYPE_CHECKING, Any +from typing import Any import numpy as np import torch import warp as wp +from pxr import UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg 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 -from isaaclab_ovphysx.assets.kernels import _body_wrench_to_world, _scatter_rows_partial +from isaaclab_ovphysx.assets import kernels as shared_kernels +from isaaclab_ovphysx.assets.kernels import _body_wrench_to_world from isaaclab_ovphysx.physics import OvPhysxManager from .articulation_data import ArticulationData -from .kernels import update_soft_joint_pos_limits - -if TYPE_CHECKING: - from isaaclab.actuators import ActuatorBase - from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg - +from .kernels import ( + clamp_default_joint_pos_and_update_soft_limits_index, + clamp_default_joint_pos_and_update_soft_limits_mask, + update_soft_joint_pos_limits, + write_joint_friction_data_to_buffer_index, + write_joint_friction_data_to_buffer_mask, +) + +# import logger logger = logging.getLogger(__name__) class Articulation(BaseArticulation): - """Articulation backed by the ovphysx TensorBindingsAPI. + """An articulation asset class. - Reads and writes simulation state through ovphysx.TensorBinding objects created - from the OvPhysxManager's PhysX instance. - """ + An articulation is a collection of rigid bodies connected by joints. The joints can be either + fixed or actuated. The joints can be of different types, such as revolute, prismatic, D-6, etc. + However, the articulation class has currently been tested with revolute and prismatic joints. + The class supports both floating-base and fixed-base articulations. The type of articulation + is determined based on the root joint of the articulation. If the root joint is fixed, then + the articulation is considered a fixed-base system. Otherwise, it is considered a floating-base + system. This can be checked using the :attr:`Articulation.is_fixed_base` attribute. - __backend_name__ = "ovphysx" + For an asset to be considered an articulation, the root prim of the asset must have the + `USD ArticulationRootAPI`_. This API is used to define the sub-tree of the articulation using + the reduced coordinate formulation. On playing the simulation, the physics engine parses the + articulation root prim and creates the corresponding articulation in the physics engine. The + articulation root prim can be specified using the :attr:`AssetBaseCfg.prim_path` attribute. + + OVPhysX exposes per-tensor-type :class:`ovphysx.TensorBinding` objects rather than a single + opaque view; binding handles are created eagerly in :meth:`_initialize_impl` and reused across + reads and writes. CPU-only bindings (mass, CoM, inertia, joint properties, tendon properties) + are routed through pinned-host staging buffers managed by :class:`ArticulationData`. + + .. _`USD ArticulationRootAPI`: https://openusd.org/dev/api/class_usd_physics_articulation_root_a_p_i.html + + """ cfg: ArticulationCfg + """Configuration instance for the articulation.""" + + __backend_name__: str = "ovphysx" + """The name of the backend for the articulation.""" def __init__(self, cfg: ArticulationCfg): + """Initialize the articulation. + + Args: + cfg: A configuration instance. + """ super().__init__(cfg) + # bindings are populated eagerly in ``_initialize_impl``; the dict + # also caches any tensor type the user explicitly queries later + self._bindings: dict[int, Any] = {} """ Properties @@ -55,12 +94,10 @@ def __init__(self, cfg: ArticulationCfg): @property def data(self) -> ArticulationData: - """Data container with simulation state for this articulation.""" return self._data @property def num_instances(self) -> int: - """Number of articulation instances (environments).""" return self._num_instances @property @@ -70,64 +107,88 @@ def is_fixed_base(self) -> bool: @property def num_joints(self) -> int: - """Number of joints in the articulation.""" + """Number of joints in articulation.""" return self._num_joints @property def num_fixed_tendons(self) -> int: - """Number of fixed tendons in the articulation.""" - return getattr(self, "_num_fixed_tendons", 0) + """Number of fixed tendons in articulation.""" + return self._num_fixed_tendons @property def num_spatial_tendons(self) -> int: - """Number of spatial tendons in the articulation.""" - return getattr(self, "_num_spatial_tendons", 0) + """Number of spatial tendons in articulation.""" + return self._num_spatial_tendons @property def num_bodies(self) -> int: - """Number of bodies (links) in the articulation.""" + """Number of bodies in articulation.""" return self._num_bodies @property def joint_names(self) -> list[str]: - """Ordered names of joints in the articulation.""" + """Ordered names of joints in articulation.""" return self._joint_names @property def fixed_tendon_names(self) -> list[str]: - """Ordered names of fixed tendons in the articulation.""" - return getattr(self, "_fixed_tendon_names", []) + """Ordered names of fixed tendons in articulation.""" + return self._fixed_tendon_names @property def spatial_tendon_names(self) -> list[str]: - """Ordered names of spatial tendons in the articulation.""" - return getattr(self, "_spatial_tendon_names", []) + """Ordered names of spatial tendons in articulation.""" + return self._spatial_tendon_names @property def body_names(self) -> list[str]: - """Ordered names of bodies (links) in the articulation.""" + """Ordered names of bodies in articulation.""" return self._body_names @property - def root_view(self) -> Any: - """Root articulation view (not available for ovphysx backend).""" - return None + def root_view(self) -> dict[int, Any]: + """Root view for the asset. + + OVPhysX exposes per-tensor-type bindings rather than a single opaque view object + as used by the PhysX and Newton backends. Callers that need low-level binding + access should call :meth:`_get_binding` rather than iterating this dict directly. + For high-level state access (instance counts, prim paths, transforms), use the + :attr:`num_instances`, :attr:`body_names`, and :attr:`~ArticulationData.root_link_pose_w` + accessors instead. + + .. note:: + Use this view with caution. It requires handling of tensors in a specific way. + """ + return self._bindings @property - def instantaneous_wrench_composer(self) -> WrenchComposer | None: - """Wrench composer for forces applied only during the current step.""" + def instantaneous_wrench_composer(self) -> WrenchComposer: + """Instantaneous wrench composer. + + Returns a :class:`~isaaclab.utils.wrench_composer.WrenchComposer` instance. Wrenches added or set to this wrench + composer are only valid for the current simulation step. At the end of the simulation step, the wrenches set + to this object are discarded. This is useful to apply forces that change all the time, things like drag forces + for instance. + """ return self._instantaneous_wrench_composer @property - def permanent_wrench_composer(self) -> WrenchComposer | None: - """Wrench composer for forces applied persistently every step.""" + def permanent_wrench_composer(self) -> WrenchComposer: + """Permanent wrench composer. + + Returns a :class:`~isaaclab.utils.wrench_composer.WrenchComposer` instance. Wrenches added or set to this wrench + composer are persistent and are applied to the simulation at every step. This is useful to apply forces that + are constant over a period of time, things like the thrust of a motor for instance. + """ return self._permanent_wrench_composer """ Operations. """ - def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None) -> None: + def reset( + self, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_mask: wp.array | None = None + ) -> None: """Reset the articulation. .. caution:: @@ -137,26 +198,55 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None env_ids: Environment indices. If None, then all indices are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ - # use ellipses object to skip initial indices. if (env_ids is None) or (env_ids == slice(None)): env_ids = slice(None) - # reset actuators - for actuator in self.actuators.values(): - actuator.reset(env_ids) # reset external wrenches. self._instantaneous_wrench_composer.reset(env_ids, env_mask) self._permanent_wrench_composer.reset(env_ids, env_mask) def write_data_to_sim(self) -> None: - """Apply external wrenches, actuator model, and write commands into the simulation.""" - # Apply external wrenches (before actuators, same as PhysX backend). - self._apply_external_wrenches() + """Write external wrenches and joint commands to the simulation. + + If any explicit actuators are present, then the actuator models are used to compute the + joint commands. Otherwise, the joint commands are directly set into the simulation. + + .. note:: + We write external wrench to the simulation here since this function is called before the simulation step. + This ensures that the external wrench is applied at every simulation step. + """ + # write external wrench + inst = self._instantaneous_wrench_composer + perm = self._permanent_wrench_composer + if inst.active or perm.active: + if inst.active: + if perm.active: + inst.add_raw_buffers_from(perm) + force_b = inst.out_force_b.warp + torque_b = inst.out_torque_b.warp + else: + force_b = perm.out_force_b.warp + torque_b = perm.out_torque_b.warp + + # rotate body-frame wrenches into the world frame expected by ``LINK_WRENCH`` + poses = self._data.body_link_pose_w.warp + wp.launch( + _body_wrench_to_world, + dim=(self._num_instances, self._num_bodies), + inputs=[force_b, torque_b, poses], + outputs=[self._wrench_buf], + device=self._device, + ) + binding = self._get_binding(TT.LINK_WRENCH) + if binding is not None: + binding.write(self._wrench_buf) + inst.reset() + # apply actuator models self._apply_actuator_model() - # Write effort tensor to simulation. + # write actions into simulation (zeros are safe when no actuators are active) if self._effort_binding is not None: self._effort_binding.write(self._effort_write_view) - # Write position and velocity targets in one shot (not per-actuator). + # position and velocity targets only for implicit actuators if self._has_implicit_actuators: if self._pos_target_binding is not None: self._pos_target_binding.write(self._pos_target_write_view) @@ -164,10 +254,10 @@ def write_data_to_sim(self) -> None: self._vel_target_binding.write(self._vel_target_write_view) def update(self, dt: float) -> None: - """Update internal data buffers after a simulation step. + """Updates the simulation data. Args: - dt: The simulation time step [s] used for finite-difference quantities. + dt: The time step size in seconds. """ self._data.update(dt) @@ -188,7 +278,7 @@ 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 resolve_matching_names(name_keys, self._body_names, preserve_order) + return resolve_matching_names(name_keys, self.body_names, preserve_order) def find_joints( self, @@ -198,8 +288,8 @@ def find_joints( ) -> tuple[list[int], list[str]]: """Find joints in the articulation based on the name keys. - Please check the :func:`isaaclab.utils.string.resolve_matching_names` function for more - information on the name matching. + Please see the :func:`isaaclab.utils.string.resolve_matching_names` function for more information + on the name matching. Args: name_keys: A regular expression or a list of regular expressions to match the joint names. @@ -211,7 +301,8 @@ def find_joints( A tuple of lists containing the joint indices and names. """ if joint_subset is None: - joint_subset = self._joint_names + joint_subset = self.joint_names + # find joints return resolve_matching_names(name_keys, joint_subset, preserve_order) def find_fixed_tendons( @@ -222,20 +313,23 @@ def find_fixed_tendons( ) -> tuple[list[int], list[str]]: """Find fixed tendons in the articulation based on the name keys. + Please see the :func:`isaaclab.utils.string.resolve_matching_names` function for more information + on the name matching. + Args: - name_keys: A regular expression or a list of regular expressions - to match the joint names with fixed tendons. - tendon_subsets: A subset of joints with fixed tendons 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. + name_keys: A regular expression or a list of regular expressions to match the + joint names with fixed tendons. + tendon_subsets: A subset of joints with fixed tendons 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 tendon indices and names. """ if tendon_subsets is None: + # tendons follow the joint names they are attached to tendon_subsets = self.fixed_tendon_names + # find tendons return resolve_matching_names(name_keys, tendon_subsets, preserve_order) def find_spatial_tendons( @@ -246,19 +340,21 @@ def find_spatial_tendons( ) -> tuple[list[int], list[str]]: """Find spatial tendons in the articulation based on the name keys. + Please see the :func:`isaaclab.utils.string.resolve_matching_names` function for more information + on the name matching. + Args: - name_keys: A regular expression or a list of regular expressions - to match the tendon names. - tendon_subsets: A subset of tendons to search for. Defaults to - None, which means all tendons in the articulation are searched. - preserve_order: Whether to preserve the order of the name keys in - the output. Defaults to False. + name_keys: A regular expression or a list of regular expressions to match the tendon names. + tendon_subsets: A subset of tendons to search for. Defaults to None, which means all tendons + 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 tendon indices and names. """ if tendon_subsets is None: tendon_subsets = self.spatial_tendon_names + # find tendons return resolve_matching_names(name_keys, tendon_subsets, preserve_order) """ @@ -269,19 +365,25 @@ def write_root_pose_to_sim_index( self, *, root_pose: torch.Tensor | wp.array, - env_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Set the root pose over selected environment indices into the simulation. The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + Args: - root_pose: Root poses in simulation frame. Shape is (len(env_ids),) with dtype wp.transformf. + root_pose: Root poses in simulation frame. Shape is (len(env_ids), 7) + or (len(env_ids),) with dtype wp.transformf. env_ids: Environment indices. If None, then all indices are used. """ - n = self._n_envs_index(env_ids) - self.assert_shape_and_dtype(root_pose, (n,), wp.transformf, "root_pose") - self._write_root_state(TT.ROOT_POSE, root_pose, env_ids) + self.write_root_link_pose_to_sim_index(root_pose=root_pose, env_ids=env_ids) def write_root_pose_to_sim_mask( self, @@ -289,34 +391,61 @@ def write_root_pose_to_sim_mask( root_pose: torch.Tensor | wp.array, env_mask: wp.array | None = None, ) -> None: - """Set the root pose over masked environments into the simulation. + """Set the root pose over selected environment mask into the simulation. - The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. Args: - root_pose: Root poses in simulation frame. Shape is (num_instances,) with dtype wp.transformf. - env_mask: Environment mask. If None, then all instances are updated. + root_pose: Root poses in simulation frame. Shape is (num_instances, 7) + or (num_instances,) with dtype wp.transformf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ - self.assert_shape_and_dtype(root_pose, (self._num_instances,), wp.transformf, "root_pose") - self._write_root_state(TT.ROOT_POSE, root_pose, mask=env_mask) + self.write_root_link_pose_to_sim_mask(root_pose=root_pose, env_mask=env_mask) def write_root_link_pose_to_sim_index( self, *, root_pose: torch.Tensor | wp.array, - env_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Set the root link pose over selected environment indices into the simulation. The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + Args: - root_pose: Root link poses in simulation frame. Shape is (len(env_ids),) with dtype wp.transformf. + root_pose: Root link poses in simulation frame. Shape is (len(env_ids), 7) + or (len(env_ids),) with dtype wp.transformf. env_ids: Environment indices. If None, then all indices are used. """ - n = self._n_envs_index(env_ids) - self.assert_shape_and_dtype(root_pose, (n,), wp.transformf, "root_pose") - self._write_root_state(TT.ROOT_POSE, root_pose, env_ids) + env_ids = self._resolve_env_ids(env_ids) + self.assert_shape_and_dtype(root_pose, (env_ids.shape[0],), wp.transformf, "root_pose") + wp.launch( + shared_kernels.set_root_link_pose_to_sim_index, + dim=env_ids.shape[0], + inputs=[root_pose, env_ids], + outputs=[self.data.root_link_pose_w], + device=self._device, + ) + # invalidate dependent timestamps: root link pose changes the body + # kinematics chain, so all body-pose buffers go stale + self.data._root_com_pose_w.timestamp = -1.0 + self.data._body_link_pose_w.timestamp = -1.0 + self.data._body_com_pose_w.timestamp = -1.0 + # push cache to the simulation via an indexed write + binding = self._get_binding(TT.ROOT_POSE) + binding.write(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) def write_root_link_pose_to_sim_mask( self, @@ -324,36 +453,76 @@ def write_root_link_pose_to_sim_mask( root_pose: torch.Tensor | wp.array, env_mask: wp.array | None = None, ) -> None: - """Set the root link pose over masked environments into the simulation. + """Set the root link pose over selected environment mask into the simulation. The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + Args: - root_pose: Root link poses in simulation frame. Shape is (num_instances,) with dtype wp.transformf. - env_mask: Environment mask. If None, then all instances are updated. + root_pose: Root poses in simulation frame. Shape is (num_instances, 7) + or (num_instances,) with dtype wp.transformf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ + env_mask_wp = self._resolve_env_mask(env_mask) self.assert_shape_and_dtype(root_pose, (self._num_instances,), wp.transformf, "root_pose") - self._write_root_state(TT.ROOT_POSE, root_pose, mask=env_mask) + wp.launch( + shared_kernels.set_root_link_pose_to_sim_mask, + dim=self._num_instances, + inputs=[root_pose, env_mask_wp], + outputs=[self.data.root_link_pose_w], + device=self._device, + ) + # invalidate dependent timestamps (see :meth:`write_root_link_pose_to_sim_index`) + self.data._root_com_pose_w.timestamp = -1.0 + self.data._body_link_pose_w.timestamp = -1.0 + self.data._body_com_pose_w.timestamp = -1.0 + binding = self._get_binding(TT.ROOT_POSE) + binding.write(self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) def write_root_com_pose_to_sim_index( self, *, root_pose: torch.Tensor | wp.array, - env_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Set the root center of mass pose over selected environment indices into the simulation. The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). The orientation is the orientation of the principal axes of inertia. + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + Args: - root_pose: Root center of mass poses in simulation frame. Shape is (len(env_ids),) - with dtype wp.transformf. + root_pose: Root center of mass poses in simulation frame. Shape is (len(env_ids), 7) + or (len(env_ids),) with dtype wp.transformf. env_ids: Environment indices. If None, then all indices are used. """ - n = self._n_envs_index(env_ids) - self.assert_shape_and_dtype(root_pose, (n,), wp.transformf, "root_pose") - self._write_root_state(TT.ROOT_POSE, root_pose, env_ids) + env_ids = self._resolve_env_ids(env_ids) + self.assert_shape_and_dtype(root_pose, (env_ids.shape[0],), wp.transformf, "root_pose") + wp.launch( + shared_kernels.set_root_com_pose_to_sim_index, + dim=env_ids.shape[0], + inputs=[root_pose, self.data.body_com_pose_b, env_ids], + outputs=[self.data.root_com_pose_w, self.data.root_link_pose_w], + device=self._device, + ) + # writing the root CoM pose updates the inferred root link pose, which + # in turn invalidates the body kinematics chain + self.data._body_link_pose_w.timestamp = -1.0 + self.data._body_com_pose_w.timestamp = -1.0 + binding = self._get_binding(TT.ROOT_POSE) + binding.write(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) def write_root_com_pose_to_sim_mask( self, @@ -361,37 +530,64 @@ def write_root_com_pose_to_sim_mask( root_pose: torch.Tensor | wp.array, env_mask: wp.array | None = None, ) -> None: - """Set the root center of mass pose over masked environments into the simulation. + """Set the root center of mass pose over selected environment mask into the simulation. The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). The orientation is the orientation of the principal axes of inertia. + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + Args: - root_pose: Root center of mass poses in simulation frame. Shape is (num_instances,) - with dtype wp.transformf. - env_mask: Environment mask. If None, then all instances are updated. + root_pose: Root center of mass poses in simulation frame. Shape is (num_instances, 7) + or (num_instances,) with dtype wp.transformf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ + env_mask_wp = self._resolve_env_mask(env_mask) self.assert_shape_and_dtype(root_pose, (self._num_instances,), wp.transformf, "root_pose") - self._write_root_state(TT.ROOT_POSE, root_pose, mask=env_mask) + wp.launch( + shared_kernels.set_root_com_pose_to_sim_mask, + dim=self._num_instances, + inputs=[root_pose, self.data.body_com_pose_b, env_mask_wp], + outputs=[self.data.root_com_pose_w, self.data.root_link_pose_w], + device=self._device, + ) + # invalidate dependent timestamps (see :meth:`write_root_com_pose_to_sim_index`) + self.data._body_link_pose_w.timestamp = -1.0 + self.data._body_com_pose_w.timestamp = -1.0 + binding = self._get_binding(TT.ROOT_POSE) + binding.write(self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) def write_root_velocity_to_sim_index( self, *, root_velocity: torch.Tensor | wp.array, - env_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set the root velocity over selected environment indices into the simulation. + """Set the root center of mass velocity over selected environment indices into the simulation. The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + .. note:: + This sets the velocity of the root's center of mass rather than the root's frame. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + Args: - root_velocity: Root velocities in simulation world frame. Shape is (len(env_ids),) - with dtype wp.spatial_vectorf. + root_velocity: Root center of mass velocities in simulation world frame. Shape is (len(env_ids), 6) + or (len(env_ids),) with dtype wp.spatial_vectorf. env_ids: Environment indices. If None, then all indices are used. """ - n = self._n_envs_index(env_ids) - self.assert_shape_and_dtype(root_velocity, (n,), wp.spatial_vectorf, "root_velocity") - self._write_root_state(TT.ROOT_VELOCITY, root_velocity, env_ids) + self.write_root_com_velocity_to_sim_index(root_velocity=root_velocity, env_ids=env_ids) def write_root_velocity_to_sim_mask( self, @@ -399,36 +595,60 @@ def write_root_velocity_to_sim_mask( root_velocity: torch.Tensor | wp.array, env_mask: wp.array | None = None, ) -> None: - """Set the root velocity over masked environments into the simulation. + """Set the root center of mass velocity over selected environment mask into the simulation. - The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. Args: - root_velocity: Root velocities in simulation world frame. Shape is (num_instances,) - with dtype wp.spatial_vectorf. - env_mask: Environment mask. If None, then all instances are updated. + root_velocity: Root center of mass velocities in simulation world frame. Shape is (num_instances, 6) + or (num_instances,) with dtype wp.spatial_vectorf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ - self.assert_shape_and_dtype(root_velocity, (self._num_instances,), wp.spatial_vectorf, "root_velocity") - self._write_root_state(TT.ROOT_VELOCITY, root_velocity, mask=env_mask) + self.write_root_com_velocity_to_sim_mask(root_velocity=root_velocity, env_mask=env_mask) def write_root_com_velocity_to_sim_index( self, *, root_velocity: torch.Tensor | wp.array, - env_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Set the root center of mass velocity over selected environment indices into the simulation. The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + .. note:: + This sets the velocity of the root's center of mass rather than the root's frame. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + Args: - root_velocity: Root center of mass velocities in simulation world frame. - Shape is (len(env_ids),) with dtype wp.spatial_vectorf. + root_velocity: Root center of mass velocities in simulation world frame. Shape is (len(env_ids), 6) + or (len(env_ids),) with dtype wp.spatial_vectorf. env_ids: Environment indices. If None, then all indices are used. """ - n = self._n_envs_index(env_ids) - self.assert_shape_and_dtype(root_velocity, (n,), wp.spatial_vectorf, "root_velocity") - self._write_root_state(TT.ROOT_VELOCITY, root_velocity, env_ids) + env_ids = self._resolve_env_ids(env_ids) + self.assert_shape_and_dtype(root_velocity, (env_ids.shape[0],), wp.spatial_vectorf, "root_velocity") + wp.launch( + shared_kernels.set_root_com_velocity_to_sim_index, + dim=env_ids.shape[0], + inputs=[root_velocity, env_ids, self._num_bodies], + outputs=[self.data.root_com_vel_w, self.data.body_com_acc_w], + device=self._device, + ) + # Invalidate dependent root_link_vel timestamp. + self.data._root_link_vel_w.timestamp = -1.0 + binding = self._get_binding(TT.ROOT_VELOCITY) + binding.write(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) def write_root_com_velocity_to_sim_mask( self, @@ -436,23 +656,43 @@ def write_root_com_velocity_to_sim_mask( root_velocity: torch.Tensor | wp.array, env_mask: wp.array | None = None, ) -> None: - """Set the root center of mass velocity over masked environments into the simulation. + """Set the root center of mass velocity over selected environment mask into the simulation. The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + .. note:: + This sets the velocity of the root's center of mass rather than the root's frame. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + Args: - root_velocity: Root center of mass velocities in simulation world frame. - Shape is (num_instances,) with dtype wp.spatial_vectorf. - env_mask: Environment mask. If None, then all instances are updated. + root_velocity: Root center of mass velocities in simulation world frame. Shape is (num_instances, 6) + or (num_instances,) with dtype wp.spatial_vectorf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ + env_mask_wp = self._resolve_env_mask(env_mask) self.assert_shape_and_dtype(root_velocity, (self._num_instances,), wp.spatial_vectorf, "root_velocity") - self._write_root_state(TT.ROOT_VELOCITY, root_velocity, mask=env_mask) + wp.launch( + shared_kernels.set_root_com_velocity_to_sim_mask, + dim=self._num_instances, + inputs=[root_velocity, env_mask_wp, self._num_bodies], + outputs=[self.data.root_com_vel_w, self.data.body_com_acc_w], + device=self._device, + ) + self.data._root_link_vel_w.timestamp = -1.0 + binding = self._get_binding(TT.ROOT_VELOCITY) + binding.write(self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp) def write_root_link_velocity_to_sim_index( self, *, root_velocity: torch.Tensor | wp.array, - env_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Set the root link velocity over selected environment indices into the simulation. @@ -461,14 +701,35 @@ def write_root_link_velocity_to_sim_index( .. note:: This sets the velocity of the root's frame rather than the root's center of mass. + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. + Args: - root_velocity: Root frame velocities in simulation world frame. - Shape is (len(env_ids),) with dtype wp.spatial_vectorf. + root_velocity: Root frame velocities in simulation world frame. Shape is (len(env_ids), 6) + or (len(env_ids),) with dtype wp.spatial_vectorf. env_ids: Environment indices. If None, then all indices are used. """ - n = self._n_envs_index(env_ids) - self.assert_shape_and_dtype(root_velocity, (n,), wp.spatial_vectorf, "root_velocity") - self._write_root_state(TT.ROOT_VELOCITY, root_velocity, env_ids) + env_ids = self._resolve_env_ids(env_ids) + self.assert_shape_and_dtype(root_velocity, (env_ids.shape[0],), wp.spatial_vectorf, "root_velocity") + wp.launch( + shared_kernels.set_root_link_velocity_to_sim_index, + dim=env_ids.shape[0], + inputs=[ + root_velocity, + self.data.body_com_pose_b, + self.data.root_link_pose_w, + env_ids, + self._num_bodies, + ], + outputs=[self.data.root_link_vel_w, self.data.root_com_vel_w, self.data.body_com_acc_w], + device=self._device, + ) + binding = self._get_binding(TT.ROOT_VELOCITY) + binding.write(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) def write_root_link_velocity_to_sim_mask( self, @@ -476,118 +737,255 @@ def write_root_link_velocity_to_sim_mask( root_velocity: torch.Tensor | wp.array, env_mask: wp.array | None = None, ) -> None: - """Set the root link velocity over masked environments into the simulation. + """Set the root link velocity over selected environment mask into the simulation. The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. .. note:: This sets the velocity of the root's frame rather than the root's center of mass. - Args: - root_velocity: Root frame velocities in simulation world frame. - Shape is (num_instances,) with dtype wp.spatial_vectorf. - env_mask: Environment mask. If None, then all instances are updated. - """ - self.assert_shape_and_dtype(root_velocity, (self._num_instances,), wp.spatial_vectorf, "root_velocity") - self._write_root_state(TT.ROOT_VELOCITY, root_velocity, mask=env_mask) - - """ - Operations - Joint State Writers. - """ + .. note:: + This method expects full data. - def write_joint_state_to_sim_mask( - self, - joint_pos: torch.Tensor | wp.array, - joint_vel: torch.Tensor | wp.array, - env_mask: wp.array | None = None, - joint_mask: wp.array | None = None, - ) -> None: - """Write joint positions and velocities over masked environments into the simulation. + .. tip:: + Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. + However, to allow graphed pipelines, the mask method must be used. Args: - joint_pos: Joint positions. Shape is (num_instances, num_joints). - joint_vel: Joint velocities. Shape is (num_instances, num_joints). - env_mask: Environment mask. If None, then all instances are updated. - joint_mask: Joint mask. If None, then all joints are updated. + root_velocity: Root frame velocities in simulation world frame. Shape is (num_instances, 6) + or (num_instances,) with dtype wp.spatial_vectorf. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ - self.write_joint_position_to_sim_mask(position=joint_pos, env_mask=env_mask, joint_mask=joint_mask) - self.write_joint_velocity_to_sim_mask(velocity=joint_vel, env_mask=env_mask, joint_mask=joint_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + self.assert_shape_and_dtype(root_velocity, (self._num_instances,), wp.spatial_vectorf, "root_velocity") + wp.launch( + shared_kernels.set_root_link_velocity_to_sim_mask, + dim=self._num_instances, + inputs=[ + root_velocity, + self.data.body_com_pose_b, + self.data.root_link_pose_w, + env_mask_wp, + self._num_bodies, + ], + outputs=[self.data.root_link_vel_w, self.data.root_com_vel_w, self.data.body_com_acc_w], + device=self._device, + ) + binding = self._get_binding(TT.ROOT_VELOCITY) + binding.write(self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp) def write_joint_position_to_sim_index( self, *, position: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Write joint positions over selected environment and joint indices into the simulation. + """Set joint positions over selected env / joint indices into the simulation. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - position: Joint positions. Shape is (len(env_ids), len(joint_ids)). - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. + position: Joint positions [m or rad, depending on joint type]. Shape is + (len(env_ids), len(joint_ids)) with dtype wp.float32. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - d = len(joint_ids) if joint_ids is not None else self._num_joints - self.assert_shape_and_dtype(position, (n, d), wp.float32, "position") - self._write_flat_tensor(TT.DOF_POSITION, position, env_ids, joint_ids) - self.data._joint_pos_buf.timestamp = -1.0 + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + self.assert_shape_and_dtype(position, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "position") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[position, env_ids, joint_ids], + outputs=[self._data._joint_pos_buf.data], + device=self._device, + ) + # invalidate body-state buffers so the next read re-fetches FK from the + # wheel using the new joint positions + self._data._body_com_vel_w.timestamp = -1.0 + self._data._body_link_vel_w.timestamp = -1.0 + self._data._body_com_pose_b.timestamp = -1.0 + self._data._body_com_pose_w.timestamp = -1.0 + self._data._body_link_pose_w.timestamp = -1.0 + self._data._joint_acc.timestamp = -1.0 + binding = self._get_binding(TT.DOF_POSITION) + binding.write(self._data._joint_pos_buf.data, indices=env_ids) def write_joint_position_to_sim_mask( self, *, position: torch.Tensor | wp.array, - joint_mask: wp.array | None = None, env_mask: wp.array | None = None, + joint_mask: wp.array | None = None, ) -> None: - """Write joint positions over masked environments into the simulation. + """Set joint positions over selected env / joint masks into the simulation. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - position: Joint positions. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. + position: Joint positions [m or rad, depending on joint type]. Shape is + (num_instances, num_joints) with dtype wp.float32. + env_mask: Environment mask. If None, all instances are updated. Shape is + (num_instances,). + joint_mask: Joint mask. If None, all joints are updated. Shape is + (num_joints,). """ + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) self.assert_shape_and_dtype(position, (self._num_instances, self._num_joints), wp.float32, "position") - self._write_flat_tensor_mask(TT.DOF_POSITION, position, env_mask, joint_mask) - self.data._joint_pos_buf.timestamp = -1.0 + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=(self._num_instances, self._num_joints), + inputs=[position, env_mask_wp, joint_mask_wp], + outputs=[self._data._joint_pos_buf.data], + device=self._device, + ) + # invalidate body-state buffers (see :meth:`write_joint_position_to_sim_index`) + self._data._body_com_vel_w.timestamp = -1.0 + self._data._body_link_vel_w.timestamp = -1.0 + self._data._body_com_pose_b.timestamp = -1.0 + self._data._body_com_pose_w.timestamp = -1.0 + self._data._body_link_pose_w.timestamp = -1.0 + self._data._joint_acc.timestamp = -1.0 + binding = self._get_binding(TT.DOF_POSITION) + binding.write(self._data._joint_pos_buf.data, mask=env_mask_wp) def write_joint_velocity_to_sim_index( self, *, velocity: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Write joint velocities over selected environment and joint indices into the simulation. + """Set joint velocities over selected env / joint indices into the simulation. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - velocity: Joint velocities. Shape is (len(env_ids), len(joint_ids)). - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. + velocity: Joint velocities [m/s or rad/s, depending on joint type]. Shape is + (len(env_ids), len(joint_ids)) with dtype wp.float32. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - d = len(joint_ids) if joint_ids is not None else self._num_joints - self.assert_shape_and_dtype(velocity, (n, d), wp.float32, "velocity") - self._write_flat_tensor(TT.DOF_VELOCITY, velocity, env_ids, joint_ids) - self.data._joint_vel_buf.timestamp = -1.0 + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + self.assert_shape_and_dtype(velocity, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "velocity") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[velocity, env_ids, joint_ids], + outputs=[self._data._joint_vel_buf.data], + device=self._device, + ) + # Sync previous_joint_vel to the new values so the next FD step does not + # produce a spurious acceleration spike. + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[velocity, env_ids, joint_ids], + outputs=[self._data._previous_joint_vel], + device=self._device, + ) + self._data._joint_acc.timestamp = -1.0 + binding = self._get_binding(TT.DOF_VELOCITY) + binding.write(self._data._joint_vel_buf.data, indices=env_ids) def write_joint_velocity_to_sim_mask( self, *, velocity: torch.Tensor | wp.array, - joint_mask: wp.array | None = None, env_mask: wp.array | None = None, + joint_mask: wp.array | None = None, ) -> None: - """Write joint velocities over masked environments into the simulation. + """Set joint velocities over selected env / joint masks into the simulation. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - velocity: Joint velocities. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. + velocity: Joint velocities [m/s or rad/s, depending on joint type]. Shape is + (num_instances, num_joints) with dtype wp.float32. + env_mask: Environment mask. If None, all instances are updated. Shape is + (num_instances,). + joint_mask: Joint mask. If None, all joints are updated. Shape is + (num_joints,). """ + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) self.assert_shape_and_dtype(velocity, (self._num_instances, self._num_joints), wp.float32, "velocity") - self._write_flat_tensor_mask(TT.DOF_VELOCITY, velocity, env_mask, joint_mask) - self.data._joint_vel_buf.timestamp = -1.0 + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=(self._num_instances, self._num_joints), + inputs=[velocity, env_mask_wp, joint_mask_wp], + outputs=[self._data._joint_vel_buf.data], + device=self._device, + ) + # Sync previous_joint_vel so the next FD step does not produce a spurious spike. + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=(self._num_instances, self._num_joints), + inputs=[velocity, env_mask_wp, joint_mask_wp], + outputs=[self._data._previous_joint_vel], + device=self._device, + ) + self._data._joint_acc.timestamp = -1.0 + binding = self._get_binding(TT.DOF_VELOCITY) + binding.write(self._data._joint_vel_buf.data, mask=env_mask_wp) + + def write_joint_state_to_sim_mask( + self, + *, + position: torch.Tensor | wp.array, + velocity: torch.Tensor | wp.array, + joint_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Write joint positions and velocities over selected environment mask into the simulation. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. + + Args: + position: Joint positions [m or rad, depending on joint type]. Shape is + (num_instances, num_joints) with dtype wp.float32. + velocity: Joint velocities [m/s or rad/s, depending on joint type]. Shape is + (num_instances, num_joints) with dtype wp.float32. + joint_mask: Joint mask. If None, all joints are updated. Shape is + (num_joints,). + env_mask: Environment mask. If None, all instances are updated. Shape is + (num_instances,). + """ + self.write_joint_position_to_sim_mask(position=position, env_mask=env_mask, joint_mask=joint_mask) + self.write_joint_velocity_to_sim_mask(velocity=velocity, env_mask=env_mask, joint_mask=joint_mask) """ Operations - Simulation Parameters Writers. @@ -596,99 +994,278 @@ def write_joint_velocity_to_sim_mask( def write_joint_stiffness_to_sim_index( self, *, - stiffness: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + stiffness: float | torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Write joint stiffness over selected indices into the simulation. + """Set joint stiffness over selected env / joint indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``DOF_STIFFNESS`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - stiffness: Joint stiffness. Shape is (len(env_ids), len(joint_ids)). - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. + stiffness: Joint stiffness [N/m or N·m/rad, depending on joint type]. + May be a scalar :class:`float` (broadcast), or shape + (len(env_ids), len(joint_ids)) with dtype wp.float32. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - d = len(joint_ids) if joint_ids is not None else self._num_joints - self.assert_shape_and_dtype(stiffness, (n, d), wp.float32, "stiffness") - self._write_flat_tensor(TT.DOF_STIFFNESS, stiffness, env_ids, joint_ids) + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + shape = (env_ids.shape[0], joint_ids.shape[0]) + stiffness = self._broadcast_scalar_to_2d(stiffness, shape) + self.assert_shape_and_dtype(stiffness, shape, wp.float32, "stiffness") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=shape, + inputs=[stiffness, env_ids, joint_ids], + outputs=[self._data._joint_stiffness.data], + device=self._device, + ) + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self.data._cpu_joint_stiffness, self._data._joint_stiffness.data) + binding = self._get_binding(TT.DOF_STIFFNESS) + binding.write(self.data._cpu_joint_stiffness, indices=cpu_env_ids) def write_joint_stiffness_to_sim_mask( self, *, - stiffness: torch.Tensor | wp.array, + stiffness: float | torch.Tensor | wp.array, joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Write joint stiffness over masked environments into the simulation. + """Set joint stiffness over selected env / joint masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``DOF_STIFFNESS`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - stiffness: Joint stiffness. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. + stiffness: Joint stiffness [N/m or N·m/rad, depending on joint type]. + May be a scalar :class:`float` (broadcast), or shape + (num_instances, num_joints) with dtype wp.float32. + joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(stiffness, (self._num_instances, self._num_joints), wp.float32, "stiffness") - self._write_flat_tensor_mask(TT.DOF_STIFFNESS, stiffness, env_mask, joint_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + shape = (self._num_instances, self._num_joints) + stiffness = self._broadcast_scalar_to_2d(stiffness, shape) + self.assert_shape_and_dtype(stiffness, shape, wp.float32, "stiffness") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[stiffness, env_mask_wp, joint_mask_wp], + outputs=[self._data._joint_stiffness.data], + device=self._device, + ) + wp.copy(self.data._cpu_joint_stiffness, self._data._joint_stiffness.data) + binding = self._get_binding(TT.DOF_STIFFNESS) + binding.write(self.data._cpu_joint_stiffness, mask=self._get_cpu_env_mask(env_mask_wp)) def write_joint_damping_to_sim_index( self, *, - damping: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + damping: float | torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Write joint damping over selected indices into the simulation. + """Set joint damping over selected env / joint indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``DOF_DAMPING`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - damping: Joint damping. Shape is (len(env_ids), len(joint_ids)). - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. + damping: Joint damping [N·s/m or N·m·s/rad, depending on joint type]. + May be a scalar :class:`float` (broadcast), or shape + (len(env_ids), len(joint_ids)) with dtype wp.float32. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - d = len(joint_ids) if joint_ids is not None else self._num_joints - self.assert_shape_and_dtype(damping, (n, d), wp.float32, "damping") - self._write_flat_tensor(TT.DOF_DAMPING, damping, env_ids, joint_ids) + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + shape = (env_ids.shape[0], joint_ids.shape[0]) + damping = self._broadcast_scalar_to_2d(damping, shape) + self.assert_shape_and_dtype(damping, shape, wp.float32, "damping") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=shape, + inputs=[damping, env_ids, joint_ids], + outputs=[self._data._joint_damping.data], + device=self._device, + ) + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self.data._cpu_joint_damping, self._data._joint_damping.data) + binding = self._get_binding(TT.DOF_DAMPING) + binding.write(self.data._cpu_joint_damping, indices=cpu_env_ids) def write_joint_damping_to_sim_mask( self, *, - damping: torch.Tensor | wp.array, + damping: float | torch.Tensor | wp.array, joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Write joint damping over masked environments into the simulation. + """Set joint damping over selected env / joint masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``DOF_DAMPING`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - damping: Joint damping. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. + damping: Joint damping [N·s/m or N·m·s/rad, depending on joint type]. + May be a scalar :class:`float` (broadcast), or shape + (num_instances, num_joints) with dtype wp.float32. + joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(damping, (self._num_instances, self._num_joints), wp.float32, "damping") - self._write_flat_tensor_mask(TT.DOF_DAMPING, damping, env_mask, joint_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + shape = (self._num_instances, self._num_joints) + damping = self._broadcast_scalar_to_2d(damping, shape) + self.assert_shape_and_dtype(damping, shape, wp.float32, "damping") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[damping, env_mask_wp, joint_mask_wp], + outputs=[self._data._joint_damping.data], + device=self._device, + ) + wp.copy(self.data._cpu_joint_damping, self._data._joint_damping.data) + binding = self._get_binding(TT.DOF_DAMPING) + binding.write(self.data._cpu_joint_damping, mask=self._get_cpu_env_mask(env_mask_wp)) def write_joint_position_limit_to_sim_index( self, *, limits: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, warn_limit_violation: bool = True, ) -> None: - """Write joint position limits over selected environment indices into the simulation. + """Set joint position limits over selected env / joint indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``DOF_LIMIT`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - limits: Joint position limits [rad or m]. Shape is (len(env_ids), len(joint_ids)) - with dtype wp.vec2f. - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. - warn_limit_violation: Whether to use warning or info level logging when default joint - positions exceed the new limits. Defaults to True. + limits: Joint position limits ``[lower, upper]`` + [m or rad, depending on joint type]. Either shape + (len(env_ids), len(joint_ids), 2) with dtype wp.float32, or + shape (len(env_ids), len(joint_ids)) with dtype wp.vec2f. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). + warn_limit_violation: If True, log a warning when the provided limits + are inconsistent (lower > upper). Defaults to True. """ - if isinstance(limits, (int, float)): - raise ValueError("Float scalars are not supported for position limits (vec2f dtype)") - n = self._n_envs_index(env_ids) - d = len(joint_ids) if joint_ids is not None else self._num_joints - self.assert_shape_and_dtype(limits, (n, d), wp.vec2f, "limits") - self._write_flat_tensor(TT.DOF_LIMIT, limits, env_ids, joint_ids) + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + # Position limits cannot be scalar-broadcast (they pair lower/upper); + # match PhysX which explicitly rejects floats here. + if isinstance(limits, float): + raise ValueError("Joint position limits must be a tensor or array, not a float.") + # Accept both wp.vec2f shape (N, J) and the legacy (N, J, 2) wp.float32 + # form (canonical PhysX/Newton layout uses vec2f). + if isinstance(limits, wp.array) and limits.dtype == wp.vec2f: + self.assert_shape_and_dtype(limits, (env_ids.shape[0], joint_ids.shape[0]), wp.vec2f, "limits") + # Reinterpret the vec2f input as a (N, J, 2) float32 view for the kernel. + kernel_limits = wp.array( + ptr=limits.ptr, + shape=(env_ids.shape[0], joint_ids.shape[0], 2), + dtype=wp.float32, + device=str(limits.device), + copy=False, + ) + else: + self.assert_shape_and_dtype(limits, (env_ids.shape[0], joint_ids.shape[0], 2), wp.float32, "limits") + kernel_limits = limits + # Scatter [lower, upper] pairs into the vec2f cache buffer. + wp.launch( + shared_kernels.write_joint_position_limit_to_buffer_index, + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[kernel_limits, env_ids, joint_ids], + outputs=[self._data._joint_pos_limits.data], + device=self._device, + ) + # Clamp default_joint_pos to the new limits and refresh soft_joint_pos_limits. + clamped_count = wp.zeros(1, dtype=wp.int32, device=self._device) + wp.launch( + clamp_default_joint_pos_and_update_soft_limits_index, + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[ + self._data._joint_pos_limits.data, + env_ids, + joint_ids, + self.cfg.soft_joint_pos_limit_factor, + ], + outputs=[ + self._data._default_joint_pos, + self._data._soft_joint_pos_limits, + clamped_count, + ], + device=self._device, + ) + if clamped_count.numpy()[0] > 0: + violation_message = ( + "Some default joint positions are outside of the range of the new joint limits. Default joint" + " positions will be clamped to be within the new joint limits." + ) + if warn_limit_violation: + logger.warning(violation_message) + else: + logger.info(violation_message) + # Stage to pinned-host CPU: flatten the vec2f buffer to float32 view. + cpu_env_ids = self._get_cpu_env_ids(env_ids) + flat_src = wp.array( + ptr=self._data._joint_pos_limits.data.ptr, + shape=(self._num_instances, self._num_joints, 2), + dtype=wp.float32, + device=self._device, + copy=False, + ) + wp.copy(self.data._cpu_joint_position_limit, flat_src) + binding = self._get_binding(TT.DOF_LIMIT) + binding.write(self.data._cpu_joint_position_limit, indices=cpu_env_ids) def write_joint_position_limit_to_sim_mask( self, @@ -698,180 +1275,700 @@ def write_joint_position_limit_to_sim_mask( env_mask: wp.array | None = None, warn_limit_violation: bool = True, ) -> None: - """Write joint position limits over masked environments into the simulation. + """Set joint position limits over selected env / joint masks into the simulation. - Args: - limits: Joint position limits [rad or m]. Shape is (num_instances, num_joints) - with dtype wp.vec2f. - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. - warn_limit_violation: Whether to use warning or info level logging when default joint - positions exceed the new limits. Defaults to True. - """ - if isinstance(limits, (int, float)): - raise ValueError("Float scalars are not supported for position limits (vec2f dtype)") - self.assert_shape_and_dtype(limits, (self._num_instances, self._num_joints), wp.vec2f, "limits") - self._write_flat_tensor_mask(TT.DOF_LIMIT, limits, env_mask, joint_mask) + This is a CPU-only write routed through pinned-host staging because + ``DOF_LIMIT`` is a CPU-only OVPhysX binding. - def write_joint_velocity_limit_to_sim_index( - self, - *, - limits: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, - ) -> None: - """Write joint max velocity over selected environment indices into the simulation. + .. note:: + This method expects full data. - The velocity limit is used to constrain the joint velocities in the physics engine. + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - limits: Joint max velocity [rad/s or m/s]. Shape is (len(env_ids), len(joint_ids)). - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. + limits: Joint position limits ``[lower, upper]`` + [m or rad, depending on joint type]. Either shape + (num_instances, num_joints, 2) with dtype wp.float32, or shape + (num_instances, num_joints) with dtype wp.vec2f. + joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). + warn_limit_violation: If True, log a warning when the provided limits + are inconsistent (lower > upper). Defaults to True. """ - n = self._n_envs_index(env_ids) - d = len(joint_ids) if joint_ids is not None else self._num_joints - self.assert_shape_and_dtype(limits, (n, d), wp.float32, "limits") - self._write_flat_tensor(TT.DOF_MAX_VELOCITY, limits, env_ids, joint_ids) - - def write_joint_velocity_limit_to_sim_mask( - self, - *, - limits: torch.Tensor | wp.array, - joint_mask: wp.array | None = None, - env_mask: wp.array | None = None, + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + # Position limits cannot be scalar-broadcast (they pair lower/upper); + # match PhysX which explicitly rejects floats here. + if isinstance(limits, float): + raise ValueError("Joint position limits must be a tensor or array, not a float.") + # Accept both wp.vec2f shape (N, J) and the legacy (N, J, 2) wp.float32 + # form (canonical PhysX/Newton layout uses vec2f). + if isinstance(limits, wp.array) and limits.dtype == wp.vec2f: + self.assert_shape_and_dtype(limits, (self._num_instances, self._num_joints), wp.vec2f, "limits") + kernel_limits = wp.array( + ptr=limits.ptr, + shape=(self._num_instances, self._num_joints, 2), + dtype=wp.float32, + device=str(limits.device), + copy=False, + ) + else: + self.assert_shape_and_dtype(limits, (self._num_instances, self._num_joints, 2), wp.float32, "limits") + kernel_limits = limits + wp.launch( + shared_kernels.write_joint_position_limit_to_buffer_mask, + dim=(self._num_instances, self._num_joints), + inputs=[kernel_limits, env_mask_wp, joint_mask_wp], + outputs=[self._data._joint_pos_limits.data], + device=self._device, + ) + # Clamp default_joint_pos to the new limits and refresh soft_joint_pos_limits. + clamped_count = wp.zeros(1, dtype=wp.int32, device=self._device) + wp.launch( + clamp_default_joint_pos_and_update_soft_limits_mask, + dim=(self._num_instances, self._num_joints), + inputs=[ + self._data._joint_pos_limits.data, + env_mask_wp, + joint_mask_wp, + self.cfg.soft_joint_pos_limit_factor, + ], + outputs=[ + self._data._default_joint_pos, + self._data._soft_joint_pos_limits, + clamped_count, + ], + device=self._device, + ) + if clamped_count.numpy()[0] > 0: + violation_message = ( + "Some default joint positions are outside of the range of the new joint limits. Default joint" + " positions will be clamped to be within the new joint limits." + ) + if warn_limit_violation: + logger.warning(violation_message) + else: + logger.info(violation_message) + flat_src = wp.array( + ptr=self._data._joint_pos_limits.data.ptr, + shape=(self._num_instances, self._num_joints, 2), + dtype=wp.float32, + device=self._device, + copy=False, + ) + wp.copy(self.data._cpu_joint_position_limit, flat_src) + binding = self._get_binding(TT.DOF_LIMIT) + binding.write(self.data._cpu_joint_position_limit, mask=self._get_cpu_env_mask(env_mask_wp)) + + def write_joint_velocity_limit_to_sim_index( + self, + *, + limits: float | torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Set joint velocity limits over selected env / joint indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``DOF_MAX_VELOCITY`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. + + Args: + limits: Joint velocity limits [m/s or rad/s, depending on joint type]. + May be a scalar :class:`float` (broadcast), or shape + (len(env_ids), len(joint_ids)) with dtype wp.float32. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). + """ + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + shape = (env_ids.shape[0], joint_ids.shape[0]) + limits = self._broadcast_scalar_to_2d(limits, shape) + self.assert_shape_and_dtype(limits, shape, wp.float32, "limits") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=shape, + inputs=[limits, env_ids, joint_ids], + outputs=[self._data._joint_vel_limits.data], + device=self._device, + ) + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self.data._cpu_joint_velocity_limit, self._data._joint_vel_limits.data) + binding = self._get_binding(TT.DOF_MAX_VELOCITY) + binding.write(self.data._cpu_joint_velocity_limit, indices=cpu_env_ids) + + def write_joint_velocity_limit_to_sim_mask( + self, + *, + limits: float | torch.Tensor | wp.array, + joint_mask: wp.array | None = None, + env_mask: wp.array | None = None, ) -> None: - """Write joint max velocity over masked environments into the simulation. + """Set joint velocity limits over selected env / joint masks into the simulation. - The velocity limit is used to constrain the joint velocities in the physics engine. + This is a CPU-only write routed through pinned-host staging because + ``DOF_MAX_VELOCITY`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - limits: Joint max velocity [rad/s or m/s]. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. + limits: Joint velocity limits [m/s or rad/s, depending on joint type]. + May be a scalar :class:`float` (broadcast), or shape + (num_instances, num_joints) with dtype wp.float32. + joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(limits, (self._num_instances, self._num_joints), wp.float32, "limits") - self._write_flat_tensor_mask(TT.DOF_MAX_VELOCITY, limits, env_mask, joint_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + shape = (self._num_instances, self._num_joints) + limits = self._broadcast_scalar_to_2d(limits, shape) + self.assert_shape_and_dtype(limits, shape, wp.float32, "limits") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[limits, env_mask_wp, joint_mask_wp], + outputs=[self._data._joint_vel_limits.data], + device=self._device, + ) + wp.copy(self.data._cpu_joint_velocity_limit, self._data._joint_vel_limits.data) + binding = self._get_binding(TT.DOF_MAX_VELOCITY) + binding.write(self.data._cpu_joint_velocity_limit, mask=self._get_cpu_env_mask(env_mask_wp)) def write_joint_effort_limit_to_sim_index( self, *, - limits: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + limits: float | torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Write joint effort limits over selected environment indices into the simulation. + """Set joint effort limits over selected env / joint indices into the simulation. - The effort limit is used to constrain the computed joint efforts in the physics engine. + This is a CPU-only write routed through pinned-host staging because + ``DOF_MAX_FORCE`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - limits: Joint effort limits [N or N*m]. Shape is (len(env_ids), len(joint_ids)). - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. + limits: Joint effort limits [N or N·m, depending on joint type]. + May be a scalar :class:`float` (broadcast), or shape + (len(env_ids), len(joint_ids)) with dtype wp.float32. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - d = len(joint_ids) if joint_ids is not None else self._num_joints - self.assert_shape_and_dtype(limits, (n, d), wp.float32, "limits") - self._write_flat_tensor(TT.DOF_MAX_FORCE, limits, env_ids, joint_ids) + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + shape = (env_ids.shape[0], joint_ids.shape[0]) + limits = self._broadcast_scalar_to_2d(limits, shape) + self.assert_shape_and_dtype(limits, shape, wp.float32, "limits") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=shape, + inputs=[limits, env_ids, joint_ids], + outputs=[self._data._joint_effort_limits.data], + device=self._device, + ) + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self.data._cpu_joint_effort_limit, self._data._joint_effort_limits.data) + binding = self._get_binding(TT.DOF_MAX_FORCE) + binding.write(self.data._cpu_joint_effort_limit, indices=cpu_env_ids) def write_joint_effort_limit_to_sim_mask( self, *, - limits: torch.Tensor | wp.array, + limits: float | torch.Tensor | wp.array, joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Write joint effort limits over masked environments into the simulation. + """Set joint effort limits over selected env / joint masks into the simulation. - The effort limit is used to constrain the computed joint efforts in the physics engine. + This is a CPU-only write routed through pinned-host staging because + ``DOF_MAX_FORCE`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - limits: Joint effort limits [N or N*m]. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. + limits: Joint effort limits [N or N·m, depending on joint type]. + May be a scalar :class:`float` (broadcast), or shape + (num_instances, num_joints) with dtype wp.float32. + joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(limits, (self._num_instances, self._num_joints), wp.float32, "limits") - self._write_flat_tensor_mask(TT.DOF_MAX_FORCE, limits, env_mask, joint_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + shape = (self._num_instances, self._num_joints) + limits = self._broadcast_scalar_to_2d(limits, shape) + self.assert_shape_and_dtype(limits, shape, wp.float32, "limits") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[limits, env_mask_wp, joint_mask_wp], + outputs=[self._data._joint_effort_limits.data], + device=self._device, + ) + wp.copy(self.data._cpu_joint_effort_limit, self._data._joint_effort_limits.data) + binding = self._get_binding(TT.DOF_MAX_FORCE) + binding.write(self.data._cpu_joint_effort_limit, mask=self._get_cpu_env_mask(env_mask_wp)) def write_joint_armature_to_sim_index( self, *, - armature: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + armature: float | torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Write joint armature over selected environment indices into the simulation. + """Set joint armature over selected env / joint indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``DOF_ARMATURE`` is a CPU-only OVPhysX binding. - The armature is directly added to the corresponding joint-space inertia. It helps improve the - simulation stability by reducing the joint velocities. + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - armature: Joint armature [kg*m^2 or kg]. Shape is (len(env_ids), len(joint_ids)). - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. + armature: Joint armature [kg·m²]. May be a scalar :class:`float` + (broadcast), or shape (len(env_ids), len(joint_ids)) with + dtype wp.float32. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - d = len(joint_ids) if joint_ids is not None else self._num_joints - self.assert_shape_and_dtype(armature, (n, d), wp.float32, "armature") - self._write_flat_tensor(TT.DOF_ARMATURE, armature, env_ids, joint_ids) + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + shape = (env_ids.shape[0], joint_ids.shape[0]) + armature = self._broadcast_scalar_to_2d(armature, shape) + self.assert_shape_and_dtype(armature, shape, wp.float32, "armature") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=shape, + inputs=[armature, env_ids, joint_ids], + outputs=[self._data._joint_armature.data], + device=self._device, + ) + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self.data._cpu_joint_armature, self._data._joint_armature.data) + binding = self._get_binding(TT.DOF_ARMATURE) + binding.write(self.data._cpu_joint_armature, indices=cpu_env_ids) def write_joint_armature_to_sim_mask( self, *, - armature: torch.Tensor | wp.array, + armature: float | torch.Tensor | wp.array, joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Write joint armature over masked environments into the simulation. + """Set joint armature over selected env / joint masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``DOF_ARMATURE`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. - The armature is directly added to the corresponding joint-space inertia. It helps improve the - simulation stability by reducing the joint velocities. + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - armature: Joint armature [kg*m^2 or kg]. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. + armature: Joint armature [kg·m²]. May be a scalar :class:`float` + (broadcast), or shape (num_instances, num_joints) with dtype + wp.float32. + joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(armature, (self._num_instances, self._num_joints), wp.float32, "armature") - self._write_flat_tensor_mask(TT.DOF_ARMATURE, armature, env_mask, joint_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + shape = (self._num_instances, self._num_joints) + armature = self._broadcast_scalar_to_2d(armature, shape) + self.assert_shape_and_dtype(armature, shape, wp.float32, "armature") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[armature, env_mask_wp, joint_mask_wp], + outputs=[self._data._joint_armature.data], + device=self._device, + ) + wp.copy(self.data._cpu_joint_armature, self._data._joint_armature.data) + binding = self._get_binding(TT.DOF_ARMATURE) + binding.write(self.data._cpu_joint_armature, mask=self._get_cpu_env_mask(env_mask_wp)) def write_joint_friction_coefficient_to_sim_index( self, *, - joint_friction_coeff: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + joint_friction_coeff: float | torch.Tensor | wp.array, + joint_dynamic_friction_coeff: float | torch.Tensor | wp.array | None = None, + joint_viscous_friction_coeff: float | torch.Tensor | wp.array | None = None, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Write joint friction coefficients over selected indices into the simulation. + r"""Write joint friction coefficients over selected env / joint indices into the simulation. + + Mirrors :meth:`isaaclab_physx.assets.Articulation.write_joint_friction_coefficient_to_sim_index`: + Coulomb (static & dynamic) friction with an optional viscous term. Any of the three + components can be left unset by passing ``None``; the corresponding slot in the + combined ``DOF_FRICTION_PROPERTIES`` ``(N, J, 3)`` binding is preserved. + + ``DOF_FRICTION_PROPERTIES`` is a CPU-only OVPhysX binding, so the + write is routed through pinned-host staging. + + .. note:: + This method expects partial data. Each component, if provided, + may be a scalar :class:`float` (broadcast to + ``(len(env_ids), len(joint_ids))``) or a 2D tensor / warp array. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - joint_friction_coeff: Joint friction coefficients. Shape is (len(env_ids), len(joint_ids)). - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. + joint_friction_coeff: Static friction coefficient :math:`\mu_s` [dimensionless]. + joint_dynamic_friction_coeff: Dynamic (Coulomb) friction coefficient + :math:`\mu_d`. If ``None``, the dynamic component is preserved. + joint_viscous_friction_coeff: Viscous friction coefficient :math:`c_v`. + If ``None``, the viscous component is preserved. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - d = len(joint_ids) if joint_ids is not None else self._num_joints - self.assert_shape_and_dtype(joint_friction_coeff, (n, d), wp.float32, "joint_friction_coeff") - self._write_friction_column(joint_friction_coeff, env_ids, joint_ids) + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + shape = (env_ids.shape[0], joint_ids.shape[0]) + joint_friction_coeff = self._broadcast_scalar_to_2d(joint_friction_coeff, shape) + if joint_dynamic_friction_coeff is not None: + joint_dynamic_friction_coeff = self._broadcast_scalar_to_2d(joint_dynamic_friction_coeff, shape) + if joint_viscous_friction_coeff is not None: + joint_viscous_friction_coeff = self._broadcast_scalar_to_2d(joint_viscous_friction_coeff, shape) + self.assert_shape_and_dtype(joint_friction_coeff, shape, wp.float32, "joint_friction_coeff") + if joint_dynamic_friction_coeff is not None: + self.assert_shape_and_dtype(joint_dynamic_friction_coeff, shape, wp.float32, "joint_dynamic_friction_coeff") + if joint_viscous_friction_coeff is not None: + self.assert_shape_and_dtype(joint_viscous_friction_coeff, shape, wp.float32, "joint_viscous_friction_coeff") + # refresh the combined (N, J, 3) buffer from the binding so unchanged + # components are preserved on the round-trip + self._data._read_scalar_binding(TT.DOF_FRICTION_PROPERTIES, self._data._joint_friction_props_buf) + wp.launch( + write_joint_friction_data_to_buffer_index, + dim=shape, + inputs=[ + joint_friction_coeff, + joint_dynamic_friction_coeff, + joint_viscous_friction_coeff, + env_ids, + joint_ids, + ], + outputs=[self._data._joint_friction_props_buf.data], + device=self._device, + ) + # Stage the combined (N, J, 3) buffer to pinned-host CPU and write to the binding. + cpu_env_ids = self._get_cpu_env_ids(env_ids) + cpu_friction = self._data._stage_to_pinned_cpu( + TT.DOF_FRICTION_PROPERTIES, "write", self._data._joint_friction_props_buf.data + ) + binding = self._get_binding(TT.DOF_FRICTION_PROPERTIES) + binding.write(cpu_friction, indices=cpu_env_ids) def write_joint_friction_coefficient_to_sim_mask( self, *, - joint_friction_coeff: torch.Tensor | wp.array, + joint_friction_coeff: float | torch.Tensor | wp.array, + joint_dynamic_friction_coeff: float | torch.Tensor | wp.array | None = None, + joint_viscous_friction_coeff: float | torch.Tensor | wp.array | None = None, joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Write joint friction coefficients over masked environments into the simulation. + r"""Mask variant of :meth:`write_joint_friction_coefficient_to_sim_index`. Args: - joint_friction_coeff: Joint friction coefficients. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. + joint_friction_coeff: Static friction coefficient :math:`\mu_s`. Full data, + shape ``(num_instances, num_joints)``. May be a scalar :class:`float`. + joint_dynamic_friction_coeff: Dynamic friction. ``None`` to preserve. + joint_viscous_friction_coeff: Viscous friction. ``None`` to preserve. + joint_mask: Joint mask. If None, all joints are updated. + env_mask: Environment mask. If None, all instances are updated. """ - self.assert_shape_and_dtype( - joint_friction_coeff, (self._num_instances, self._num_joints), wp.float32, "joint_friction_coeff" + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + shape = (self._num_instances, self._num_joints) + joint_friction_coeff = self._broadcast_scalar_to_2d(joint_friction_coeff, shape) + if joint_dynamic_friction_coeff is not None: + joint_dynamic_friction_coeff = self._broadcast_scalar_to_2d(joint_dynamic_friction_coeff, shape) + if joint_viscous_friction_coeff is not None: + joint_viscous_friction_coeff = self._broadcast_scalar_to_2d(joint_viscous_friction_coeff, shape) + self.assert_shape_and_dtype(joint_friction_coeff, shape, wp.float32, "joint_friction_coeff") + if joint_dynamic_friction_coeff is not None: + self.assert_shape_and_dtype(joint_dynamic_friction_coeff, shape, wp.float32, "joint_dynamic_friction_coeff") + if joint_viscous_friction_coeff is not None: + self.assert_shape_and_dtype(joint_viscous_friction_coeff, shape, wp.float32, "joint_viscous_friction_coeff") + # refresh the (N, J, 3) buffer first (see ``_index`` variant) + self._data._read_scalar_binding(TT.DOF_FRICTION_PROPERTIES, self._data._joint_friction_props_buf) + wp.launch( + write_joint_friction_data_to_buffer_mask, + dim=shape, + inputs=[ + joint_friction_coeff, + joint_dynamic_friction_coeff, + joint_viscous_friction_coeff, + env_mask_wp, + joint_mask_wp, + ], + outputs=[self._data._joint_friction_props_buf.data], + device=self._device, + ) + cpu_friction = self._data._stage_to_pinned_cpu( + TT.DOF_FRICTION_PROPERTIES, "write", self._data._joint_friction_props_buf.data + ) + binding = self._get_binding(TT.DOF_FRICTION_PROPERTIES) + binding.write(cpu_friction, mask=self._get_cpu_env_mask(env_mask_wp)) + + def write_joint_dynamic_friction_coefficient_to_sim_index( + self, + *, + joint_dynamic_friction_coeff: float | torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + r"""Write joint dynamic friction coefficients over selected env / joint indices into the simulation. + + Mirrors :meth:`isaaclab_physx.assets.Articulation.write_joint_dynamic_friction_coefficient_to_sim_index`: + updates only the dynamic (Coulomb) slot of the combined ``DOF_FRICTION_PROPERTIES`` ``(N, J, 3)`` + binding; the static and viscous components are preserved. + + ``DOF_FRICTION_PROPERTIES`` is a CPU-only OVPhysX binding, so the + write is routed through pinned-host staging. + + .. note:: + This method expects partial data. ``joint_dynamic_friction_coeff`` may be a + scalar :class:`float` (broadcast to ``(len(env_ids), len(joint_ids))``) or a + 2D tensor / warp array. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. + + Args: + joint_dynamic_friction_coeff: Dynamic (Coulomb) friction coefficient + :math:`\mu_d` [dimensionless]. Shape is ``(len(env_ids), len(joint_ids))`` + with dtype wp.float32, or a scalar that is broadcast. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). + """ + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + shape = (env_ids.shape[0], joint_ids.shape[0]) + joint_dynamic_friction_coeff = self._broadcast_scalar_to_2d(joint_dynamic_friction_coeff, shape) + self.assert_shape_and_dtype(joint_dynamic_friction_coeff, shape, wp.float32, "joint_dynamic_friction_coeff") + # refresh the combined (N, J, 3) buffer from the binding so unchanged + # components are preserved on the round-trip + self._data._read_scalar_binding(TT.DOF_FRICTION_PROPERTIES, self._data._joint_friction_props_buf) + wp.launch( + write_joint_friction_data_to_buffer_index, + dim=shape, + inputs=[ + None, # in_static — preserved + joint_dynamic_friction_coeff, + None, # in_viscous — preserved + env_ids, + joint_ids, + ], + outputs=[self._data._joint_friction_props_buf.data], + device=self._device, + ) + cpu_env_ids = self._get_cpu_env_ids(env_ids) + cpu_friction = self._data._stage_to_pinned_cpu( + TT.DOF_FRICTION_PROPERTIES, "write", self._data._joint_friction_props_buf.data + ) + binding = self._get_binding(TT.DOF_FRICTION_PROPERTIES) + binding.write(cpu_friction, indices=cpu_env_ids) + + def write_joint_dynamic_friction_coefficient_to_sim_mask( + self, + *, + joint_dynamic_friction_coeff: float | torch.Tensor | wp.array, + joint_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + r"""Mask variant of :meth:`write_joint_dynamic_friction_coefficient_to_sim_index`. + + Updates only the dynamic (Coulomb) slot of the combined ``DOF_FRICTION_PROPERTIES`` + ``(N, J, 3)`` binding; the static and viscous components are preserved. + + Args: + joint_dynamic_friction_coeff: Dynamic (Coulomb) friction coefficient + :math:`\mu_d` [dimensionless]. Full data, shape + ``(num_instances, num_joints)``. May be a scalar :class:`float`. + joint_mask: Joint mask. If None, all joints are updated. + env_mask: Environment mask. If None, all instances are updated. + """ + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + shape = (self._num_instances, self._num_joints) + joint_dynamic_friction_coeff = self._broadcast_scalar_to_2d(joint_dynamic_friction_coeff, shape) + self.assert_shape_and_dtype(joint_dynamic_friction_coeff, shape, wp.float32, "joint_dynamic_friction_coeff") + # refresh the (N, J, 3) buffer first (see ``_index`` variant) + self._data._read_scalar_binding(TT.DOF_FRICTION_PROPERTIES, self._data._joint_friction_props_buf) + wp.launch( + write_joint_friction_data_to_buffer_mask, + dim=shape, + inputs=[ + None, # in_static — preserved + joint_dynamic_friction_coeff, + None, # in_viscous — preserved + env_mask_wp, + joint_mask_wp, + ], + outputs=[self._data._joint_friction_props_buf.data], + device=self._device, + ) + cpu_friction = self._data._stage_to_pinned_cpu( + TT.DOF_FRICTION_PROPERTIES, "write", self._data._joint_friction_props_buf.data + ) + binding = self._get_binding(TT.DOF_FRICTION_PROPERTIES) + binding.write(cpu_friction, mask=self._get_cpu_env_mask(env_mask_wp)) + + def write_joint_viscous_friction_coefficient_to_sim_index( + self, + *, + joint_viscous_friction_coeff: float | torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + r"""Write joint viscous friction coefficients over selected env / joint indices into the simulation. + + Mirrors :meth:`isaaclab_physx.assets.Articulation.write_joint_viscous_friction_coefficient_to_sim_index`: + updates only the viscous slot of the combined ``DOF_FRICTION_PROPERTIES`` ``(N, J, 3)`` + binding; the static and dynamic components are preserved. + + ``DOF_FRICTION_PROPERTIES`` is a CPU-only OVPhysX binding, so the + write is routed through pinned-host staging. + + .. note:: + This method expects partial data. ``joint_viscous_friction_coeff`` may be a + scalar :class:`float` (broadcast to ``(len(env_ids), len(joint_ids))``) or a + 2D tensor / warp array. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. + + Args: + joint_viscous_friction_coeff: Viscous friction coefficient + :math:`c_v` [N·m·s/rad or N·s/m, depending on joint type]. + Shape is ``(len(env_ids), len(joint_ids))`` with dtype wp.float32, or + a scalar that is broadcast. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). + """ + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + shape = (env_ids.shape[0], joint_ids.shape[0]) + joint_viscous_friction_coeff = self._broadcast_scalar_to_2d(joint_viscous_friction_coeff, shape) + self.assert_shape_and_dtype(joint_viscous_friction_coeff, shape, wp.float32, "joint_viscous_friction_coeff") + # refresh the combined (N, J, 3) buffer from the binding so unchanged + # components are preserved on the round-trip + self._data._read_scalar_binding(TT.DOF_FRICTION_PROPERTIES, self._data._joint_friction_props_buf) + wp.launch( + write_joint_friction_data_to_buffer_index, + dim=shape, + inputs=[ + None, # in_static — preserved + None, # in_dynamic — preserved + joint_viscous_friction_coeff, + env_ids, + joint_ids, + ], + outputs=[self._data._joint_friction_props_buf.data], + device=self._device, + ) + cpu_env_ids = self._get_cpu_env_ids(env_ids) + cpu_friction = self._data._stage_to_pinned_cpu( + TT.DOF_FRICTION_PROPERTIES, "write", self._data._joint_friction_props_buf.data + ) + binding = self._get_binding(TT.DOF_FRICTION_PROPERTIES) + binding.write(cpu_friction, indices=cpu_env_ids) + + def write_joint_viscous_friction_coefficient_to_sim_mask( + self, + *, + joint_viscous_friction_coeff: float | torch.Tensor | wp.array, + joint_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + r"""Mask variant of :meth:`write_joint_viscous_friction_coefficient_to_sim_index`. + + Updates only the viscous slot of the combined ``DOF_FRICTION_PROPERTIES`` + ``(N, J, 3)`` binding; the static and dynamic components are preserved. + + Args: + joint_viscous_friction_coeff: Viscous friction coefficient + :math:`c_v` [N·m·s/rad or N·s/m, depending on joint type]. + Full data, shape ``(num_instances, num_joints)``. May be a + scalar :class:`float`. + joint_mask: Joint mask. If None, all joints are updated. + env_mask: Environment mask. If None, all instances are updated. + """ + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + shape = (self._num_instances, self._num_joints) + joint_viscous_friction_coeff = self._broadcast_scalar_to_2d(joint_viscous_friction_coeff, shape) + self.assert_shape_and_dtype(joint_viscous_friction_coeff, shape, wp.float32, "joint_viscous_friction_coeff") + # refresh the (N, J, 3) buffer first (see ``_index`` variant) + self._data._read_scalar_binding(TT.DOF_FRICTION_PROPERTIES, self._data._joint_friction_props_buf) + wp.launch( + write_joint_friction_data_to_buffer_mask, + dim=shape, + inputs=[ + None, # in_static — preserved + None, # in_dynamic — preserved + joint_viscous_friction_coeff, + env_mask_wp, + joint_mask_wp, + ], + outputs=[self._data._joint_friction_props_buf.data], + device=self._device, + ) + cpu_friction = self._data._stage_to_pinned_cpu( + TT.DOF_FRICTION_PROPERTIES, "write", self._data._joint_friction_props_buf.data ) - self._write_friction_column_mask(joint_friction_coeff, env_mask, joint_mask) + binding = self._get_binding(TT.DOF_FRICTION_PROPERTIES) + binding.write(cpu_friction, mask=self._get_cpu_env_mask(env_mask_wp)) """ Operations - Setters. @@ -881,20 +1978,42 @@ def set_masses_index( self, *, masses: torch.Tensor | wp.array, - body_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + body_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set masses of all bodies using indices. + """Set body masses over selected env / body indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_MASS`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized + implementations. Performance is similar for both. However, to + allow graphed pipelines, the mask method must be used. Args: - masses: Masses of all bodies [kg]. Shape is (len(env_ids), len(body_ids)). - body_ids: The body indices to set the masses for. Defaults to None (all bodies). - env_ids: The environment indices to set the masses for. Defaults to None (all environments). + masses: Body masses [kg]. Shape is (len(env_ids), len(body_ids)) + with dtype wp.float32. + body_ids: Body indices. Defaults to None (all bodies). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - b = len(body_ids) if body_ids is not None else self._num_bodies - self.assert_shape_and_dtype(masses, (n, b), wp.float32, "masses") - self._write_flat_tensor(TT.BODY_MASS, masses, env_ids, body_ids) + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype(masses, (env_ids.shape[0], body_ids.shape[0]), wp.float32, "masses") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[masses, env_ids, body_ids], + outputs=[self._data._body_mass.data], + device=self._device, + ) + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self.data._cpu_body_mass, self._data._body_mass.data) + binding = self._get_binding(TT.BODY_MASS) + binding.write(self.data._cpu_body_mass, indices=cpu_env_ids) def set_masses_mask( self, @@ -903,36 +2022,84 @@ def set_masses_mask( body_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set masses of all bodies using masks. + """Set body masses over selected env / body masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_MASS`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized + implementations. Performance is similar for both. However, to + allow graphed pipelines, the mask method must be used. Args: - masses: Masses of all bodies [kg]. Shape is (num_instances, num_bodies). - body_mask: Body mask. If None, then all bodies are used. - env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + masses: Body masses [kg]. Shape is (num_instances, num_bodies) + with dtype wp.float32. + body_mask: Body mask. If None, all bodies are updated. + Shape is (num_bodies,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ + env_mask_wp = self._resolve_env_mask(env_mask) + body_mask_wp = self._resolve_body_mask(body_mask) self.assert_shape_and_dtype(masses, (self._num_instances, self._num_bodies), wp.float32, "masses") - self._write_flat_tensor_mask(TT.BODY_MASS, masses, env_mask, body_mask) + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=(self._num_instances, self._num_bodies), + inputs=[masses, env_mask_wp, body_mask_wp], + outputs=[self._data._body_mass.data], + device=self._device, + ) + wp.copy(self.data._cpu_body_mass, self._data._body_mass.data) + binding = self._get_binding(TT.BODY_MASS) + binding.write(self.data._cpu_body_mass, mask=self._get_cpu_env_mask(env_mask_wp)) def set_coms_index( self, *, coms: torch.Tensor | wp.array, - body_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + body_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set center of mass pose of all bodies using indices. + """Set body center-of-mass poses over selected env / body indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_COM_POSE`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized + implementations. Performance is similar for both. However, to + allow graphed pipelines, the mask method must be used. Args: - coms: Center of mass pose of all bodies. Shape is (len(env_ids), len(body_ids)) - with dtype wp.transformf. - body_ids: The body indices to set the center of mass pose for. Defaults to None (all bodies). - env_ids: The environment indices to set the center of mass pose for. - Defaults to None (all environments). + coms: Body center-of-mass poses [m, quaternion (w, x, y, z)]. + Shape is (len(env_ids), len(body_ids)) with dtype wp.transformf. + body_ids: Body indices. Defaults to None (all bodies). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - b = len(body_ids) if body_ids is not None else self._num_bodies - self.assert_shape_and_dtype(coms, (n, b), wp.transformf, "coms") - self._write_flat_tensor(TT.BODY_COM_POSE, coms, env_ids, body_ids) + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype(coms, (env_ids.shape[0], body_ids.shape[0]), wp.transformf, "coms") + wp.launch( + shared_kernels.write_body_com_pose_to_buffer_index, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[coms, env_ids, body_ids], + outputs=[self._data._body_com_pose_b.data], + device=self._device, + ) + # Invalidate derived buffers that depend on body_com_pose_b. + self.data._root_com_pose_w.timestamp = -1.0 + self.data._body_com_pose_w.timestamp = -1.0 + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self.data._cpu_body_coms, self._data._body_com_pose_b.data) + binding = self._get_binding(TT.BODY_COM_POSE) + binding.write(self.data._cpu_body_coms, indices=cpu_env_ids) def set_coms_mask( self, @@ -941,35 +2108,84 @@ def set_coms_mask( body_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set center of mass pose of all bodies using masks. + """Set body center-of-mass poses over selected env / body masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_COM_POSE`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized + implementations. Performance is similar for both. However, to + allow graphed pipelines, the mask method must be used. Args: - coms: Center of mass pose of all bodies. Shape is (num_instances, num_bodies) - with dtype wp.transformf. - body_mask: Body mask. If None, then all bodies are used. - env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + coms: Body center-of-mass poses [m, quaternion (w, x, y, z)]. + Shape is (num_instances, num_bodies) with dtype wp.transformf. + body_mask: Body mask. If None, all bodies are updated. + Shape is (num_bodies,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ + env_mask_wp = self._resolve_env_mask(env_mask) + body_mask_wp = self._resolve_body_mask(body_mask) self.assert_shape_and_dtype(coms, (self._num_instances, self._num_bodies), wp.transformf, "coms") - self._write_flat_tensor_mask(TT.BODY_COM_POSE, coms, env_mask, body_mask) + wp.launch( + shared_kernels.write_body_com_pose_to_buffer_mask, + dim=(self._num_instances, self._num_bodies), + inputs=[coms, env_mask_wp, body_mask_wp], + outputs=[self._data._body_com_pose_b.data], + device=self._device, + ) + # Invalidate derived buffers that depend on body_com_pose_b. + self.data._root_com_pose_w.timestamp = -1.0 + self.data._body_com_pose_w.timestamp = -1.0 + wp.copy(self.data._cpu_body_coms, self._data._body_com_pose_b.data) + binding = self._get_binding(TT.BODY_COM_POSE) + binding.write(self.data._cpu_body_coms, mask=self._get_cpu_env_mask(env_mask_wp)) def set_inertias_index( self, *, inertias: torch.Tensor | wp.array, - body_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + body_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set inertias of all bodies using indices. + """Set body inertia tensors over selected env / body indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_INERTIA`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized + implementations. Performance is similar for both. However, to + allow graphed pipelines, the mask method must be used. Args: - inertias: Inertias of all bodies [kg*m^2]. Shape is (len(env_ids), len(body_ids), 9). - body_ids: The body indices to set the inertias for. Defaults to None (all bodies). - env_ids: The environment indices to set the inertias for. Defaults to None (all environments). + inertias: Body inertia tensors [kg·m²]. Shape is + (len(env_ids), len(body_ids), 9) with dtype wp.float32. + body_ids: Body indices. Defaults to None (all bodies). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - b = len(body_ids) if body_ids is not None else self._num_bodies - self.assert_shape_and_dtype(inertias, (n, b, 9), wp.float32, "inertias") - self._write_flat_tensor(TT.BODY_INERTIA, inertias, env_ids, body_ids) + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype(inertias, (env_ids.shape[0], body_ids.shape[0], 9), wp.float32, "inertias") + wp.launch( + shared_kernels.write_body_inertia_to_buffer_index, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[inertias, env_ids, body_ids], + outputs=[self._data._body_inertia.data], + device=self._device, + ) + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self.data._cpu_body_inertia, self._data._body_inertia.data) + binding = self._get_binding(TT.BODY_INERTIA) + binding.write(self.data._cpu_body_inertia, indices=cpu_env_ids) def set_inertias_mask( self, @@ -978,38 +2194,80 @@ def set_inertias_mask( body_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set inertias of all bodies using masks. + """Set body inertia tensors over selected env / body masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_INERTIA`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized + implementations. Performance is similar for both. However, to + allow graphed pipelines, the mask method must be used. Args: - inertias: Inertias of all bodies [kg*m^2]. Shape is (num_instances, num_bodies, 9). - body_mask: Body mask. If None, then all bodies are used. - env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + inertias: Body inertia tensors [kg·m²]. Shape is + (num_instances, num_bodies, 9) with dtype wp.float32. + body_mask: Body mask. If None, all bodies are updated. + Shape is (num_bodies,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ + env_mask_wp = self._resolve_env_mask(env_mask) + body_mask_wp = self._resolve_body_mask(body_mask) self.assert_shape_and_dtype(inertias, (self._num_instances, self._num_bodies, 9), wp.float32, "inertias") - self._write_flat_tensor_mask(TT.BODY_INERTIA, inertias, env_mask, body_mask) - - """ - Operations - Target Setters. - """ + wp.launch( + shared_kernels.write_body_inertia_to_buffer_mask, + dim=(self._num_instances, self._num_bodies), + inputs=[inertias, env_mask_wp, body_mask_wp], + outputs=[self._data._body_inertia.data], + device=self._device, + ) + wp.copy(self.data._cpu_body_inertia, self._data._body_inertia.data) + binding = self._get_binding(TT.BODY_INERTIA) + binding.write(self.data._cpu_body_inertia, mask=self._get_cpu_env_mask(env_mask_wp)) def set_joint_position_target_index( self, *, target: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Set joint position targets into internal buffers using indices. - This function does not apply the joint targets to the simulation. It only fills the buffers with - the desired values. To apply the joint targets, call the :meth:`write_data_to_sim` function. + This function does not apply the joint targets to the simulation. It only fills the + buffers with the desired values. To apply the joint targets, call + :meth:`write_data_to_sim`. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - target: Joint position targets [rad or m]. Shape is (len(env_ids), len(joint_ids)). - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. + target: Joint position targets [m or rad, depending on joint type]. Shape is + (len(env_ids), len(joint_ids)) with dtype wp.float32. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). """ - self._set_target_into_buffer(self._data._joint_pos_target, target, env_ids, joint_ids) + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + self.assert_shape_and_dtype(target, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "target") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[target, env_ids, joint_ids], + outputs=[self._data._joint_pos_target], + device=self._device, + ) + binding = self._get_binding(TT.DOF_POSITION_TARGET) + binding.write(self._data._joint_pos_target, indices=env_ids) def set_joint_position_target_mask( self, @@ -1020,31 +2278,73 @@ def set_joint_position_target_mask( ) -> None: """Set joint position targets into internal buffers using masks. + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. + Args: - target: Joint position targets [rad or m]. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. + target: Joint position targets [m or rad, depending on joint type]. Shape is + (num_instances, num_joints) with dtype wp.float32. + joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). + env_mask: Environment mask. If None, all instances are updated. Shape is + (num_instances,). """ - self._set_target_into_buffer_mask(self._data._joint_pos_target, target, env_mask, joint_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + self.assert_shape_and_dtype(target, (self._num_instances, self._num_joints), wp.float32, "target") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=(self._num_instances, self._num_joints), + inputs=[target, env_mask_wp, joint_mask_wp], + outputs=[self._data._joint_pos_target], + device=self._device, + ) + binding = self._get_binding(TT.DOF_POSITION_TARGET) + binding.write(self._data._joint_pos_target, mask=env_mask_wp) def set_joint_velocity_target_index( self, *, target: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Set joint velocity targets into internal buffers using indices. - This function does not apply the joint targets to the simulation. It only fills the buffers with - the desired values. To apply the joint targets, call the :meth:`write_data_to_sim` function. + This function does not apply the joint targets to the simulation. It only fills the + buffers with the desired values. To apply the joint targets, call + :meth:`write_data_to_sim`. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - target: Joint velocity targets [rad/s or m/s]. Shape is (len(env_ids), len(joint_ids)). - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. + target: Joint velocity targets [m/s or rad/s, depending on joint type]. Shape is + (len(env_ids), len(joint_ids)) with dtype wp.float32. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). """ - self._set_target_into_buffer(self._data._joint_vel_target, target, env_ids, joint_ids) + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + self.assert_shape_and_dtype(target, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "target") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[target, env_ids, joint_ids], + outputs=[self._data._joint_vel_target], + device=self._device, + ) + binding = self._get_binding(TT.DOF_VELOCITY_TARGET) + binding.write(self._data._joint_vel_target, indices=env_ids) def set_joint_velocity_target_mask( self, @@ -1055,31 +2355,73 @@ def set_joint_velocity_target_mask( ) -> None: """Set joint velocity targets into internal buffers using masks. + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. + Args: - target: Joint velocity targets [rad/s or m/s]. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. + target: Joint velocity targets [m/s or rad/s, depending on joint type]. Shape is + (num_instances, num_joints) with dtype wp.float32. + joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). + env_mask: Environment mask. If None, all instances are updated. Shape is + (num_instances,). """ - self._set_target_into_buffer_mask(self._data._joint_vel_target, target, env_mask, joint_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + self.assert_shape_and_dtype(target, (self._num_instances, self._num_joints), wp.float32, "target") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=(self._num_instances, self._num_joints), + inputs=[target, env_mask_wp, joint_mask_wp], + outputs=[self._data._joint_vel_target], + device=self._device, + ) + binding = self._get_binding(TT.DOF_VELOCITY_TARGET) + binding.write(self._data._joint_vel_target, mask=env_mask_wp) def set_joint_effort_target_index( self, *, target: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set joint efforts into internal buffers using indices. + """Set joint effort targets into internal buffers using indices. + + This function does not apply the joint targets to the simulation. It only fills the + buffers with the desired values. To apply the joint targets, call + :meth:`write_data_to_sim`. + + .. note:: + This method expects partial data. - This function does not apply the joint targets to the simulation. It only fills the buffers with - the desired values. To apply the joint targets, call the :meth:`write_data_to_sim` function. + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - target: Joint effort targets [N or N*m]. Shape is (len(env_ids), len(joint_ids)). - joint_ids: Joint indices. If None, then all joints are used. - env_ids: Environment indices. If None, then all indices are used. + target: Joint effort targets [N or N·m, depending on joint type]. Shape is + (len(env_ids), len(joint_ids)) with dtype wp.float32. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). """ - self._set_target_into_buffer(self._data._joint_effort_target, target, env_ids, joint_ids) + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + self.assert_shape_and_dtype(target, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "target") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[target, env_ids, joint_ids], + outputs=[self._data._joint_effort_target], + device=self._device, + ) + binding = self._get_binding(TT.DOF_ACTUATION_FORCE) + binding.write(self._data._joint_effort_target, indices=env_ids) def set_joint_effort_target_mask( self, @@ -1088,14 +2430,35 @@ def set_joint_effort_target_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set joint efforts into internal buffers using masks. + """Set joint effort targets into internal buffers using masks. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - target: Joint effort targets [N or N*m]. Shape is (num_instances, num_joints). - joint_mask: Joint mask. If None, then all joints are updated. - env_mask: Environment mask. If None, then all instances are updated. + target: Joint effort targets [N or N·m, depending on joint type]. Shape is + (num_instances, num_joints) with dtype wp.float32. + joint_mask: Joint mask. If None, all joints are updated. Shape is (num_joints,). + env_mask: Environment mask. If None, all instances are updated. Shape is + (num_instances,). """ - self._set_target_into_buffer_mask(self._data._joint_effort_target, target, env_mask, joint_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + joint_mask_wp = self._resolve_joint_mask(joint_mask) + self.assert_shape_and_dtype(target, (self._num_instances, self._num_joints), wp.float32, "target") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=(self._num_instances, self._num_joints), + inputs=[target, env_mask_wp, joint_mask_wp], + outputs=[self._data._joint_effort_target], + device=self._device, + ) + binding = self._get_binding(TT.DOF_ACTUATION_FORCE) + binding.write(self._data._joint_effort_target, mask=env_mask_wp) """ Operations - Tendons. @@ -1108,23 +2471,41 @@ def set_fixed_tendon_stiffness_index( fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set fixed tendon stiffness into internal buffers using indices. + """Set fixed-tendon stiffness over selected env / tendon indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_STIFFNESS`` is a CPU-only OVPhysX binding. - This function does not apply the tendon stiffness to the simulation. It only fills the buffers with - the desired values. To apply the tendon stiffness, call the - :meth:`write_fixed_tendon_properties_to_sim_index` method. + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - stiffness: Fixed tendon stiffness. Shape is (len(env_ids), len(fixed_tendon_ids)). - fixed_tendon_ids: The tendon indices to set the stiffness for. Defaults to None (all fixed tendons). - env_ids: Environment indices. If None, then all indices are used. + stiffness: Fixed-tendon stiffness [N/m]. May be a scalar + :class:`float` (broadcast), or shape + (len(env_ids), len(fixed_tendon_ids)) with dtype wp.float32. + fixed_tendon_ids: Fixed-tendon indices. Defaults to None (all fixed tendons). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - t = len(fixed_tendon_ids) if fixed_tendon_ids else self._nft() - self.assert_shape_and_dtype(stiffness, (n, t), wp.float32, "stiffness") - if self._data._fixed_tendon_stiffness is not None: - self._set_target_into_buffer(self._data._fixed_tendon_stiffness, stiffness, env_ids, fixed_tendon_ids) - + env_ids = self._resolve_env_ids(env_ids) + tendon_ids = self._resolve_fixed_tendon_ids(fixed_tendon_ids) + shape = (env_ids.shape[0], tendon_ids.shape[0]) + stiffness = self._broadcast_scalar_to_2d(stiffness, shape) + self.assert_shape_and_dtype(stiffness, shape, wp.float32, "stiffness") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=shape, + inputs=[stiffness, env_ids, tendon_ids], + outputs=[self._data._fixed_tendon_stiffness.data], + device=self._device, + ) + binding = self._get_binding(TT.FIXED_TENDON_STIFFNESS) + binding.write(self._data._fixed_tendon_stiffness.data, indices=env_ids) + def set_fixed_tendon_stiffness_mask( self, *, @@ -1132,18 +2513,42 @@ def set_fixed_tendon_stiffness_mask( fixed_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set fixed tendon stiffness into internal buffers using masks. + """Set fixed-tendon stiffness over selected env / tendon masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_STIFFNESS`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - stiffness: Fixed tendon stiffness. Shape is (num_instances, num_fixed_tendons). - fixed_tendon_mask: Tendon mask. If None, then all fixed tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. + stiffness: Fixed-tendon stiffness [N/m]. May be a scalar + :class:`float` (broadcast), or shape + (num_instances, num_fixed_tendons) with dtype wp.float32. + fixed_tendon_mask: Fixed-tendon mask. If None, all fixed tendons are updated. + Shape is (num_fixed_tendons,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(stiffness, (self._num_instances, self._nft()), wp.float32, "stiffness") - if self._data._fixed_tendon_stiffness is not None: - self._set_target_into_buffer_mask( - self._data._fixed_tendon_stiffness, stiffness, env_mask, fixed_tendon_mask - ) + env_mask_wp = self._resolve_env_mask(env_mask) + tendon_mask_wp = self._resolve_fixed_tendon_mask(fixed_tendon_mask) + shape = (self._num_instances, self._num_fixed_tendons) + stiffness = self._broadcast_scalar_to_2d(stiffness, shape) + self.assert_shape_and_dtype(stiffness, shape, wp.float32, "stiffness") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[stiffness, env_mask_wp, tendon_mask_wp], + outputs=[self._data._fixed_tendon_stiffness.data], + device=self._device, + ) + binding = self._get_binding(TT.FIXED_TENDON_STIFFNESS) + binding.write(self._data._fixed_tendon_stiffness.data, mask=env_mask_wp) def set_fixed_tendon_damping_index( self, @@ -1152,18 +2557,40 @@ def set_fixed_tendon_damping_index( fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set fixed tendon damping into internal buffers using indices. + """Set fixed-tendon damping over selected env / tendon indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_DAMPING`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - damping: Fixed tendon damping. Shape is (len(env_ids), len(fixed_tendon_ids)). - fixed_tendon_ids: The tendon indices to set the damping for. Defaults to None (all fixed tendons). - env_ids: Environment indices. If None, then all indices are used. + damping: Fixed-tendon damping [N·s/m]. May be a scalar :class:`float` + (broadcast), or shape (len(env_ids), len(fixed_tendon_ids)) with + dtype wp.float32. + fixed_tendon_ids: Fixed-tendon indices. Defaults to None (all fixed tendons). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - t = len(fixed_tendon_ids) if fixed_tendon_ids else self._nft() - self.assert_shape_and_dtype(damping, (n, t), wp.float32, "damping") - if self._data._fixed_tendon_damping is not None: - self._set_target_into_buffer(self._data._fixed_tendon_damping, damping, env_ids, fixed_tendon_ids) + env_ids = self._resolve_env_ids(env_ids) + tendon_ids = self._resolve_fixed_tendon_ids(fixed_tendon_ids) + shape = (env_ids.shape[0], tendon_ids.shape[0]) + damping = self._broadcast_scalar_to_2d(damping, shape) + self.assert_shape_and_dtype(damping, shape, wp.float32, "damping") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=shape, + inputs=[damping, env_ids, tendon_ids], + outputs=[self._data._fixed_tendon_damping.data], + device=self._device, + ) + binding = self._get_binding(TT.FIXED_TENDON_DAMPING) + binding.write(self._data._fixed_tendon_damping.data, indices=env_ids) def set_fixed_tendon_damping_mask( self, @@ -1172,16 +2599,42 @@ def set_fixed_tendon_damping_mask( fixed_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set fixed tendon damping into internal buffers using masks. + """Set fixed-tendon damping over selected env / tendon masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_DAMPING`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - damping: Fixed tendon damping. Shape is (num_instances, num_fixed_tendons). - fixed_tendon_mask: Tendon mask. If None, then all fixed tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. + damping: Fixed-tendon damping [N·s/m]. May be a scalar :class:`float` + (broadcast), or shape (num_instances, num_fixed_tendons) with + dtype wp.float32. + fixed_tendon_mask: Fixed-tendon mask. If None, all fixed tendons are updated. + Shape is (num_fixed_tendons,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(damping, (self._num_instances, self._nft()), wp.float32, "damping") - if self._data._fixed_tendon_damping is not None: - self._set_target_into_buffer_mask(self._data._fixed_tendon_damping, damping, env_mask, fixed_tendon_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + tendon_mask_wp = self._resolve_fixed_tendon_mask(fixed_tendon_mask) + shape = (self._num_instances, self._num_fixed_tendons) + damping = self._broadcast_scalar_to_2d(damping, shape) + self.assert_shape_and_dtype(damping, shape, wp.float32, "damping") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[damping, env_mask_wp, tendon_mask_wp], + outputs=[self._data._fixed_tendon_damping.data], + device=self._device, + ) + binding = self._get_binding(TT.FIXED_TENDON_DAMPING) + binding.write(self._data._fixed_tendon_damping.data, mask=env_mask_wp) def set_fixed_tendon_limit_stiffness_index( self, @@ -1190,20 +2643,40 @@ def set_fixed_tendon_limit_stiffness_index( fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set fixed tendon limit stiffness into internal buffers using indices. + """Set fixed-tendon limit stiffness over selected env / tendon indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_LIMIT_STIFFNESS`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - limit_stiffness: Fixed tendon limit stiffness. Shape is (len(env_ids), len(fixed_tendon_ids)). - fixed_tendon_ids: The tendon indices. Defaults to None (all fixed tendons). - env_ids: Environment indices. If None, then all indices are used. + limit_stiffness: Fixed-tendon limit stiffness [N/m]. May be a + scalar :class:`float` (broadcast), or shape + (len(env_ids), len(fixed_tendon_ids)) with dtype wp.float32. + fixed_tendon_ids: Fixed-tendon indices. Defaults to None (all fixed tendons). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - t = len(fixed_tendon_ids) if fixed_tendon_ids else self._nft() - self.assert_shape_and_dtype(limit_stiffness, (n, t), wp.float32, "limit_stiffness") - if self._data._fixed_tendon_limit_stiffness is not None: - self._set_target_into_buffer( - self._data._fixed_tendon_limit_stiffness, limit_stiffness, env_ids, fixed_tendon_ids - ) + env_ids = self._resolve_env_ids(env_ids) + tendon_ids = self._resolve_fixed_tendon_ids(fixed_tendon_ids) + shape = (env_ids.shape[0], tendon_ids.shape[0]) + limit_stiffness = self._broadcast_scalar_to_2d(limit_stiffness, shape) + self.assert_shape_and_dtype(limit_stiffness, shape, wp.float32, "limit_stiffness") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=shape, + inputs=[limit_stiffness, env_ids, tendon_ids], + outputs=[self._data._fixed_tendon_limit_stiffness.data], + device=self._device, + ) + binding = self._get_binding(TT.FIXED_TENDON_LIMIT_STIFFNESS) + binding.write(self._data._fixed_tendon_limit_stiffness.data, indices=env_ids) def set_fixed_tendon_limit_stiffness_mask( self, @@ -1212,18 +2685,42 @@ def set_fixed_tendon_limit_stiffness_mask( fixed_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set fixed tendon limit stiffness into internal buffers using masks. + """Set fixed-tendon limit stiffness over selected env / tendon masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_LIMIT_STIFFNESS`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - limit_stiffness: Fixed tendon limit stiffness. Shape is (num_instances, num_fixed_tendons). - fixed_tendon_mask: Tendon mask. If None, then all fixed tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. + limit_stiffness: Fixed-tendon limit stiffness [N/m]. May be a + scalar :class:`float` (broadcast), or shape + (num_instances, num_fixed_tendons) with dtype wp.float32. + fixed_tendon_mask: Fixed-tendon mask. If None, all fixed tendons are updated. + Shape is (num_fixed_tendons,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(limit_stiffness, (self._num_instances, self._nft()), wp.float32, "limit_stiffness") - if self._data._fixed_tendon_limit_stiffness is not None: - self._set_target_into_buffer_mask( - self._data._fixed_tendon_limit_stiffness, limit_stiffness, env_mask, fixed_tendon_mask - ) + env_mask_wp = self._resolve_env_mask(env_mask) + tendon_mask_wp = self._resolve_fixed_tendon_mask(fixed_tendon_mask) + shape = (self._num_instances, self._num_fixed_tendons) + limit_stiffness = self._broadcast_scalar_to_2d(limit_stiffness, shape) + self.assert_shape_and_dtype(limit_stiffness, shape, wp.float32, "limit_stiffness") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[limit_stiffness, env_mask_wp, tendon_mask_wp], + outputs=[self._data._fixed_tendon_limit_stiffness.data], + device=self._device, + ) + binding = self._get_binding(TT.FIXED_TENDON_LIMIT_STIFFNESS) + binding.write(self._data._fixed_tendon_limit_stiffness.data, mask=env_mask_wp) def set_fixed_tendon_position_limit_index( self, @@ -1232,19 +2729,46 @@ def set_fixed_tendon_position_limit_index( fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set fixed tendon position limits into internal buffers using indices. + """Set fixed-tendon position limits over selected env / tendon indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_LIMIT`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - limit: Fixed tendon position limits. Shape is (len(env_ids), len(fixed_tendon_ids)) - with dtype wp.vec2f. - fixed_tendon_ids: The tendon indices. Defaults to None (all fixed tendons). - env_ids: Environment indices. If None, then all indices are used. + limit: Fixed-tendon position limits ``[lower, upper]`` [m]. + Shape is (len(env_ids), len(fixed_tendon_ids), 2) with dtype wp.float32. + fixed_tendon_ids: Fixed-tendon indices. Defaults to None (all fixed tendons). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - t = len(fixed_tendon_ids) if fixed_tendon_ids else self._nft() - self.assert_shape_and_dtype(limit, (n, t), wp.vec2f, "limit") - if self._data._fixed_tendon_pos_limits is not None: - self._set_target_into_buffer(self._data._fixed_tendon_pos_limits, limit, env_ids, fixed_tendon_ids) + env_ids = self._resolve_env_ids(env_ids) + tendon_ids = self._resolve_fixed_tendon_ids(fixed_tendon_ids) + self.assert_shape_and_dtype(limit, (env_ids.shape[0], tendon_ids.shape[0], 2), wp.float32, "limit") + # Scatter [lower, upper] pairs into the vec2f cache buffer. + wp.launch( + shared_kernels.write_joint_position_limit_to_buffer_index, + dim=(env_ids.shape[0], tendon_ids.shape[0]), + inputs=[limit, env_ids, tendon_ids], + outputs=[self._data._fixed_tendon_pos_limits.data], + device=self._device, + ) + # reinterpret the vec2f buffer as a (N, T, 2) float32 view for the binding + flat_src = wp.array( + ptr=self._data._fixed_tendon_pos_limits.data.ptr, + shape=(self._num_instances, self._num_fixed_tendons, 2), + dtype=wp.float32, + device=self._device, + copy=False, + ) + binding = self._get_binding(TT.FIXED_TENDON_LIMIT) + binding.write(flat_src, indices=env_ids) def set_fixed_tendon_position_limit_mask( self, @@ -1253,17 +2777,46 @@ def set_fixed_tendon_position_limit_mask( fixed_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set fixed tendon position limits into internal buffers using masks. + """Set fixed-tendon position limits over selected env / tendon masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_LIMIT`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - limit: Fixed tendon position limits. Shape is (num_instances, num_fixed_tendons) - with dtype wp.vec2f. - fixed_tendon_mask: Tendon mask. If None, then all fixed tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. + limit: Fixed-tendon position limits ``[lower, upper]`` [m]. + Shape is (num_instances, num_fixed_tendons, 2) with dtype wp.float32. + fixed_tendon_mask: Fixed-tendon mask. If None, all fixed tendons are updated. + Shape is (num_fixed_tendons,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(limit, (self._num_instances, self._nft()), wp.vec2f, "limit") - if self._data._fixed_tendon_pos_limits is not None: - self._set_target_into_buffer_mask(self._data._fixed_tendon_pos_limits, limit, env_mask, fixed_tendon_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + tendon_mask_wp = self._resolve_fixed_tendon_mask(fixed_tendon_mask) + self.assert_shape_and_dtype(limit, (self._num_instances, self._num_fixed_tendons, 2), wp.float32, "limit") + wp.launch( + shared_kernels.write_joint_position_limit_to_buffer_mask, + dim=(self._num_instances, self._num_fixed_tendons), + inputs=[limit, env_mask_wp, tendon_mask_wp], + outputs=[self._data._fixed_tendon_pos_limits.data], + device=self._device, + ) + flat_src = wp.array( + ptr=self._data._fixed_tendon_pos_limits.data.ptr, + shape=(self._num_instances, self._num_fixed_tendons, 2), + dtype=wp.float32, + device=self._device, + copy=False, + ) + binding = self._get_binding(TT.FIXED_TENDON_LIMIT) + binding.write(flat_src, mask=env_mask_wp) def set_fixed_tendon_rest_length_index( self, @@ -1272,18 +2825,40 @@ def set_fixed_tendon_rest_length_index( fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set fixed tendon rest length into internal buffers using indices. + """Set fixed-tendon rest lengths over selected env / tendon indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_REST_LENGTH`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - rest_length: Fixed tendon rest length. Shape is (len(env_ids), len(fixed_tendon_ids)). - fixed_tendon_ids: The tendon indices. Defaults to None (all fixed tendons). - env_ids: Environment indices. If None, then all indices are used. + rest_length: Fixed-tendon rest lengths [m]. May be a scalar + :class:`float` (broadcast), or shape + (len(env_ids), len(fixed_tendon_ids)) with dtype wp.float32. + fixed_tendon_ids: Fixed-tendon indices. Defaults to None (all fixed tendons). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - t = len(fixed_tendon_ids) if fixed_tendon_ids else self._nft() - self.assert_shape_and_dtype(rest_length, (n, t), wp.float32, "rest_length") - if self._data._fixed_tendon_rest_length is not None: - self._set_target_into_buffer(self._data._fixed_tendon_rest_length, rest_length, env_ids, fixed_tendon_ids) + env_ids = self._resolve_env_ids(env_ids) + tendon_ids = self._resolve_fixed_tendon_ids(fixed_tendon_ids) + shape = (env_ids.shape[0], tendon_ids.shape[0]) + rest_length = self._broadcast_scalar_to_2d(rest_length, shape) + self.assert_shape_and_dtype(rest_length, shape, wp.float32, "rest_length") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=shape, + inputs=[rest_length, env_ids, tendon_ids], + outputs=[self._data._fixed_tendon_rest_length.data], + device=self._device, + ) + binding = self._get_binding(TT.FIXED_TENDON_REST_LENGTH) + binding.write(self._data._fixed_tendon_rest_length.data, indices=env_ids) def set_fixed_tendon_rest_length_mask( self, @@ -1292,18 +2867,42 @@ def set_fixed_tendon_rest_length_mask( fixed_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set fixed tendon rest length into internal buffers using masks. + """Set fixed-tendon rest lengths over selected env / tendon masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_REST_LENGTH`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - rest_length: Fixed tendon rest length. Shape is (num_instances, num_fixed_tendons). - fixed_tendon_mask: Tendon mask. If None, then all fixed tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. + rest_length: Fixed-tendon rest lengths [m]. May be a scalar + :class:`float` (broadcast), or shape + (num_instances, num_fixed_tendons) with dtype wp.float32. + fixed_tendon_mask: Fixed-tendon mask. If None, all fixed tendons are updated. + Shape is (num_fixed_tendons,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(rest_length, (self._num_instances, self._nft()), wp.float32, "rest_length") - if self._data._fixed_tendon_rest_length is not None: - self._set_target_into_buffer_mask( - self._data._fixed_tendon_rest_length, rest_length, env_mask, fixed_tendon_mask - ) + env_mask_wp = self._resolve_env_mask(env_mask) + tendon_mask_wp = self._resolve_fixed_tendon_mask(fixed_tendon_mask) + shape = (self._num_instances, self._num_fixed_tendons) + rest_length = self._broadcast_scalar_to_2d(rest_length, shape) + self.assert_shape_and_dtype(rest_length, shape, wp.float32, "rest_length") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[rest_length, env_mask_wp, tendon_mask_wp], + outputs=[self._data._fixed_tendon_rest_length.data], + device=self._device, + ) + binding = self._get_binding(TT.FIXED_TENDON_REST_LENGTH) + binding.write(self._data._fixed_tendon_rest_length.data, mask=env_mask_wp) def set_fixed_tendon_offset_index( self, @@ -1312,18 +2911,40 @@ def set_fixed_tendon_offset_index( fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set fixed tendon offset into internal buffers using indices. + """Set fixed-tendon offsets over selected env / tendon indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_OFFSET`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - offset: Fixed tendon offset. Shape is (len(env_ids), len(fixed_tendon_ids)). - fixed_tendon_ids: The tendon indices. Defaults to None (all fixed tendons). - env_ids: Environment indices. If None, then all indices are used. + offset: Fixed-tendon offsets [m]. May be a scalar :class:`float` + (broadcast), or shape (len(env_ids), len(fixed_tendon_ids)) + with dtype wp.float32. + fixed_tendon_ids: Fixed-tendon indices. Defaults to None (all fixed tendons). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - t = len(fixed_tendon_ids) if fixed_tendon_ids else self._nft() - self.assert_shape_and_dtype(offset, (n, t), wp.float32, "offset") - if self._data._fixed_tendon_offset is not None: - self._set_target_into_buffer(self._data._fixed_tendon_offset, offset, env_ids, fixed_tendon_ids) + env_ids = self._resolve_env_ids(env_ids) + tendon_ids = self._resolve_fixed_tendon_ids(fixed_tendon_ids) + shape = (env_ids.shape[0], tendon_ids.shape[0]) + offset = self._broadcast_scalar_to_2d(offset, shape) + self.assert_shape_and_dtype(offset, shape, wp.float32, "offset") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=shape, + inputs=[offset, env_ids, tendon_ids], + outputs=[self._data._fixed_tendon_offset.data], + device=self._device, + ) + binding = self._get_binding(TT.FIXED_TENDON_OFFSET) + binding.write(self._data._fixed_tendon_offset.data, indices=env_ids) def set_fixed_tendon_offset_mask( self, @@ -1332,16 +2953,42 @@ def set_fixed_tendon_offset_mask( fixed_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set fixed tendon offset into internal buffers using masks. + """Set fixed-tendon offsets over selected env / tendon masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``FIXED_TENDON_OFFSET`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - offset: Fixed tendon offset. Shape is (num_instances, num_fixed_tendons). - fixed_tendon_mask: Tendon mask. If None, then all fixed tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. + offset: Fixed-tendon offsets [m]. May be a scalar :class:`float` + (broadcast), or shape (num_instances, num_fixed_tendons) with + dtype wp.float32. + fixed_tendon_mask: Fixed-tendon mask. If None, all fixed tendons are updated. + Shape is (num_fixed_tendons,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(offset, (self._num_instances, self._nft()), wp.float32, "offset") - if self._data._fixed_tendon_offset is not None: - self._set_target_into_buffer_mask(self._data._fixed_tendon_offset, offset, env_mask, fixed_tendon_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + tendon_mask_wp = self._resolve_fixed_tendon_mask(fixed_tendon_mask) + shape = (self._num_instances, self._num_fixed_tendons) + offset = self._broadcast_scalar_to_2d(offset, shape) + self.assert_shape_and_dtype(offset, shape, wp.float32, "offset") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[offset, env_mask_wp, tendon_mask_wp], + outputs=[self._data._fixed_tendon_offset.data], + device=self._device, + ) + binding = self._get_binding(TT.FIXED_TENDON_OFFSET) + binding.write(self._data._fixed_tendon_offset.data, mask=env_mask_wp) def write_fixed_tendon_properties_to_sim_index( self, @@ -1349,25 +2996,44 @@ def write_fixed_tendon_properties_to_sim_index( fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Write fixed tendon properties into the simulation using indices. + """Push the cached fixed-tendon properties to the simulation in a single batch. + + PhysX exposes a single ``root_view.set_fixed_tendon_properties`` that writes all + six tendon property buffers at once. OVPhysX has no such batch setter, so this + method writes each ``FIXED_TENDON_*`` binding individually from the matching + ``self._data._fixed_tendon_*`` buffer. + + .. note:: + Only env indices apply to the simulation write; ``fixed_tendon_ids`` is + accepted for API parity with PhysX but is unused (the simulation + writes all tendons of the selected envs). Args: - fixed_tendon_ids: The fixed tendon indices to write the properties for. Defaults to None - (all fixed tendons). - env_ids: Environment indices. If None, then all indices are used. + fixed_tendon_ids: Accepted for PhysX API parity; ignored. + env_ids: Environment indices. If None, all environments are written. """ - if self._nft() == 0: - return - for tt, buf in [ + env_ids = self._resolve_env_ids(env_ids) + for tt, buf in ( (TT.FIXED_TENDON_STIFFNESS, self._data._fixed_tendon_stiffness), (TT.FIXED_TENDON_DAMPING, self._data._fixed_tendon_damping), (TT.FIXED_TENDON_LIMIT_STIFFNESS, self._data._fixed_tendon_limit_stiffness), - (TT.FIXED_TENDON_LIMIT, self._data._fixed_tendon_pos_limits), (TT.FIXED_TENDON_REST_LENGTH, self._data._fixed_tendon_rest_length), (TT.FIXED_TENDON_OFFSET, self._data._fixed_tendon_offset), - ]: - if buf is not None: - self._write_flat_tensor(tt, buf, env_ids, fixed_tendon_ids) + ): + binding = self._get_binding(tt) + if binding is not None: + binding.write(buf.data, indices=env_ids) + # Position-limit binding consumes a flat (N, T, 2) float32 view. + binding = self._get_binding(TT.FIXED_TENDON_LIMIT) + if binding is not None: + flat_src = wp.array( + ptr=self._data._fixed_tendon_pos_limits.data.ptr, + shape=(self._num_instances, self._num_fixed_tendons, 2), + dtype=wp.float32, + device=self._device, + copy=False, + ) + binding.write(flat_src, indices=env_ids) def write_fixed_tendon_properties_to_sim_mask( self, @@ -1375,24 +3041,33 @@ def write_fixed_tendon_properties_to_sim_mask( fixed_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Write fixed tendon properties into the simulation using masks. + """Mask variant of :meth:`write_fixed_tendon_properties_to_sim_index`. Args: - fixed_tendon_mask: Fixed tendon mask. If None, then all fixed tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. + fixed_tendon_mask: Accepted for PhysX API parity; ignored. + env_mask: Environment mask. If None, all environments are written. """ - if self._nft() == 0: - return - for tt, buf in [ + env_mask_wp = self._resolve_env_mask(env_mask) + for tt, buf in ( (TT.FIXED_TENDON_STIFFNESS, self._data._fixed_tendon_stiffness), (TT.FIXED_TENDON_DAMPING, self._data._fixed_tendon_damping), (TT.FIXED_TENDON_LIMIT_STIFFNESS, self._data._fixed_tendon_limit_stiffness), - (TT.FIXED_TENDON_LIMIT, self._data._fixed_tendon_pos_limits), (TT.FIXED_TENDON_REST_LENGTH, self._data._fixed_tendon_rest_length), (TT.FIXED_TENDON_OFFSET, self._data._fixed_tendon_offset), - ]: - if buf is not None: - self._write_flat_tensor_mask(tt, buf, env_mask, fixed_tendon_mask) + ): + binding = self._get_binding(tt) + if binding is not None: + binding.write(buf.data, mask=env_mask_wp) + binding = self._get_binding(TT.FIXED_TENDON_LIMIT) + if binding is not None: + flat_src = wp.array( + ptr=self._data._fixed_tendon_pos_limits.data.ptr, + shape=(self._num_instances, self._num_fixed_tendons, 2), + dtype=wp.float32, + device=self._device, + copy=False, + ) + binding.write(flat_src, mask=env_mask_wp) def set_spatial_tendon_stiffness_index( self, @@ -1401,18 +3076,41 @@ def set_spatial_tendon_stiffness_index( spatial_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set spatial tendon stiffness into internal buffers using indices. + """Set spatial-tendon stiffness over selected env / tendon indices into the simulation. + + ``SPATIAL_TENDON_STIFFNESS`` is a sim-device binding on OVPhysX + (tendon properties are applied without a CPU clone), so the write + goes directly from the sim-device buffer to the binding. + + .. note:: + This method expects partial data. A scalar :class:`float` is + broadcast to ``(len(env_ids), len(spatial_tendon_ids))``. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - stiffness: Spatial tendon stiffness. Shape is (len(env_ids), len(spatial_tendon_ids)). - spatial_tendon_ids: The tendon indices. Defaults to None (all spatial tendons). - env_ids: Environment indices. If None, then all indices are used. + stiffness: Spatial-tendon stiffness [N/m]. Scalar :class:`float`, + or shape ``(len(env_ids), len(spatial_tendon_ids))`` with + dtype wp.float32. + spatial_tendon_ids: Spatial-tendon indices. Defaults to None (all spatial tendons). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - t = len(spatial_tendon_ids) if spatial_tendon_ids else self._nst() - self.assert_shape_and_dtype(stiffness, (n, t), wp.float32, "stiffness") - if self._data._spatial_tendon_stiffness is not None: - self._set_target_into_buffer(self._data._spatial_tendon_stiffness, stiffness, env_ids, spatial_tendon_ids) + env_ids = self._resolve_env_ids(env_ids) + tendon_ids = self._resolve_spatial_tendon_ids(spatial_tendon_ids) + stiffness = self._broadcast_scalar_to_2d(stiffness, (env_ids.shape[0], tendon_ids.shape[0])) + self.assert_shape_and_dtype(stiffness, (env_ids.shape[0], tendon_ids.shape[0]), wp.float32, "stiffness") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], tendon_ids.shape[0]), + inputs=[stiffness, env_ids, tendon_ids], + outputs=[self._data._spatial_tendon_stiffness.data], + device=self._device, + ) + binding = self._get_binding(TT.SPATIAL_TENDON_STIFFNESS) + binding.write(self._data._spatial_tendon_stiffness.data, indices=env_ids) def set_spatial_tendon_stiffness_mask( self, @@ -1421,18 +3119,42 @@ def set_spatial_tendon_stiffness_mask( spatial_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set spatial tendon stiffness into internal buffers using masks. + """Set spatial-tendon stiffness over selected env / tendon masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``SPATIAL_TENDON_STIFFNESS`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - stiffness: Spatial tendon stiffness. Shape is (num_instances, num_spatial_tendons). - spatial_tendon_mask: Tendon mask. If None, then all spatial tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. + stiffness: Spatial-tendon stiffness [N/m]. May be a scalar + :class:`float` (broadcast), or shape + (num_instances, num_spatial_tendons) with dtype wp.float32. + spatial_tendon_mask: Spatial-tendon mask. If None, all spatial tendons are updated. + Shape is (num_spatial_tendons,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(stiffness, (self._num_instances, self._nst()), wp.float32, "stiffness") - if self._data._spatial_tendon_stiffness is not None: - self._set_target_into_buffer_mask( - self._data._spatial_tendon_stiffness, stiffness, env_mask, spatial_tendon_mask - ) + env_mask_wp = self._resolve_env_mask(env_mask) + tendon_mask_wp = self._resolve_spatial_tendon_mask(spatial_tendon_mask) + shape = (self._num_instances, self._num_spatial_tendons) + stiffness = self._broadcast_scalar_to_2d(stiffness, shape) + self.assert_shape_and_dtype(stiffness, shape, wp.float32, "stiffness") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[stiffness, env_mask_wp, tendon_mask_wp], + outputs=[self._data._spatial_tendon_stiffness.data], + device=self._device, + ) + binding = self._get_binding(TT.SPATIAL_TENDON_STIFFNESS) + binding.write(self._data._spatial_tendon_stiffness.data, mask=env_mask_wp) def set_spatial_tendon_damping_index( self, @@ -1441,18 +3163,38 @@ def set_spatial_tendon_damping_index( spatial_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set spatial tendon damping into internal buffers using indices. + """Set spatial-tendon damping over selected env / tendon indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``SPATIAL_TENDON_DAMPING`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - damping: Spatial tendon damping. Shape is (len(env_ids), len(spatial_tendon_ids)). - spatial_tendon_ids: The tendon indices. Defaults to None (all spatial tendons). - env_ids: Environment indices. If None, then all indices are used. + damping: Spatial-tendon damping [N·s/m]. Shape is + (len(env_ids), len(spatial_tendon_ids)) with dtype wp.float32. + spatial_tendon_ids: Spatial-tendon indices. Defaults to None (all spatial tendons). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - t = len(spatial_tendon_ids) if spatial_tendon_ids else self._nst() - self.assert_shape_and_dtype(damping, (n, t), wp.float32, "damping") - if self._data._spatial_tendon_damping is not None: - self._set_target_into_buffer(self._data._spatial_tendon_damping, damping, env_ids, spatial_tendon_ids) + env_ids = self._resolve_env_ids(env_ids) + tendon_ids = self._resolve_spatial_tendon_ids(spatial_tendon_ids) + damping = self._broadcast_scalar_to_2d(damping, (env_ids.shape[0], tendon_ids.shape[0])) + self.assert_shape_and_dtype(damping, (env_ids.shape[0], tendon_ids.shape[0]), wp.float32, "damping") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], tendon_ids.shape[0]), + inputs=[damping, env_ids, tendon_ids], + outputs=[self._data._spatial_tendon_damping.data], + device=self._device, + ) + binding = self._get_binding(TT.SPATIAL_TENDON_DAMPING) + binding.write(self._data._spatial_tendon_damping.data, indices=env_ids) def set_spatial_tendon_damping_mask( self, @@ -1461,18 +3203,42 @@ def set_spatial_tendon_damping_mask( spatial_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set spatial tendon damping into internal buffers using masks. + """Set spatial-tendon damping over selected env / tendon masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``SPATIAL_TENDON_DAMPING`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - damping: Spatial tendon damping. Shape is (num_instances, num_spatial_tendons). - spatial_tendon_mask: Tendon mask. If None, then all spatial tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. + damping: Spatial-tendon damping [N·s/m]. May be a scalar + :class:`float` (broadcast), or shape + (num_instances, num_spatial_tendons) with dtype wp.float32. + spatial_tendon_mask: Spatial-tendon mask. If None, all spatial tendons are updated. + Shape is (num_spatial_tendons,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(damping, (self._num_instances, self._nst()), wp.float32, "damping") - if self._data._spatial_tendon_damping is not None: - self._set_target_into_buffer_mask( - self._data._spatial_tendon_damping, damping, env_mask, spatial_tendon_mask - ) + env_mask_wp = self._resolve_env_mask(env_mask) + tendon_mask_wp = self._resolve_spatial_tendon_mask(spatial_tendon_mask) + shape = (self._num_instances, self._num_spatial_tendons) + damping = self._broadcast_scalar_to_2d(damping, shape) + self.assert_shape_and_dtype(damping, shape, wp.float32, "damping") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[damping, env_mask_wp, tendon_mask_wp], + outputs=[self._data._spatial_tendon_damping.data], + device=self._device, + ) + binding = self._get_binding(TT.SPATIAL_TENDON_DAMPING) + binding.write(self._data._spatial_tendon_damping.data, mask=env_mask_wp) def set_spatial_tendon_limit_stiffness_index( self, @@ -1481,20 +3247,42 @@ def set_spatial_tendon_limit_stiffness_index( spatial_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set spatial tendon limit stiffness into internal buffers using indices. + """Set spatial-tendon limit stiffness over selected env / tendon indices into the simulation. + + ``SPATIAL_TENDON_LIMIT_STIFFNESS`` is a sim-device binding on OVPhysX; + the write goes directly from the sim-device buffer to the binding. + + .. note:: + This method expects partial data. A scalar :class:`float` is + broadcast to ``(len(env_ids), len(spatial_tendon_ids))``. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - limit_stiffness: Spatial tendon limit stiffness. Shape is (len(env_ids), len(spatial_tendon_ids)). - spatial_tendon_ids: The tendon indices. Defaults to None (all spatial tendons). - env_ids: Environment indices. If None, then all indices are used. + limit_stiffness: Spatial-tendon limit stiffness [N/m]. Scalar + :class:`float`, or shape ``(len(env_ids), len(spatial_tendon_ids))`` + with dtype wp.float32. + spatial_tendon_ids: Spatial-tendon indices. Defaults to None (all spatial tendons). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - t = len(spatial_tendon_ids) if spatial_tendon_ids else self._nst() - self.assert_shape_and_dtype(limit_stiffness, (n, t), wp.float32, "limit_stiffness") - if self._data._spatial_tendon_limit_stiffness is not None: - self._set_target_into_buffer( - self._data._spatial_tendon_limit_stiffness, limit_stiffness, env_ids, spatial_tendon_ids - ) + env_ids = self._resolve_env_ids(env_ids) + tendon_ids = self._resolve_spatial_tendon_ids(spatial_tendon_ids) + limit_stiffness = self._broadcast_scalar_to_2d(limit_stiffness, (env_ids.shape[0], tendon_ids.shape[0])) + self.assert_shape_and_dtype( + limit_stiffness, (env_ids.shape[0], tendon_ids.shape[0]), wp.float32, "limit_stiffness" + ) + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], tendon_ids.shape[0]), + inputs=[limit_stiffness, env_ids, tendon_ids], + outputs=[self._data._spatial_tendon_limit_stiffness.data], + device=self._device, + ) + binding = self._get_binding(TT.SPATIAL_TENDON_LIMIT_STIFFNESS) + binding.write(self._data._spatial_tendon_limit_stiffness.data, indices=env_ids) def set_spatial_tendon_limit_stiffness_mask( self, @@ -1503,18 +3291,42 @@ def set_spatial_tendon_limit_stiffness_mask( spatial_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set spatial tendon limit stiffness into internal buffers using masks. + """Set spatial-tendon limit stiffness over selected env / tendon masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``SPATIAL_TENDON_LIMIT_STIFFNESS`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - limit_stiffness: Spatial tendon limit stiffness. Shape is (num_instances, num_spatial_tendons). - spatial_tendon_mask: Tendon mask. If None, then all spatial tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. + limit_stiffness: Spatial-tendon limit stiffness [N/m]. May be a + scalar :class:`float` (broadcast), or shape + (num_instances, num_spatial_tendons) with dtype wp.float32. + spatial_tendon_mask: Spatial-tendon mask. If None, all spatial tendons are updated. + Shape is (num_spatial_tendons,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(limit_stiffness, (self._num_instances, self._nst()), wp.float32, "limit_stiffness") - if self._data._spatial_tendon_limit_stiffness is not None: - self._set_target_into_buffer_mask( - self._data._spatial_tendon_limit_stiffness, limit_stiffness, env_mask, spatial_tendon_mask - ) + env_mask_wp = self._resolve_env_mask(env_mask) + tendon_mask_wp = self._resolve_spatial_tendon_mask(spatial_tendon_mask) + shape = (self._num_instances, self._num_spatial_tendons) + limit_stiffness = self._broadcast_scalar_to_2d(limit_stiffness, shape) + self.assert_shape_and_dtype(limit_stiffness, shape, wp.float32, "limit_stiffness") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[limit_stiffness, env_mask_wp, tendon_mask_wp], + outputs=[self._data._spatial_tendon_limit_stiffness.data], + device=self._device, + ) + binding = self._get_binding(TT.SPATIAL_TENDON_LIMIT_STIFFNESS) + binding.write(self._data._spatial_tendon_limit_stiffness.data, mask=env_mask_wp) def set_spatial_tendon_offset_index( self, @@ -1523,18 +3335,40 @@ def set_spatial_tendon_offset_index( spatial_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set spatial tendon offset into internal buffers using indices. + """Set spatial-tendon offsets over selected env / tendon indices into the simulation. + + ``SPATIAL_TENDON_OFFSET`` is a sim-device binding on OVPhysX; the + write goes directly from the sim-device buffer to the binding. + + .. note:: + This method expects partial data. A scalar :class:`float` is + broadcast to ``(len(env_ids), len(spatial_tendon_ids))``. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - offset: Spatial tendon offset. Shape is (len(env_ids), len(spatial_tendon_ids)). - spatial_tendon_ids: The tendon indices. Defaults to None (all spatial tendons). - env_ids: Environment indices. If None, then all indices are used. + offset: Spatial-tendon offsets [m]. Scalar :class:`float`, or + shape ``(len(env_ids), len(spatial_tendon_ids))`` with + dtype wp.float32. + spatial_tendon_ids: Spatial-tendon indices. Defaults to None (all spatial tendons). + env_ids: Environment indices. Defaults to None (all environments). """ - n = self._n_envs_index(env_ids) - t = len(spatial_tendon_ids) if spatial_tendon_ids else self._nst() - self.assert_shape_and_dtype(offset, (n, t), wp.float32, "offset") - if self._data._spatial_tendon_offset is not None: - self._set_target_into_buffer(self._data._spatial_tendon_offset, offset, env_ids, spatial_tendon_ids) + env_ids = self._resolve_env_ids(env_ids) + tendon_ids = self._resolve_spatial_tendon_ids(spatial_tendon_ids) + offset = self._broadcast_scalar_to_2d(offset, (env_ids.shape[0], tendon_ids.shape[0])) + self.assert_shape_and_dtype(offset, (env_ids.shape[0], tendon_ids.shape[0]), wp.float32, "offset") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], tendon_ids.shape[0]), + inputs=[offset, env_ids, tendon_ids], + outputs=[self._data._spatial_tendon_offset.data], + device=self._device, + ) + binding = self._get_binding(TT.SPATIAL_TENDON_OFFSET) + binding.write(self._data._spatial_tendon_offset.data, indices=env_ids) def set_spatial_tendon_offset_mask( self, @@ -1543,16 +3377,42 @@ def set_spatial_tendon_offset_mask( spatial_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set spatial tendon offset into internal buffers using masks. + """Set spatial-tendon offsets over selected env / tendon masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``SPATIAL_TENDON_OFFSET`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + .. tip:: + Both the index and mask methods have dedicated optimized implementations. + Performance is similar for both. However, to allow graphed pipelines, the + mask method must be used. Args: - offset: Spatial tendon offset. Shape is (num_instances, num_spatial_tendons). - spatial_tendon_mask: Tendon mask. If None, then all spatial tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. + offset: Spatial-tendon offsets [m]. May be a scalar :class:`float` + (broadcast), or shape (num_instances, num_spatial_tendons) with + dtype wp.float32. + spatial_tendon_mask: Spatial-tendon mask. If None, all spatial tendons are updated. + Shape is (num_spatial_tendons,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). """ - self.assert_shape_and_dtype(offset, (self._num_instances, self._nst()), wp.float32, "offset") - if self._data._spatial_tendon_offset is not None: - self._set_target_into_buffer_mask(self._data._spatial_tendon_offset, offset, env_mask, spatial_tendon_mask) + env_mask_wp = self._resolve_env_mask(env_mask) + tendon_mask_wp = self._resolve_spatial_tendon_mask(spatial_tendon_mask) + shape = (self._num_instances, self._num_spatial_tendons) + offset = self._broadcast_scalar_to_2d(offset, shape) + self.assert_shape_and_dtype(offset, shape, wp.float32, "offset") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=shape, + inputs=[offset, env_mask_wp, tendon_mask_wp], + outputs=[self._data._spatial_tendon_offset.data], + device=self._device, + ) + binding = self._get_binding(TT.SPATIAL_TENDON_OFFSET) + binding.write(self._data._spatial_tendon_offset.data, mask=env_mask_wp) def write_spatial_tendon_properties_to_sim_index( self, @@ -1560,23 +3420,28 @@ def write_spatial_tendon_properties_to_sim_index( spatial_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Write spatial tendon properties into the simulation using indices. + """Push the cached spatial-tendon properties to the simulation in a single batch. + + Mirrors :meth:`write_fixed_tendon_properties_to_sim_index` for + spatial tendons. Only the four wheel-supported tensor types are + written; ``ARTICULATION_SPATIAL_TENDON_LIMIT`` and + ``ARTICULATION_SPATIAL_TENDON_REST_LENGTH`` are forward-compat + stubs (see ``docs/superpowers/specs/2026-04-28-ovphysx-wheel-gaps-for-marco.md``). Args: - spatial_tendon_ids: The spatial tendon indices to write the properties for. Defaults to None - (all spatial tendons). - env_ids: Environment indices. If None, then all indices are used. + spatial_tendon_ids: Accepted for PhysX API parity; ignored. + env_ids: Environment indices. If None, all environments are written. """ - if self._nst() == 0: - return - for tt, buf in [ + env_ids = self._resolve_env_ids(env_ids) + for tt, buf in ( (TT.SPATIAL_TENDON_STIFFNESS, self._data._spatial_tendon_stiffness), (TT.SPATIAL_TENDON_DAMPING, self._data._spatial_tendon_damping), (TT.SPATIAL_TENDON_LIMIT_STIFFNESS, self._data._spatial_tendon_limit_stiffness), (TT.SPATIAL_TENDON_OFFSET, self._data._spatial_tendon_offset), - ]: - if buf is not None: - self._write_flat_tensor(tt, buf, env_ids, spatial_tendon_ids) + ): + binding = self._get_binding(tt) + if binding is not None: + binding.write(buf.data, indices=env_ids) def write_spatial_tendon_properties_to_sim_mask( self, @@ -1584,138 +3449,99 @@ def write_spatial_tendon_properties_to_sim_mask( spatial_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Write spatial tendon properties into the simulation using masks. - - Args: - spatial_tendon_mask: Spatial tendon mask. If None, then all spatial tendons are used. - env_mask: Environment mask. If None, then all the instances are updated. - """ - if self._nst() == 0: - return - for tt, buf in [ + """Mask variant of :meth:`write_spatial_tendon_properties_to_sim_index`.""" + env_mask_wp = self._resolve_env_mask(env_mask) + for tt, buf in ( (TT.SPATIAL_TENDON_STIFFNESS, self._data._spatial_tendon_stiffness), (TT.SPATIAL_TENDON_DAMPING, self._data._spatial_tendon_damping), (TT.SPATIAL_TENDON_LIMIT_STIFFNESS, self._data._spatial_tendon_limit_stiffness), (TT.SPATIAL_TENDON_OFFSET, self._data._spatial_tendon_offset), - ]: - if buf is not None: - self._write_flat_tensor_mask(tt, buf, env_mask, spatial_tendon_mask) - - """ - Deprecated methods. - """ - - def write_root_state_to_sim( - self, - root_state: torch.Tensor | wp.array, - env_ids: Sequence[int] | wp.array | None = None, - ) -> None: - """Deprecated in base class. Use :meth:`write_root_pose_to_sim_index` and - :meth:`write_root_velocity_to_sim_index` instead.""" - self._write_root_state(TT.ROOT_POSE, root_state[:, :7], env_ids) - self._write_root_state(TT.ROOT_VELOCITY, root_state[:, 7:], env_ids) - - def write_root_com_state_to_sim( - self, - root_state: torch.Tensor | wp.array, - env_ids: Sequence[int] | wp.array | None = None, - ) -> None: - """Deprecated in base class. Use :meth:`write_root_com_pose_to_sim_index` and - :meth:`write_root_com_velocity_to_sim_index` instead.""" - self._write_root_state(TT.ROOT_POSE, root_state[:, :7], env_ids) - self._write_root_state(TT.ROOT_VELOCITY, root_state[:, 7:], env_ids) - - def write_root_link_state_to_sim( - self, - root_state: torch.Tensor | wp.array, - env_ids: Sequence[int] | wp.array | None = None, - ) -> None: - """Deprecated in base class. Use :meth:`write_root_link_pose_to_sim_index` and - :meth:`write_root_link_velocity_to_sim_index` instead.""" - self._write_root_state(TT.ROOT_POSE, root_state[:, :7], env_ids) - self._write_root_state(TT.ROOT_VELOCITY, root_state[:, 7:], env_ids) - - def write_joint_state_to_sim( - self, - position: torch.Tensor | wp.array, - velocity: torch.Tensor | wp.array, - joint_ids: Sequence[int] | None = None, - env_ids: Sequence[int] | wp.array | None = None, - ) -> None: - """Deprecated in base class. Use :meth:`write_joint_position_to_sim_index` and - :meth:`write_joint_velocity_to_sim_index` instead.""" - self.write_joint_position_to_sim_index(position=position, joint_ids=joint_ids, env_ids=env_ids) - self.write_joint_velocity_to_sim_index(velocity=velocity, joint_ids=joint_ids, env_ids=env_ids) + ): + binding = self._get_binding(tt) + if binding is not None: + binding.write(buf.data, mask=env_mask_wp) """ Internal helper. """ def _initialize_impl(self) -> None: + """Initialize the articulation from the OVPhysX simulation backend.""" + # obtain global simulation view physx_instance = OvPhysxManager.get_physx_instance() if physx_instance is None: raise RuntimeError("OvPhysxManager has not been initialized yet.") + self._ovphysx = physx_instance + self._device = OvPhysxManager.get_device() + # IsaacLab uses two conventions for env-glob prim paths: + # /World/envs/env_.*/Robot -- regex dot-star for "any env index" + # /World/envs/{ENV_REGEX_NS}/... -- explicit placeholder + # ovphysx ``create_tensor_binding`` expects fnmatch-style globs, so both map to '*'. prim_path = self.cfg.prim_path - # Convert IsaacLab prim-path notation to the glob patterns ovphysx expects. - # IsaacLab uses two conventions: - # /World/envs/env_.*/Robot -- regex dot-star for "any env index" - # /World/envs/{ENV_REGEX_NS}/Robot -- explicit placeholder - # ovphysx create_tensor_binding() uses fnmatch-style globs, so both map to '*'. pattern = re.sub(r"\{ENV_REGEX_NS\}", "*", prim_path) - pattern = re.sub(r"\.\*", "*", pattern) # env_.* -> env_* - - # The pattern above points to the ArticulationCfg prim (e.g. /World/envs/env_*/Robot). - # However, PhysicsArticulationRootAPI may be on a CHILD prim (e.g. /Robot/torso) - # rather than on the prim itself. create_tensor_binding() only matches prims that - # *have* PhysicsArticulationRootAPI, so we need to extend the pattern to the actual - # articulation root. Mirror the PhysX backend's discovery logic: find the first - # matching prim in the USD stage, walk its subtree for the articulation root, and - # append the relative suffix to the glob pattern. - from pxr import UsdPhysics - - from isaaclab.sim.utils.queries import find_first_matching_prim, get_all_matching_child_prims + pattern = re.sub(r"\.\*", "*", pattern) + # ``PhysicsArticulationRootAPI`` may live on a CHILD prim rather than on + # the cfg prim itself. ``create_tensor_binding`` only matches prims that + # have the API applied, so the pattern must be extended to the actual + # articulation root. stage = PhysicsManager._sim.stage - first_prim = find_first_matching_prim(prim_path, stage=stage) - if first_prim is None: - raise RuntimeError(f"OvPhysxManager: no prim found for path '{prim_path}'.") - first_prim_path = first_prim.GetPath().pathString - - root_prims = get_all_matching_child_prims( - first_prim_path, - predicate=lambda p: p.HasAPI(UsdPhysics.ArticulationRootAPI), - traverse_instance_prims=False, - ) - if len(root_prims) == 0: - raise RuntimeError( - f"No prim with PhysicsArticulationRootAPI found under '{first_prim_path}'." - " Check that the articulation has 'PhysicsArticulationRootAPI' applied." - ) - if len(root_prims) > 1: - raise RuntimeError( - f"Multiple articulation roots found under '{first_prim_path}': {root_prims}." - " There must be exactly one articulation root per prim path." - ) - self._articulation_root_path = root_prims[0].GetPath().pathString - root_relative = self._articulation_root_path[len(first_prim_path) :] - if root_relative: - # e.g. first_prim_path=/World/envs/env_0/Robot, root_relative=/torso - # pattern becomes /World/envs/env_*/Robot/torso + if self.cfg.articulation_root_prim_path is not None: + # explicit subpath: skip auto-discovery but validate the prim exists + root_relative = self.cfg.articulation_root_prim_path + self._articulation_root_path = prim_path + root_relative + if sim_utils.find_first_matching_prim(self._articulation_root_path, stage=stage) is None: + raise RuntimeError( + f"Failed to find articulation root prim at '{self._articulation_root_path}'." + " Check that ``cfg.articulation_root_prim_path`` points at a prim that exists" + " in the USD stage." + ) pattern = pattern + root_relative - logger.info("OvPhysxManager: articulation root at '%s' (pattern extended to '%s')", root_relative, pattern) + logger.info("OvPhysxManager: explicit articulation root '%s' (pattern '%s')", root_relative, pattern) + else: + first_prim = sim_utils.find_first_matching_prim(prim_path, stage=stage) + if first_prim is None: + raise RuntimeError(f"Failed to find prim for expression: '{prim_path}'.") + first_prim_path = first_prim.GetPath().pathString + + root_prims = sim_utils.get_all_matching_child_prims( + first_prim_path, + predicate=lambda p: p.HasAPI(UsdPhysics.ArticulationRootAPI), + traverse_instance_prims=False, + ) + if len(root_prims) == 0: + raise RuntimeError( + f"Failed to find an articulation root when resolving '{prim_path}'." + " Ensure the prim has 'USD ArticulationRootAPI' applied." + ) + if len(root_prims) > 1: + raise RuntimeError( + f"Failed to find a single articulation root when resolving '{prim_path}'." + f" Found multiple under '{first_prim_path}'." + ) + + self._articulation_root_path = root_prims[0].GetPath().pathString + root_relative = self._articulation_root_path[len(first_prim_path) :] + if root_relative: + pattern = pattern + root_relative + logger.info( + "OvPhysxManager: articulation root at '%s' (pattern extended to '%s')", root_relative, pattern + ) - # Bindings are created lazily (on first access) to avoid allocating - # handles for tensor types the user never queries. Only the root-pose - # binding is created eagerly because we need it to read articulation - # metadata (joint count, body count, names, fixed-base flag). - self._bindings: dict[int, Any] = {} - self._physx_instance = physx_instance self._binding_pattern = pattern + # eagerly create every binding the data container reads at init, so + # failures surface here rather than as KeyError downstream eager_types = [ TT.ROOT_POSE, + TT.ROOT_VELOCITY, + TT.LINK_POSE, + TT.LINK_VELOCITY, + TT.LINK_ACCELERATION, + TT.LINK_INCOMING_JOINT_FORCE, TT.DOF_POSITION, + TT.DOF_VELOCITY, TT.DOF_STIFFNESS, TT.DOF_DAMPING, TT.DOF_LIMIT, @@ -1733,6 +3559,14 @@ def _initialize_impl(self) -> None: except Exception: logger.debug("Could not create tensor binding for type %s on pattern %s", tt, pattern) + if not self._bindings: + raise RuntimeError( + f"OVPhysX could not create any articulation bindings for pattern {pattern!r}. " + f"Check that prim_path={prim_path!r} matches at least one " + "UsdPhysics.ArticulationRootAPI prim." + ) + + # read metadata from the first available binding sample = next(iter(self._bindings.values())) self._num_instances = sample.count self._num_joints = sample.dof_count @@ -1741,23 +3575,55 @@ def _initialize_impl(self) -> None: self._joint_names = list(sample.dof_names) self._body_names = list(sample.body_names) - # Create data container. - self._data = ArticulationData(self._bindings, self._device, binding_getter=self._get_binding) - - # Discover tendon counts/names before buffer allocation so that - # _create_buffers can size the tendon property arrays. + # tendon counts/names must be resolved before buffer allocation self._process_tendons() + # eagerly create tendon bindings now that the counts are known; this keeps + # ArticulationData's _get_binding a simple dict lookup (no lazy callback). + if self._num_fixed_tendons > 0: + for tt in ( + TT.FIXED_TENDON_STIFFNESS, + TT.FIXED_TENDON_DAMPING, + TT.FIXED_TENDON_LIMIT_STIFFNESS, + TT.FIXED_TENDON_LIMIT, + TT.FIXED_TENDON_REST_LENGTH, + TT.FIXED_TENDON_OFFSET, + ): + try: + self._bindings[tt] = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=tt) + except Exception: + logger.debug("Could not create tensor binding for type %s on pattern %s", tt, pattern) + if self._num_spatial_tendons > 0: + for tt in ( + TT.SPATIAL_TENDON_STIFFNESS, + TT.SPATIAL_TENDON_DAMPING, + TT.SPATIAL_TENDON_LIMIT_STIFFNESS, + TT.SPATIAL_TENDON_OFFSET, + ): + try: + self._bindings[tt] = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=tt) + except Exception: + logger.debug("Could not create tensor binding for type %s on pattern %s", tt, pattern) + + # construct the data container; counts come from the bindings + self._data = ArticulationData(self._bindings, self._device) + self._data.body_names = self._body_names + self._data.joint_names = self._joint_names + self._data.fixed_tendon_names = self._fixed_tendon_names + self._data.spatial_tendon_names = self._spatial_tendon_names + + # allocate asset-side buffers self._create_buffers() + # apply initial state from config self._process_cfg() + + # build actuator instances and write drive properties to PhysX self._process_actuators_cfg() - self._validate_cfg() - self._log_articulation_info() - # Cache the effort binding and a stable float32 view of the applied_torque - # buffer for write_data_to_sim(). The binding's internal write cache - # (keyed on object identity) handles the fast path automatically. + # cache effort / target bindings and write-views for write_data_to_sim(). + # The effort view aliases applied_torque so the binding gets the actuator + # output without an extra copy. self._effort_binding = self._get_binding(TT.DOF_ACTUATION_FORCE) if self._effort_binding is not None: torque = self._data._applied_torque @@ -1772,7 +3638,6 @@ def _initialize_impl(self) -> None: else: self._effort_write_view = None - # Cache position/velocity target bindings + views for one-shot writes. def _make_write_view(tt, buf): b = self._get_binding(tt) if b is None or buf is None: @@ -1787,24 +3652,59 @@ def _make_write_view(tt, buf): TT.DOF_VELOCITY_TARGET, self._data._joint_vel_target ) - # Let the articulation data know that it is fully instantiated and ready to use. - self.data.is_primed = True + # validate the resolved configuration AFTER actuator/tendon processing + # so the values reflect any overrides applied by the actuator models + self._validate_cfg() - def _create_buffers(self) -> None: - self._data._create_buffers() + # prime the data by performing the first read + self.update(0.0) - self._ALL_INDICES = wp.array(np.arange(self._num_instances, dtype=np.int32), device=self._device) + # mark data as ready + self._data.is_primed = True + def _create_buffers(self) -> None: + """Allocate asset-side buffers (index/mask constants, wrench buf, pinned CPU staging).""" + N = self._num_instances + B = self._num_bodies + J = self._num_joints + FT = self._num_fixed_tendons + ST = self._num_spatial_tendons + device = self._device + + # Index constants. + self._ALL_INDICES = wp.array(np.arange(N, dtype=np.int32), device=device) + self._ALL_BODY_INDICES = wp.array(np.arange(B, dtype=np.int32), device=device) + self._ALL_JOINT_INDICES = wp.array(np.arange(J, dtype=np.int32), device=device) + self._ALL_FIXED_TENDON_INDICES = wp.array(np.arange(FT, dtype=np.int32), device=device) + self._ALL_SPATIAL_TENDON_INDICES = wp.array(np.arange(ST, dtype=np.int32), device=device) + + # All-true masks. + self._ALL_TRUE_ENV_MASK = wp.array(np.ones(N, dtype=bool), dtype=wp.bool, device=device) + self._ALL_TRUE_BODY_MASK = wp.array(np.ones(B, dtype=bool), dtype=wp.bool, device=device) + self._ALL_TRUE_JOINT_MASK = wp.array(np.ones(J, dtype=bool), dtype=wp.bool, device=device) + self._ALL_TRUE_FIXED_TENDON_MASK = wp.array(np.ones(FT, dtype=bool), dtype=wp.bool, device=device) + self._ALL_TRUE_SPATIAL_TENDON_MASK = wp.array(np.ones(ST, dtype=bool), dtype=wp.bool, device=device) + + # Wrench buffer (force, torque, position) per body, written by the + # ``_body_wrench_to_world`` kernel and consumed by the + # ``LINK_WRENCH`` binding which expects the 3D ``(N, B, 9)`` shape. + self._wrench_buf = wp.zeros((N, B, 9), dtype=wp.float32, device=device) + + # Wrench composers. self._instantaneous_wrench_composer = WrenchComposer(self) self._permanent_wrench_composer = WrenchComposer(self) - self._wrench_buf = wp.zeros((self._num_instances, self._num_bodies, 9), dtype=wp.float32, device=self._device) - # Joint-index arrays for each actuator (filled by _process_actuators_cfg). + # Wrench scratch buffer (used by _apply_external_wrenches, not yet allocated above). + # Joint-index arrays for each actuator (populated by _process_actuators_cfg). self._joint_ids_per_actuator: dict[str, list[int]] = {} - self._write_scratch: dict[int, wp.array] = {} + + # Pinned-host CPU staging for env ids/masks (PR #5329 pattern). + self._cpu_env_ids_all = wp.zeros(N, dtype=wp.int32, device="cpu", pinned=True) + wp.copy(self._cpu_env_ids_all, self._ALL_INDICES) + self._cpu_env_mask = wp.zeros(N, dtype=wp.bool, device="cpu", pinned=True) def _process_cfg(self) -> None: - """Process the articulation configuration (initial state, soft limits, etc.).""" + """Populate default state buffers from the config (mirrors RigidObject and Newton Articulation).""" cfg = self.cfg N = self._num_instances D = self._num_joints @@ -1815,84 +3715,28 @@ def _process_cfg(self) -> None: default_root_vel = tuple(cfg.init_state.lin_vel) + tuple(cfg.init_state.ang_vel) np_pose = np.tile(np.array(default_root_pose, dtype=np.float32), (N, 1)) np_vel = np.tile(np.array(default_root_vel, dtype=np.float32), (N, 1)) - wp.copy( - self._data._default_root_pose, - wp.from_numpy(np_pose, dtype=wp.transformf, device=dev), - ) - wp.copy( - self._data._default_root_vel, - wp.from_numpy(np_vel, dtype=wp.spatial_vectorf, device=dev), - ) + self._data.default_root_pose = wp.array(np_pose, dtype=wp.transformf, device=dev) + self._data.default_root_vel = wp.array(np_vel, dtype=wp.spatial_vectorf, device=dev) # Default joint positions / velocities from config patterns. + # cfg.init_state.joint_pos is a dict[str, float] where keys are regex patterns + # matching joint names. We expand this into a (N, D) buffer. self._resolve_joint_values(cfg.init_state.joint_pos, self._data._default_joint_pos) self._resolve_joint_values(cfg.init_state.joint_vel, self._data._default_joint_vel) - # Keep soft-limit computation on-device, matching the PhysX/Newton path. - wp.launch( - update_soft_joint_pos_limits, - dim=(N, D), - inputs=[self._data.joint_pos_limits, cfg.soft_joint_pos_limit_factor], - outputs=[self._data._soft_joint_pos_limits], - device=dev, - ) - - def _invalidate_initialize_callback(self, event) -> None: - self._is_initialized = False - - def _process_actuators_cfg(self) -> None: - """Build actuator instances from the config and write drive properties to PhysX. + # Compute soft joint position limits from the hard limits read from the binding + # (or zeros if no joints). This matches the PhysX/Newton path. + if D > 0: + wp.launch( + update_soft_joint_pos_limits, + dim=(N, D), + inputs=[self._data.joint_pos_limits, cfg.soft_joint_pos_limit_factor], + outputs=[self._data._soft_joint_pos_limits], + device=dev, + ) - Mirrors what the legacy PhysX backend does in its own _process_actuators_cfg: - - For ImplicitActuator: write the configured stiffness / damping to the PhysX - drive so the solver uses exactly the values from the actuator config. - - For all explicit actuators: zero out PhysX stiffness / damping so the - USD-authored drive gains cannot interfere with the explicit torque path. - - For all actuators: write effort_limit_sim and velocity_limit_sim. - - These writes happen via TensorBinding (GPU-resident) after warmup has - allocated the GPU buffers (MODEL_INIT fires post-warmup). - """ - from isaaclab.actuators import ImplicitActuator - - self.actuators: dict[str, ActuatorBase] = {} - self._has_implicit_actuators = False - for name, act_cfg in self.cfg.actuators.items(): - joint_ids, joint_names = self.find_joints(act_cfg.joint_names_expr) - if not joint_ids: - logger.warning("Actuator '%s': no joints matched '%s'", name, act_cfg.joint_names_expr) - continue - act_cfg_copy = act_cfg.copy() - act = act_cfg_copy.class_type( - act_cfg_copy, - joint_names=joint_names, - joint_ids=joint_ids, - num_envs=self._num_instances, - device=self._device, - ) - self.actuators[name] = act - self._joint_ids_per_actuator[name] = joint_ids - - # Write drive gains and limits to PhysX to match the actuator config. - # Without this, PhysX retains whatever stiffness/damping was authored in the - # USD file, which can produce large restoring forces if the USD gains differ - # from the actuator config (e.g. a position-controlled robot exported with - # non-zero drive stiffness but configured with ImplicitActuator(stiffness=0)). - jids = list(joint_ids) - if isinstance(act, ImplicitActuator): - self._has_implicit_actuators = True - stiffness = act.stiffness # torch (N, J) - damping = act.damping # torch (N, J) - else: - stiffness = wp.zeros((self._num_instances, len(jids)), dtype=wp.float32, device=self._device) - damping = wp.zeros((self._num_instances, len(jids)), dtype=wp.float32, device=self._device) - self.write_joint_stiffness_to_sim_index(stiffness=stiffness, joint_ids=jids) - self.write_joint_damping_to_sim_index(damping=damping, joint_ids=jids) - self.write_joint_effort_limit_to_sim_index(limits=act.effort_limit_sim, joint_ids=jids) - self.write_joint_velocity_limit_to_sim_index(limits=act.velocity_limit_sim, joint_ids=jids) - - def _process_tendons(self) -> None: - """Discover tendon counts from binding metadata and names from USD. + def _process_tendons(self) -> None: + """Discover tendon counts from binding metadata and names from USD. Tendon counts come from the ovphysx binding metadata. Tendon names are recovered from the exported USD articulation subtree because ovphysx @@ -1910,7 +3754,7 @@ def _process_tendons(self) -> None: stage_path = OvPhysxManager._stage_path if stage_path is not None: try: - from pxr import Usd, UsdPhysics + from pxr import Usd from isaaclab.sim.utils.queries import get_all_matching_child_prims @@ -1947,55 +3791,154 @@ def _process_tendons(self) -> None: except Exception: logger.debug("Could not parse USD stage for tendon names at %s", stage_path) - self._data._num_fixed_tendons = self._num_fixed_tendons - self._data._num_spatial_tendons = self._num_spatial_tendons - self._data.fixed_tendon_names = self._fixed_tendon_names - self._data.spatial_tendon_names = self._spatial_tendon_names + def _get_binding(self, tensor_type: int): + """Return a cached TensorBinding, creating it on first access. - def _apply_external_wrenches(self) -> None: - """Compose and write external wrenches to the LINK_WRENCH binding. + Bindings are lightweight handles (a pointer + shape metadata into + PhysX's shared GPU buffer). Creating one does NOT allocate new GPU + memory -- the underlying simulation buffers are allocated once by PhysX + regardless of how many bindings point into them. Still, we defer + creation so that tensor types the user never queries are never looked up. - WrenchComposer accumulates forces/torques in body (link) frame. - The LINK_WRENCH binding expects world-frame [fx,fy,fz,tx,ty,tz,px,py,pz]. - We rotate the body-frame vectors to world frame using the link quaternion - and pack them into the [N, L, 9] tensor with application position = origin. + Args: + tensor_type: The TensorType constant identifying which simulation + buffer to bind (e.g. :attr:`~isaaclab_ovphysx.tensor_types.ROOT_POSE`). + + Returns: + A TensorBinding object, or ``None`` if the binding could not be created. """ - inst = self._instantaneous_wrench_composer - perm = self._permanent_wrench_composer - if not inst.active and not perm.active: - return - if inst.active: - if perm.active: - inst.add_forces_and_torques_index( - forces=perm.composed_force, - torques=perm.composed_torque, - body_ids=list(range(self._num_bodies)), - env_ids=list(range(self._num_instances)), - ) - force_b = inst.composed_force - torque_b = inst.composed_torque - else: - force_b = perm.composed_force - torque_b = perm.composed_torque + binding = self._bindings.get(tensor_type) + if binding is not None: + return binding + try: + binding = self._ovphysx.create_tensor_binding(pattern=self._binding_pattern, tensor_type=tensor_type) + self._bindings[tensor_type] = binding + return binding + except Exception: + logger.debug("Could not create tensor binding for type %s", tensor_type) + return None - poses = self._data.body_link_pose_w - wp.launch( - _body_wrench_to_world, - dim=(self._num_instances, self._num_bodies), - inputs=[force_b, torque_b, poses], - outputs=[self._wrench_buf], - device=self._device, - ) - wrench_binding = self._get_binding(TT.LINK_WRENCH) - if wrench_binding is not None: - wrench_binding.write(self._wrench_buf) - inst.reset() + def _resolve_joint_values(self, pattern_dict: dict[str, float], buffer: wp.array) -> None: + """Resolve a ``{pattern: value}`` dict into a per-joint buffer. - def _apply_actuator_model(self) -> None: - """Run the actuator model to compute torques from user targets. + Builds values on CPU then copies to buffer's device (GPU arrays' + ``.numpy()`` returns a read-only copy, not a writable view). + + Args: + pattern_dict: A mapping from regex pattern strings to scalar values. + Matches joint names returned by :attr:`joint_names`. + buffer: Target warp array of shape ``(num_instances, num_joints)`` + to populate. + """ + buf_np = buffer.numpy() + modified = False + for pattern, value in pattern_dict.items(): + for j, name in enumerate(self._joint_names): + if re.fullmatch(pattern, name): + buf_np[:, j] = value + modified = True + if modified: + wp.copy(buffer, wp.from_numpy(buf_np, dtype=buffer.dtype, device=str(buffer.device))) + + def _n_envs_index(self, env_ids) -> int: + """Return the number of environments from an ``env_ids`` argument.""" + if env_ids is None: + return self._num_instances + if isinstance(env_ids, (list, tuple)): + return len(env_ids) + return env_ids.shape[0] if hasattr(env_ids, "shape") else len(env_ids) + + def _nft(self) -> int: + """Return the number of fixed tendons (0 if none).""" + return self._num_fixed_tendons + + def _nst(self) -> int: + """Return the number of spatial tendons (0 if none).""" + return self._num_spatial_tendons + + """ + Internal simulation callbacks. + """ + + def _invalidate_initialize_callback(self, event) -> None: + """Invalidate the asset on simulation reset.""" + super()._invalidate_initialize_callback(event) + + """ + Internal helpers -- Actuators. + """ + + def _process_actuators_cfg(self) -> None: + """Build actuator instances from the config and write drive properties to PhysX. + + Mirrors the PhysX backend's ``_process_actuators_cfg``: + + * For :class:`~isaaclab.actuators.ImplicitActuator`: write the configured + stiffness/damping to the PhysX drive so the solver uses exactly those values. + * For all explicit actuators: zero out PhysX stiffness/damping so USD-authored + drive gains cannot interfere with the explicit torque path. + * For all actuators: write :attr:`~isaaclab.actuators.ActuatorBase.effort_limit_sim` + and :attr:`~isaaclab.actuators.ActuatorBase.velocity_limit_sim`. + """ + from isaaclab.actuators import ImplicitActuator + + self.actuators: dict[str, Any] = {} + self._has_implicit_actuators = False + for name, act_cfg in self.cfg.actuators.items(): + joint_ids, joint_names = self.find_joints(act_cfg.joint_names_expr) + if not joint_ids: + logger.warning("Actuator '%s': no joints matched '%s'", name, act_cfg.joint_names_expr) + continue + act_cfg_copy = act_cfg.copy() + # seed the actuator with the simulation's already-correct DOF defaults + # (USD-authored ``physxJoint:maxJointVelocity`` etc. parsed at scene-load). + # Without these the ActuatorBase constructor falls back to ``inf`` for unset + # cfg fields, and the ``write_joint_*_to_sim_index`` calls below then + # overwrite the correct values with ``inf``. + act = act_cfg_copy.class_type( + act_cfg_copy, + joint_names=joint_names, + joint_ids=joint_ids, + num_envs=self._num_instances, + device=self._device, + stiffness=self._data.joint_stiffness.torch[:, joint_ids], + damping=self._data.joint_damping.torch[:, joint_ids], + armature=self._data.joint_armature.torch[:, joint_ids], + friction=self._data.joint_friction_coeff.torch[:, joint_ids], + dynamic_friction=self._data.joint_dynamic_friction_coeff.torch[:, joint_ids], + viscous_friction=self._data.joint_viscous_friction_coeff.torch[:, joint_ids], + effort_limit=self._data.joint_effort_limits.torch[:, joint_ids].clone(), + velocity_limit=self._data.joint_vel_limits.torch[:, joint_ids], + ) + self.actuators[name] = act + self._joint_ids_per_actuator[name] = joint_ids - IsaacLab actuators are torch-based. We convert warp -> torch via - DLPack (zero-copy on GPU), run the actuator, then write results back. + # Write drive gains and limits to PhysX to match the actuator config. + # Without this, PhysX retains whatever stiffness/damping was authored in the + # USD file, which can produce large restoring forces when the USD gains differ + # from the actuator config. + jids = list(joint_ids) + if isinstance(act, ImplicitActuator): + self._has_implicit_actuators = True + stiffness = act.stiffness # torch (N, J) + damping = act.damping # torch (N, J) + else: + stiffness = wp.zeros((self._num_instances, len(jids)), dtype=wp.float32, device=self._device) + damping = wp.zeros((self._num_instances, len(jids)), dtype=wp.float32, device=self._device) + self.write_joint_stiffness_to_sim_index(stiffness=stiffness, joint_ids=jids) + self.write_joint_damping_to_sim_index(damping=damping, joint_ids=jids) + self.write_joint_effort_limit_to_sim_index(limits=act.effort_limit_sim, joint_ids=jids) + self.write_joint_velocity_limit_to_sim_index(limits=act.velocity_limit_sim, joint_ids=jids) + + def _apply_actuator_model(self) -> None: + """Run the actuator model to compute joint torques from user-supplied targets. + + IsaacLab actuators are torch-based. The method converts Warp buffers to + torch via DLPack (zero-copy on GPU), runs each actuator's + :meth:`~isaaclab.actuators.ActuatorBase.compute` method, then writes the + computed effort back to the private ``_computed_torque`` / ``_applied_torque`` + buffers of the data container. :meth:`write_data_to_sim` then pushes + ``_applied_torque`` to the ``DOF_ACTUATION_FORCE`` binding in one shot. """ from isaaclab.utils.types import ArticulationActions @@ -2006,7 +3949,7 @@ def _apply_actuator_model(self) -> None: jids_t = jids if isinstance(jids, list) else list(jids) all_joints = len(jids_t) == self._num_joints - # warp -> torch (zero-copy on same device via DLPack) + # Warp -> torch (zero-copy on same device via DLPack). jp_target_full = self._data.joint_pos_target.torch jv_target_full = self._data.joint_vel_target.torch je_target_full = self._data.joint_effort_target.torch @@ -2037,640 +3980,380 @@ def _apply_actuator_model(self) -> None: ct[:, jids_t] = act.computed_effort at[:, jids_t] = act.applied_effort + """ + Internal helpers -- Debugging. + """ + def _validate_cfg(self) -> None: - pass + """Validate the configuration after processing. - def _log_articulation_info(self) -> None: - """Log information about the articulation. - - .. note:: We purposefully read the values from the simulator to ensure that the values are configured as - expected. - """ - from prettytable import PrettyTable - - def format_large_number(_, v: float) -> str: - if abs(v) >= 1e3: - return f"{v:.1e}" - return f"{v:.3f}" - - def format_limits(_, v: tuple[float, float]) -> str: - if abs(v[0]) >= 1e3 or abs(v[1]) >= 1e3: - return f"[{v[0]:.1e}, {v[1]:.1e}]" - return f"[{v[0]:.3f}, {v[1]:.3f}]" - - stiffnesses = self.data.joint_stiffness.warp.numpy()[0].tolist() - dampings = self.data.joint_damping.warp.numpy()[0].tolist() - armatures = self.data.joint_armature.warp.numpy()[0].tolist() - frictions = self.data.joint_friction_coeff.warp.numpy()[0].tolist() - pos_limits_np = self.data.joint_pos_limits.warp.numpy().reshape(self._num_instances, self._num_joints, 2) - position_limits = [tuple(pos_limits_np[0, j].tolist()) for j in range(self._num_joints)] - velocity_limits = self.data.joint_vel_limits.warp.numpy()[0].tolist() - effort_limits = self.data.joint_effort_limits.warp.numpy()[0].tolist() - - joint_table = PrettyTable() - joint_table.title = f"Simulation Joint Information (Prim path: {self.cfg.prim_path})" - joint_table.field_names = [ - "Index", - "Name", - "Stiffness", - "Damping", - "Armature", - "Friction", - "Position Limits", - "Velocity Limits", - "Effort Limits", - ] - joint_table.custom_format["Stiffness"] = format_large_number - joint_table.custom_format["Damping"] = format_large_number - joint_table.custom_format["Armature"] = format_large_number - joint_table.custom_format["Friction"] = format_large_number - joint_table.custom_format["Position Limits"] = format_limits - joint_table.custom_format["Velocity Limits"] = format_large_number - joint_table.custom_format["Effort Limits"] = format_large_number - joint_table.align["Name"] = "l" - - for index, name in enumerate(self.joint_names): - joint_table.add_row( - [ - index, - name, - stiffnesses[index], - dampings[index], - armatures[index], - frictions[index], - position_limits[index], - velocity_limits[index], - effort_limits[index], - ] - ) - logger.info(f"Simulation parameters for joints in {self.cfg.prim_path}:\n" + joint_table.get_string()) - - if self.num_fixed_tendons > 0: - ft_stiffnesses = self.data.fixed_tendon_stiffness.warp.numpy()[0].tolist() - ft_dampings = self.data.fixed_tendon_damping.warp.numpy()[0].tolist() - ft_limit_stiffnesses = self.data.fixed_tendon_limit_stiffness.warp.numpy()[0].tolist() - ft_limits_np = self.data.fixed_tendon_pos_limits.warp.numpy().reshape( - self._num_instances, self.num_fixed_tendons, 2 - ) - ft_limits = [tuple(ft_limits_np[0, t].tolist()) for t in range(self.num_fixed_tendons)] - ft_rest_lengths = self.data.fixed_tendon_rest_length.warp.numpy()[0].tolist() - ft_offsets = self.data.fixed_tendon_offset.warp.numpy()[0].tolist() - - tendon_table = PrettyTable() - tendon_table.title = f"Simulation Fixed Tendon Information (Prim path: {self.cfg.prim_path})" - tendon_table.field_names = [ - "Index", - "Stiffness", - "Damping", - "Limit Stiffness", - "Limits", - "Rest Length", - "Offset", - ] - tendon_table.custom_format["Stiffness"] = format_large_number - tendon_table.custom_format["Damping"] = format_large_number - tendon_table.custom_format["Limit Stiffness"] = format_large_number - tendon_table.custom_format["Limits"] = format_limits - tendon_table.custom_format["Rest Length"] = format_large_number - tendon_table.custom_format["Offset"] = format_large_number - for index in range(self.num_fixed_tendons): - tendon_table.add_row( - [ - index, - ft_stiffnesses[index], - ft_dampings[index], - ft_limit_stiffnesses[index], - ft_limits[index], - ft_rest_lengths[index], - ft_offsets[index], - ] - ) - logger.info( - f"Simulation parameters for fixed tendons in {self.cfg.prim_path}:\n" + tendon_table.get_string() - ) + Mirrors :meth:`isaaclab_physx.assets.Articulation._validate_cfg` (raises + ``ValueError`` with a per-joint message when any default joint position + is outside ``[lower, upper]`` or any default joint velocity exceeds the + per-joint max velocity). Reads come from :attr:`ArticulationData` + accessors instead of PhysX's ``root_view.get_dof_limits`` / + ``get_dof_max_velocities`` because OVPhysX's ``root_view`` is the + per-tensor-type bindings dict. - if self.num_spatial_tendons > 0: - st_stiffnesses = self.data.spatial_tendon_stiffness.warp.numpy()[0].tolist() - st_dampings = self.data.spatial_tendon_damping.warp.numpy()[0].tolist() - st_limit_stiffnesses = self.data.spatial_tendon_limit_stiffness.warp.numpy()[0].tolist() - st_offsets = self.data.spatial_tendon_offset.warp.numpy()[0].tolist() - - tendon_table = PrettyTable() - tendon_table.title = f"Simulation Spatial Tendon Information (Prim path: {self.cfg.prim_path})" - tendon_table.field_names = [ - "Index", - "Stiffness", - "Damping", - "Limit Stiffness", - "Offset", - ] - tendon_table.float_format = ".3" - for index in range(self.num_spatial_tendons): - tendon_table.add_row( - [ - index, - st_stiffnesses[index], - st_dampings[index], - st_limit_stiffnesses[index], - st_offsets[index], - ] - ) - logger.info( - f"Simulation parameters for spatial tendons in {self.cfg.prim_path}:\n" + tendon_table.get_string() - ) + .. note:: + Must be called only after :meth:`_create_buffers` / + :meth:`_process_cfg` / :meth:`_process_actuators_cfg`, otherwise + limits and defaults may not yet reflect the final values. + """ + # check that the default joint positions are within the limits + joint_pos_limits = self._data.joint_pos_limits.torch[0] # (num_joints, 2) + default_joint_pos = self._data.default_joint_pos.torch[0] # (num_joints,) + out_of_range = default_joint_pos < joint_pos_limits[:, 0] + out_of_range |= default_joint_pos > joint_pos_limits[:, 1] + violated_indices = torch.nonzero(out_of_range, as_tuple=False).squeeze(-1) + if len(violated_indices) > 0: + msg = "The following joints have default positions out of the limits: \n" + for idx in violated_indices: + joint_name = self._data.joint_names[idx] + joint_limit = joint_pos_limits[idx] + joint_pos = default_joint_pos[idx] + msg += f"\t- '{joint_name}': {joint_pos:.3f} not in [{joint_limit[0]:.3f}, {joint_limit[1]:.3f}]\n" + raise ValueError(msg) + + # check that the default joint velocities are within the limits + joint_max_vel = self._data.joint_vel_limits.torch[0] # (num_joints,) + default_joint_vel = self._data.default_joint_vel.torch[0] # (num_joints,) + out_of_range = torch.abs(default_joint_vel) > joint_max_vel + violated_indices = torch.nonzero(out_of_range, as_tuple=False).squeeze(-1) + if len(violated_indices) > 0: + msg = "The following joints have default velocities out of the limits: \n" + for idx in violated_indices: + joint_name = self._data.joint_names[idx] + joint_limit = [-joint_max_vel[idx], joint_max_vel[idx]] + joint_vel = default_joint_vel[idx] + msg += f"\t- '{joint_name}': {joint_vel:.3f} not in [{joint_limit[0]:.3f}, {joint_limit[1]:.3f}]\n" + raise ValueError(msg) - """ - Internal helpers -- Bindings. - """ + def _log_articulation_info(self) -> None: + pass - def _get_binding(self, tensor_type: int): - """Return a cached TensorBinding, creating it on first access. + def _resolve_env_ids(self, env_ids) -> wp.array: + """Resolve environment indices to a warp int32 array on ``self._device`` (mirrors PhysX). - Bindings are lightweight handles (a pointer + shape metadata into - PhysX's shared GPU buffer). Creating one does NOT allocate new GPU - memory -- the underlying simulation buffers are allocated once by PhysX - regardless of how many bindings point into them. Still, we defer - creation so that tensor types the user never queries are never looked up. + Tests sometimes hand us indices on CPU even when the sim runs on GPU; we move the + resolved array onto ``self._device`` so kernel launches don't fail on a device + mismatch. """ - binding = self._bindings.get(tensor_type) - if binding is not None: - return binding - try: - binding = self._physx_instance.create_tensor_binding(pattern=self._binding_pattern, tensor_type=tensor_type) - self._bindings[tensor_type] = binding - return binding - except Exception: - logger.debug("Could not create tensor binding for type %s", tensor_type) - return None + if env_ids is None or env_ids == slice(None): + return self._ALL_INDICES + if isinstance(env_ids, list): + return wp.array(env_ids, dtype=wp.int32, device=self._device) + if isinstance(env_ids, torch.Tensor): + return wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) + if isinstance(env_ids, wp.array) and str(env_ids.device) != self._device: + env_ids = wp.clone(env_ids, device=self._device) + return env_ids + + def _resolve_body_ids(self, body_ids) -> wp.array: + """Resolve body indices to a warp int32 array on ``self._device`` (mirrors PhysX).""" + if body_ids is None or 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) + if isinstance(body_ids, torch.Tensor): + return wp.from_torch(body_ids.to(torch.int32), dtype=wp.int32) + if isinstance(body_ids, wp.array) and str(body_ids.device) != self._device: + body_ids = wp.clone(body_ids, device=self._device) + return body_ids + + def _resolve_joint_ids(self, joint_ids) -> wp.array: + """Resolve joint indices to a warp int32 array on ``self._device``.""" + if joint_ids is None or joint_ids == slice(None): + return self._ALL_JOINT_INDICES + if isinstance(joint_ids, list): + return wp.array(joint_ids, dtype=wp.int32, device=self._device) + if isinstance(joint_ids, torch.Tensor): + return wp.from_torch(joint_ids.to(torch.int32), dtype=wp.int32) + if isinstance(joint_ids, wp.array) and str(joint_ids.device) != self._device: + joint_ids = wp.clone(joint_ids, device=self._device) + return joint_ids + + def _resolve_fixed_tendon_ids(self, tendon_ids) -> wp.array: + """Resolve fixed-tendon indices to a warp int32 array on ``self._device``.""" + if tendon_ids is None or tendon_ids == slice(None): + return self._ALL_FIXED_TENDON_INDICES + if isinstance(tendon_ids, list): + return wp.array(tendon_ids, dtype=wp.int32, device=self._device) + if isinstance(tendon_ids, torch.Tensor): + return wp.from_torch(tendon_ids.to(torch.int32), dtype=wp.int32) + if isinstance(tendon_ids, wp.array) and str(tendon_ids.device) != self._device: + tendon_ids = wp.clone(tendon_ids, device=self._device) + return tendon_ids + + def _resolve_spatial_tendon_ids(self, tendon_ids) -> wp.array: + """Resolve spatial-tendon indices to a warp int32 array on ``self._device``.""" + if tendon_ids is None or tendon_ids == slice(None): + return self._ALL_SPATIAL_TENDON_INDICES + if isinstance(tendon_ids, list): + return wp.array(tendon_ids, dtype=wp.int32, device=self._device) + if isinstance(tendon_ids, torch.Tensor): + return wp.from_torch(tendon_ids.to(torch.int32), dtype=wp.int32) + if isinstance(tendon_ids, wp.array) and str(tendon_ids.device) != self._device: + tendon_ids = wp.clone(tendon_ids, device=self._device) + return tendon_ids + + def _broadcast_scalar_to_2d( + self, value: float | torch.Tensor | wp.array, shape: tuple[int, int] + ) -> torch.Tensor | wp.array: + """Broadcast a scalar :class:`float` to a ``(rows, cols)`` torch ``float32`` tensor. + + Tendon and joint setters accept ``float | torch.Tensor | wp.array``; the + underlying ``shared_kernels.write_2d_data_to_buffer_*`` kernels only + accept 2D arrays. This helper expands a Python float into a constant + tensor on :attr:`_device`; tensor / warp inputs are returned as-is. + + Mirrors the PhysX backend's ``isinstance(value, float)`` branching, + which dispatches to ``articulation_kernels.float_data_to_buffer_with_*``. + OVPhysX does not have those scalar kernels, so we materialize the + broadcast on the Python side. - """ - Internal helpers -- Write. - """ + Args: + value: Scalar float or 2D tensor / warp array. + shape: ``(rows, cols)`` target shape used when broadcasting a + scalar. - def _to_flat_f32(self, data, target_shape: tuple[int, ...] | None = None) -> wp.array | np.ndarray: - """Ensure data is a contiguous float32 tensor suitable for binding I/O. + Returns: + A 2D :class:`torch.Tensor` on ``self._device`` if *value* was a + float; otherwise *value* unchanged. + """ + if isinstance(value, float): + return torch.full(shape, value, dtype=torch.float32, device=self._device) + return value - State tensor bindings (positions, velocities, poses) live on the - simulation device (GPU in GPU mode). We always return data on - self._device so the binding device check passes. + def _resolve_env_mask(self, env_mask: wp.array | None) -> wp.array: + """Resolve an environment mask to a ``wp.bool`` array on ``self._device``. - For structured warp dtypes (transformf, spatial_vectorf, etc.) a - zero-copy flat float32 view is created instead of roundtripping - through CPU numpy. + OVPhysX (like Newton) uses the binding's native ``binding.write(mask=...)`` path, + so the mask is preserved end-to-end; no ``torch.nonzero`` conversion is needed. + ``None`` returns the pre-allocated all-true mask. """ - dev = self._device - if isinstance(data, wp.array): - if str(data.device) != dev: - data = wp.clone(data, device=dev) - if data.dtype == wp.float32: - return data - # Structured dtype: zero-copy flat float32 view. - # transformf -> [N, 7], spatial_vectorf -> [N, 6], etc. - floats_per_elem = data.strides[0] // 4 - return wp.array( - ptr=data.ptr, - shape=(data.shape[0], floats_per_elem), - dtype=wp.float32, - device=dev, - copy=False, - ) - elif isinstance(data, torch.Tensor): - if data.is_cuda and dev.startswith("cuda"): - return wp.from_torch(data.detach().contiguous().float()) - np_data = data.detach().cpu().numpy().astype(np.float32) - return wp.from_numpy(np_data, dtype=wp.float32, device=dev) - elif isinstance(data, np.ndarray): - return wp.from_numpy(data.astype(np.float32), dtype=wp.float32, device=dev) - elif isinstance(data, (int, float)): - return wp.from_numpy(np.array(data, dtype=np.float32), dtype=wp.float32, device=dev) - return wp.from_numpy(np.asarray(data, dtype=np.float32), dtype=wp.float32, device=dev) - - def _as_gpu_f32_2d(self, data, cols: int) -> wp.array: - """View/convert data as 2D [rows, cols] float32 on self._device. - - For warp arrays with structured dtypes (transformf, spatial_vectorf), - creates a zero-copy flat float32 view. For torch/numpy, converts to - warp on the simulation device. + if env_mask is None: + return self._ALL_TRUE_ENV_MASK + if isinstance(env_mask, torch.Tensor): + return wp.from_torch(env_mask.to(torch.bool), dtype=wp.bool) + if isinstance(env_mask, wp.array) and str(env_mask.device) != self._device: + env_mask = wp.clone(env_mask, device=self._device) + return env_mask + + def _resolve_body_mask(self, body_mask: wp.array | None) -> wp.array: + """Resolve a body mask to a ``wp.bool`` array on ``self._device`` (Newton-style).""" + if body_mask is None: + return self._ALL_TRUE_BODY_MASK + if isinstance(body_mask, torch.Tensor): + return wp.from_torch(body_mask.to(torch.bool), dtype=wp.bool) + if isinstance(body_mask, wp.array) and str(body_mask.device) != self._device: + body_mask = wp.clone(body_mask, device=self._device) + return body_mask + + def _resolve_joint_mask(self, joint_mask: wp.array | None) -> wp.array: + """Resolve a joint mask to a ``wp.bool`` array on ``self._device``.""" + if joint_mask is None: + return self._ALL_TRUE_JOINT_MASK + if isinstance(joint_mask, torch.Tensor): + return wp.from_torch(joint_mask.to(torch.bool), dtype=wp.bool) + if isinstance(joint_mask, wp.array) and str(joint_mask.device) != self._device: + joint_mask = wp.clone(joint_mask, device=self._device) + return joint_mask + + def _resolve_fixed_tendon_mask(self, tendon_mask: wp.array | None) -> wp.array: + """Resolve a fixed-tendon mask to a ``wp.bool`` array on ``self._device``.""" + if tendon_mask is None: + return self._ALL_TRUE_FIXED_TENDON_MASK + if isinstance(tendon_mask, torch.Tensor): + return wp.from_torch(tendon_mask.to(torch.bool), dtype=wp.bool) + if isinstance(tendon_mask, wp.array) and str(tendon_mask.device) != self._device: + tendon_mask = wp.clone(tendon_mask, device=self._device) + return tendon_mask + + def _resolve_spatial_tendon_mask(self, tendon_mask: wp.array | None) -> wp.array: + """Resolve a spatial-tendon mask to a ``wp.bool`` array on ``self._device``.""" + if tendon_mask is None: + return self._ALL_TRUE_SPATIAL_TENDON_MASK + if isinstance(tendon_mask, torch.Tensor): + return wp.from_torch(tendon_mask.to(torch.bool), dtype=wp.bool) + if isinstance(tendon_mask, wp.array) and str(tendon_mask.device) != self._device: + tendon_mask = wp.clone(tendon_mask, device=self._device) + return tendon_mask + + def _get_cpu_env_mask(self, env_mask: wp.array) -> wp.array: + """Return a pinned-host CPU copy of :paramref:`env_mask` for a CPU-only binding write. + + :paramref:`env_mask` is normally on ``self._device``; ``binding.write(mask=...)`` + requires the mask on the binding's device, which is CPU for mass / CoMs / inertia. + Reuses the pre-allocated ``_cpu_env_mask`` pinned buffer. """ - dev = self._device - if isinstance(data, wp.array): - if str(data.device) != dev: - data = wp.clone(data, device=dev) - if data.dtype == wp.float32 and data.ndim == 2: - return data - n = data.shape[0] - return wp.array( - ptr=data.ptr, - shape=(n, cols), - dtype=wp.float32, - device=dev, - copy=False, - ) - if isinstance(data, torch.Tensor) and data.is_cuda and dev.startswith("cuda"): - return wp.from_torch(data.detach().contiguous().float().reshape(-1, cols)) - np_data = self._to_cpu_numpy(data).reshape(-1, cols) - return wp.from_numpy(np_data, dtype=wp.float32, device=dev) - - def _get_write_scratch(self, tensor_type: int, binding) -> wp.array: - """Return a cached GPU scratch buffer for read-modify-write.""" - if not hasattr(self, "_write_scratch"): - self._write_scratch = {} - buf = self._write_scratch.get(tensor_type) - if buf is None: - buf = wp.zeros(binding.shape, dtype=wp.float32, device=self._device) - self._write_scratch[tensor_type] = buf - return buf - - def _write_root_state(self, tensor_type: int, data, env_ids=None, mask=None, _ids_gpu=None) -> None: - """GPU-native write for root pose [N,7] or velocity [N,6]. - - Three paths, fastest first: - - Full write (no env_ids, no mask): zero-copy DLPack. - - Indexed write with full-size data: zero-copy view + indices. - The binding API only copies the indexed rows from the full buffer, - so no read-modify-write is needed when data is already [N,...]. - - Indexed write with partial data [K,...]: scatter kernel into a GPU - scratch buffer, then write with indices. - - Masked write: data is always full [N,...], pass directly with mask. - - Args: - _ids_gpu: Pre-converted GPU warp int32 array of env indices. - When provided, skips the per-call GPU->CPU->GPU conversion - of env_ids. - """ - binding = self._get_binding(tensor_type) - if binding is None: - return - N, C = binding.shape - - if env_ids is None and _ids_gpu is None and mask is None: - binding.write(self._to_flat_f32(data)) - self._invalidate_root_caches(tensor_type) - return - - src = self._as_gpu_f32_2d(data, C) - - if env_ids is not None or _ids_gpu is not None: - if _ids_gpu is None: - _ids_gpu = self._env_ids_to_gpu_warp(env_ids) - K = _ids_gpu.shape[0] - if src.shape[0] == N: - binding.write(src, indices=_ids_gpu) - else: - scratch = self._get_write_scratch(tensor_type, binding) - binding.read(scratch) - wp.launch( - _scatter_rows_partial, - dim=(K, C), - inputs=[scratch, src, _ids_gpu], - device=self._device, - ) - binding.write(scratch, indices=_ids_gpu) - else: - mask_u8 = wp.from_numpy( - self._to_cpu_numpy(mask).astype(np.uint8), - device=self._device, - ) - binding.write(src, mask=mask_u8) - self._invalidate_root_caches(tensor_type) - - def _invalidate_root_caches(self, tensor_type: int) -> None: - """Force re-read from GPU on next property access after a binding write.""" - if tensor_type == TT.ROOT_POSE: - self.data._root_link_pose_w.timestamp = -1.0 - self.data._root_com_pose_w.timestamp = -1.0 - elif tensor_type == TT.ROOT_VELOCITY: - self.data._root_link_vel_w.timestamp = -1.0 - self.data._root_com_vel_w.timestamp = -1.0 - - def _write_flat_tensor(self, tensor_type: int, data, env_ids=None, joint_ids=None, _ids_gpu=None) -> None: - """Write a 2-D tensor to a binding, with optional env/joint index subsetting.""" - if isinstance(data, (int, float)): - return - binding = self._get_binding(tensor_type) - if binding is None: - return - from isaaclab_ovphysx.tensor_types import _CPU_ONLY_TYPES - - is_cpu_only = tensor_type in _CPU_ONLY_TYPES - - # CPU-only types or column scatter must go through numpy. - if is_cpu_only or joint_ids is not None: - target_device = "cpu" if is_cpu_only else self._device - np_data = self._to_cpu_numpy(data) - if joint_ids is not None: - if is_cpu_only: - full = np.zeros(binding.shape, dtype=np.float32) - binding.read(full) - else: - scratch = self._get_write_scratch(tensor_type, binding) - binding.read(scratch) - full = scratch.numpy() - jids = self._to_cpu_indices(joint_ids, np.intp) - if env_ids is not None: - eids = self._to_cpu_indices(env_ids, np.intp) - full[np.ix_(eids, jids)] = np_data.reshape(len(eids), len(jids), *np_data.shape[2:]) - else: - full[:, jids] = np_data.reshape(full.shape[0], len(jids), *np_data.shape[2:]) - binding.write(wp.from_numpy(full, dtype=wp.float32, device=target_device)) - elif env_ids is not None: - if is_cpu_only: - full = np.zeros(binding.shape, dtype=np.float32) - binding.read(full) - else: - scratch = self._get_write_scratch(tensor_type, binding) - binding.read(scratch) - full = scratch.numpy() - eids = self._to_cpu_indices(env_ids, np.intp) - full[eids] = np_data if np_data.shape[0] == len(eids) else np_data[eids] - flat = wp.from_numpy(full.astype(np.float32), dtype=wp.float32, device=target_device) - idx = _ids_gpu if _ids_gpu is not None else self._env_ids_to_gpu_warp(env_ids) - binding.write(flat, indices=idx) - else: - binding.write(wp.from_numpy(np_data.astype(np.float32), dtype=wp.float32, device=target_device)) - return - - # GPU path: data stays on device. - if env_ids is None and _ids_gpu is None: - binding.write(self._to_flat_f32(data)) - return - - N, C = binding.shape[0], binding.shape[1] - src = self._as_gpu_f32_2d(data, C) - if _ids_gpu is None: - _ids_gpu = self._env_ids_to_gpu_warp(env_ids) - K = _ids_gpu.shape[0] - if src.shape[0] == N: - binding.write(src, indices=_ids_gpu) - else: - scratch = self._get_write_scratch(tensor_type, binding) - binding.read(scratch) - wp.launch( - _scatter_rows_partial, - dim=(K, C), - inputs=[scratch, src, _ids_gpu], - device=self._device, - ) - binding.write(scratch, indices=_ids_gpu) - - def _write_flat_tensor_mask(self, tensor_type: int, data, env_mask=None, joint_mask=None) -> None: - """Write a 2-D tensor to a binding, with optional env/joint mask subsetting.""" - if isinstance(data, (int, float)): - return - binding = self._get_binding(tensor_type) - if binding is None: - return - from isaaclab_ovphysx.tensor_types import _CPU_ONLY_TYPES - - is_cpu_only = tensor_type in _CPU_ONLY_TYPES - - # CPU-only types or column-mask scatter must go through numpy. - if is_cpu_only or joint_mask is not None: - target_device = "cpu" if is_cpu_only else self._device - np_data = self._to_cpu_numpy(data) - if joint_mask is not None: - # GPU bindings cannot read into numpy directly; read into GPU - # scratch first, then pull to CPU for column scatter. - if is_cpu_only: - full = np.zeros(binding.shape, dtype=np.float32) - binding.read(full) - else: - scratch = self._get_write_scratch(tensor_type, binding) - binding.read(scratch) - full = scratch.numpy() - jmask = self._to_cpu_numpy(joint_mask).astype(bool) - cols = np.where(jmask)[0] - if env_mask is not None: - emask = self._to_cpu_numpy(env_mask).astype(bool) - rows = np.where(emask)[0] - full[rows[:, None], cols] = np_data[rows[:, None], cols] - else: - full[:, cols] = np_data[:, cols] - binding.write(wp.from_numpy(full.astype(np.float32), dtype=wp.float32, device=target_device)) - elif env_mask is not None: - flat = wp.from_numpy(np_data.astype(np.float32), dtype=wp.float32, device=target_device) - mask_u8 = wp.from_numpy( - self._to_cpu_numpy(env_mask).astype(np.uint8), - device=target_device, - ) - binding.write(flat, mask=mask_u8) - else: - binding.write(wp.from_numpy(np_data.astype(np.float32), dtype=wp.float32, device=target_device)) - return + wp.copy(self._cpu_env_mask, env_mask) + return self._cpu_env_mask - # GPU path: data stays on device. - if env_mask is None: - binding.write(self._to_flat_f32(data)) - return + def _get_cpu_env_ids(self, env_ids: wp.array | torch.Tensor) -> wp.array: + """Return CPU int32 indices, using the pre-allocated pinned ``_cpu_env_ids_all`` + fast path when *env_ids* matches ``_ALL_INDICES`` (PR #5329 pattern). + """ + if isinstance(env_ids, torch.Tensor): + env_ids = wp.from_torch(env_ids, dtype=wp.int32) + if env_ids.ptr == self._ALL_INDICES.ptr: + return self._cpu_env_ids_all + return wp.clone(env_ids, device="cpu") - # Data is full [N, D], the binding API selects rows via the mask. - mask_u8 = wp.from_numpy( - self._to_cpu_numpy(env_mask).astype(np.uint8), - device=self._device, + """ + Deprecated methods. + """ + + def write_root_state_to_sim( + self, + root_state: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated; use :meth:`write_root_link_pose_to_sim_index` and + :meth:`write_root_com_velocity_to_sim_index` instead. + + Args: + root_state: Root state [m, m, m, qw, qx, qy, qz, m/s, m/s, m/s, rad/s, rad/s, rad/s]. + Shape is (len(env_ids), 13) with dtype wp.float32. + env_ids: Environment indices. Defaults to None (all environments). + """ + warnings.warn( + "The function 'write_root_state_to_sim' will be deprecated in a future release. Please" + " use 'write_root_link_pose_to_sim_index' and 'write_root_com_velocity_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, ) - binding.write(self._to_flat_f32(data), mask=mask_u8) + self.write_root_link_pose_to_sim_index(root_pose=root_state[:, :7], env_ids=env_ids) + self.write_root_com_velocity_to_sim_index(root_velocity=root_state[:, 7:], env_ids=env_ids) - def _write_friction_column(self, data, env_ids=None, joint_ids=None) -> None: - """Write static friction coefficient into column 0 of DOF_FRICTION_PROPERTIES [N,D,3].""" - binding = self._get_binding(TT.DOF_FRICTION_PROPERTIES) - if binding is None: - return - full = np.zeros(binding.shape, dtype=np.float32) - binding.read(full) - if isinstance(data, (int, float)): - if env_ids is not None and joint_ids is not None: - eids = self._to_cpu_numpy(env_ids).astype(np.intp) - jids = self._to_cpu_indices(joint_ids, np.intp) - full[np.ix_(eids, jids, [0])] = data - elif env_ids is not None: - eids = self._to_cpu_numpy(env_ids).astype(np.intp) - full[eids, :, 0] = data - elif joint_ids is not None: - jids = self._to_cpu_indices(joint_ids, np.intp) - full[:, jids, 0] = data - else: - full[..., 0] = data - binding.write(wp.from_numpy(full.astype(np.float32), dtype=wp.float32, device="cpu")) - return - np_data = self._to_cpu_numpy(data) - if env_ids is not None and joint_ids is not None: - eids = self._to_cpu_numpy(env_ids).astype(np.intp) - jids = self._to_cpu_indices(joint_ids, np.intp) - full[np.ix_(eids, jids, [0])] = np_data.reshape(len(eids), len(jids), 1) - elif env_ids is not None: - eids = self._to_cpu_numpy(env_ids).astype(np.intp) - full[eids, :, 0] = np_data.reshape(len(eids), -1) - elif joint_ids is not None: - jids = self._to_cpu_indices(joint_ids, np.intp) - full[:, jids, 0] = np_data.reshape(full.shape[0], len(jids)) - else: - full[..., 0] = np_data.reshape(full.shape[0], full.shape[1]) - binding.write(wp.from_numpy(full.astype(np.float32), dtype=wp.float32, device="cpu")) + def write_root_com_state_to_sim( + self, + root_state: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated; use :meth:`write_root_com_pose_to_sim_index` and + :meth:`write_root_com_velocity_to_sim_index` instead. - def _write_friction_column_mask(self, data, env_mask=None, joint_mask=None) -> None: - """Write static friction coefficient via mask into column 0 of DOF_FRICTION_PROPERTIES.""" - binding = self._get_binding(TT.DOF_FRICTION_PROPERTIES) - if binding is None: - return - full = np.zeros(binding.shape, dtype=np.float32) - binding.read(full) - if isinstance(data, (int, float)): - new_col = np.full((full.shape[0], full.shape[1]), data, dtype=np.float32) - else: - new_col = self._to_cpu_numpy(data).reshape(full.shape[0], full.shape[1]) - if env_mask is not None: - emask = self._to_cpu_numpy(env_mask).astype(bool) - if joint_mask is not None: - jmask = self._to_cpu_numpy(joint_mask).astype(bool) - rows = np.where(emask)[0] - cols = np.where(jmask)[0] - full[rows[:, None], cols, 0] = new_col[rows[:, None], cols] - else: - full[emask, :, 0] = new_col[emask] - elif joint_mask is not None: - jmask = self._to_cpu_numpy(joint_mask).astype(bool) - full[:, jmask, 0] = new_col[:, jmask] - else: - full[..., 0] = new_col - binding.write(wp.from_numpy(full.astype(np.float32), dtype=wp.float32, device="cpu")) - - def _write_joint_subset(self, tensor_type: int, buffer: wp.array, joint_ids: list[int]) -> None: - """Write a full-width joint buffer into the simulation for an actuator's joints.""" - binding = self._get_binding(tensor_type) - if binding is None: - return - if not hasattr(self, "_write_dltensor_cache"): - self._write_dltensor_cache = {} - cache_key = (tensor_type, buffer.ptr) - cached = self._write_dltensor_cache.get(cache_key) - if cached is None: - flat = self._to_flat_f32(buffer) - from ovphysx._dlpack_utils import acquire_dltensor - - dl, keepalive = acquire_dltensor(flat) - self._write_dltensor_cache[cache_key] = (dl, keepalive, flat) - cached = self._write_dltensor_cache[cache_key] - binding.write(cached[0]) - - @staticmethod - def _to_cpu_numpy(data) -> np.ndarray: - """Convert data (warp, torch, numpy, scalar) to a CPU numpy array.""" - if isinstance(data, wp.array): - return data.numpy().astype(np.float32) - if isinstance(data, torch.Tensor): - return data.detach().cpu().numpy().astype(np.float32) - return np.asarray(data, dtype=np.float32) - - @staticmethod - def _to_cpu_indices(data, dtype=np.int32) -> np.ndarray: - """Convert index array (warp, torch, list, numpy) to CPU numpy int array.""" - if isinstance(data, torch.Tensor): - return data.detach().cpu().numpy().astype(dtype) - if isinstance(data, wp.array): - return data.numpy().astype(dtype) - return np.asarray(data, dtype=dtype) - - def _env_ids_to_gpu_warp(self, env_ids) -> wp.array: - """Convert env_ids to a GPU int32 warp array, with single-entry caching. - - The cache avoids repeated GPU -> CPU -> GPU round-trips when the same - ``env_ids`` object is passed to multiple binding writes in a single step - (e.g. reset writes root_pose, root_vel, joint_pos, joint_vel). A new - object identity (``id()``) or shape change invalidates the cache. - """ - if hasattr(env_ids, "data_ptr"): - key = (env_ids.data_ptr(), env_ids.shape[0]) - elif isinstance(env_ids, wp.array): - key = (env_ids.ptr, env_ids.shape[0]) - else: - key = None - - if key is not None and hasattr(self, "_ids_cache_key") and self._ids_cache_key == key: - return self._ids_cache_val - - result = wp.array(self._to_cpu_indices(env_ids, np.int32), device=self._device) - if key is not None: - self._ids_cache_key = key - self._ids_cache_val = result - return result - - def _set_target_into_buffer(self, buffer: wp.array, data, env_ids=None, joint_ids=None) -> None: - """Set user-provided target data into a warp command buffer. - - For the common case (no index subset), this uses wp.copy to stay on - the simulation device. Subset writes (specific env_ids or joint_ids) - fall back to CPU because warp does not support scatter indexing. - """ - # Fast path: all-joints shortcut. When joint_ids covers every joint - # and env_ids is None, the subset is equivalent to a full copy. - if joint_ids is not None and env_ids is None: - n_joints = buffer.shape[1] if len(buffer.shape) > 1 else 1 - if hasattr(joint_ids, "__len__") and len(joint_ids) == n_joints: - joint_ids = None - if env_ids is None and joint_ids is None: - src = self._to_flat_f32(data) - if isinstance(src, np.ndarray): - src = wp.from_numpy(src, dtype=wp.float32, device=buffer.device) - wp.copy(buffer, src) - else: - np_data = self._to_cpu_numpy(data) - buf_np = buffer.numpy() - env_idx = self._to_cpu_numpy(env_ids).astype(np.intp) if env_ids is not None else None - jnt_idx = self._to_cpu_numpy(joint_ids).astype(np.intp) if joint_ids is not None else None - if env_idx is not None and jnt_idx is not None: - buf_np[np.ix_(env_idx, jnt_idx)] = np_data - elif env_idx is not None: - buf_np[env_idx] = np_data - else: - buf_np[:, jnt_idx] = np_data - wp.copy(buffer, wp.from_numpy(buf_np, dtype=wp.float32, device=buffer.device)) + Args: + root_state: Root CoM state [m, m, m, qw, qx, qy, qz, m/s, m/s, m/s, rad/s, rad/s, rad/s]. + Shape is (len(env_ids), 13) with dtype wp.float32. + env_ids: Environment indices. Defaults to None (all environments). + """ + warnings.warn( + "The function 'write_root_com_state_to_sim' will be deprecated in a future release. Please" + " use 'write_root_com_pose_to_sim_index' and 'write_root_com_velocity_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_root_com_pose_to_sim_index(root_pose=root_state[:, :7], env_ids=env_ids) + self.write_root_com_velocity_to_sim_index(root_velocity=root_state[:, 7:], env_ids=env_ids) - def _set_target_into_buffer_mask(self, buffer: wp.array, data, env_mask=None, joint_mask=None) -> None: - """Set user-provided target data into a warp command buffer using masks.""" - if env_mask is None: - src = self._to_flat_f32(data) - if isinstance(src, np.ndarray): - src = wp.from_numpy(src, dtype=wp.float32, device=buffer.device) - wp.copy(buffer, src) - else: - np_data = self._to_cpu_numpy(data) - buf_np = buffer.numpy() - mask_np = self._to_cpu_numpy(env_mask).astype(bool) - buf_np[mask_np] = np_data[mask_np] - wp.copy(buffer, wp.from_numpy(buf_np, dtype=wp.float32, device=buffer.device)) + def write_root_link_state_to_sim( + self, + root_state: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated; use :meth:`write_root_link_pose_to_sim_index` and + :meth:`write_root_link_velocity_to_sim_index` instead. - """ - Internal helpers -- Utilities. - """ + Args: + root_state: Root link state [m, m, m, qw, qx, qy, qz, m/s, m/s, m/s, rad/s, rad/s, rad/s]. + Shape is (len(env_ids), 13) with dtype wp.float32. + env_ids: Environment indices. Defaults to None (all environments). + """ + warnings.warn( + "The function 'write_root_link_state_to_sim' will be deprecated in a future release. Please" + " use 'write_root_link_pose_to_sim_index' and 'write_root_link_velocity_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_root_link_pose_to_sim_index(root_pose=root_state[:, :7], env_ids=env_ids) + self.write_root_link_velocity_to_sim_index(root_velocity=root_state[:, 7:], env_ids=env_ids) - def _n_envs_index(self, env_ids): - """Return the number of environments from an env_ids argument.""" - if env_ids is None: - return self._num_instances - if isinstance(env_ids, (list, tuple)): - return len(env_ids) - return env_ids.shape[0] if hasattr(env_ids, "shape") else len(env_ids) + def write_joint_state_to_sim( + self, + position: torch.Tensor | wp.array, + velocity: torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated combined joint-state write; use :meth:`write_joint_position_to_sim_index` + and :meth:`write_joint_velocity_to_sim_index` instead. - def _nft(self): - """Return the number of fixed tendons (0 if none).""" - return getattr(self, "_num_fixed_tendons", 0) + Args: + position: Joint positions [m or rad, depending on joint type]. Shape is + (len(env_ids), len(joint_ids)) with dtype wp.float32. + velocity: Joint velocities [m/s or rad/s, depending on joint type]. Shape is + (len(env_ids), len(joint_ids)) with dtype wp.float32. + joint_ids: Joint indices. Defaults to None (all joints). + env_ids: Environment indices. Defaults to None (all environments). + """ + warnings.warn( + "write_joint_state_to_sim is deprecated; use write_joint_position_to_sim_index" + " and write_joint_velocity_to_sim_index instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_joint_position_to_sim_index(position=position, joint_ids=joint_ids, env_ids=env_ids) + self.write_joint_velocity_to_sim_index(velocity=velocity, joint_ids=joint_ids, env_ids=env_ids) - def _nst(self): - """Return the number of spatial tendons (0 if none).""" - return getattr(self, "_num_spatial_tendons", 0) + def write_joint_friction_coefficient_to_sim( + self, + joint_friction_coeff: torch.Tensor | wp.array | float, + joint_dynamic_friction_coeff: torch.Tensor | wp.array | float | None = None, + joint_viscous_friction_coeff: torch.Tensor | wp.array | float | None = None, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated, same as :meth:`write_joint_friction_coefficient_to_sim_index`.""" + warnings.warn( + "The function 'write_joint_friction_coefficient_to_sim' will be deprecated in a future release. Please" + " use 'write_joint_friction_coefficient_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_joint_friction_coefficient_to_sim_index( + joint_friction_coeff=joint_friction_coeff, + joint_dynamic_friction_coeff=joint_dynamic_friction_coeff, + joint_viscous_friction_coeff=joint_viscous_friction_coeff, + joint_ids=joint_ids, + env_ids=env_ids, + ) - def _resolve_joint_values(self, pattern_dict: dict[str, float], buffer: wp.array) -> None: - """Resolve a {pattern: value} dict into a per-joint buffer. + def write_joint_dynamic_friction_coefficient_to_sim( + self, + joint_dynamic_friction_coeff: torch.Tensor | wp.array | float, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated, same as :meth:`write_joint_dynamic_friction_coefficient_to_sim_index`.""" + warnings.warn( + "The function 'write_joint_dynamic_friction_coefficient_to_sim' will be deprecated in a future release. " + "Please use 'write_joint_dynamic_friction_coefficient_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_joint_dynamic_friction_coefficient_to_sim_index( + joint_dynamic_friction_coeff=joint_dynamic_friction_coeff, + joint_ids=joint_ids, + env_ids=env_ids, + ) - Builds values on CPU then copies to buffer's device (GPU arrays' - .numpy() returns a read-only copy, not a writable view). - """ - buf_np = buffer.numpy() - modified = False - for pattern, value in pattern_dict.items(): - for j, name in enumerate(self._joint_names): - if re.fullmatch(pattern, name): - buf_np[:, j] = value - modified = True - if modified: - wp.copy(buffer, wp.from_numpy(buf_np, dtype=buffer.dtype, device=str(buffer.device))) + def write_joint_viscous_friction_coefficient_to_sim( + self, + joint_viscous_friction_coeff: torch.Tensor | wp.array | float, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated, same as :meth:`write_joint_viscous_friction_coefficient_to_sim_index`.""" + warnings.warn( + "The function 'write_joint_viscous_friction_coefficient_to_sim' will be deprecated in a future release. " + "Please use 'write_joint_viscous_friction_coefficient_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_joint_viscous_friction_coefficient_to_sim_index( + joint_viscous_friction_coeff=joint_viscous_friction_coeff, + joint_ids=joint_ids, + env_ids=env_ids, + ) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py index 7c59c946dfae..de0934c666aa 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py @@ -3,10 +3,9 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Articulation data backed by ovphysx TensorBindingsAPI.""" - from __future__ import annotations +import logging import warnings from typing import Any @@ -25,13 +24,22 @@ _projected_gravity, _world_vel_to_body_ang, _world_vel_to_body_lin, + concat_body_pose_and_vel_to_state, + concat_root_pose_and_vel_to_state, + get_body_com_pose_from_body_link_pose, + get_body_link_vel_from_body_com_vel, + vec13f, ) +from isaaclab_ovphysx.physics import OvPhysxManager + +from .kernels import _fd_joint_acc -from .kernels import _compose_body_com_poses, _fd_joint_acc +# import logger +logger = logging.getLogger(__name__) class ArticulationData(BaseArticulationData): - """Data container for an articulation backed by ovphysx tensor bindings. + """Data container for an articulation. This class contains the data for an articulation in the simulation. The data includes the state of the root rigid body, the state of all the bodies in the articulation, and the joint state. The data is @@ -47,55 +55,70 @@ class ArticulationData(BaseArticulationData): Depending on the settings, the two frames may not coincide with each other. In the robotics sense, the actor frame can be interpreted as the link frame. - Uses ovphysx :class:`TensorBinding` objects to lazily read simulation state into warp - arrays. Writes happen via the :class:`Articulation` class. + .. note:: + **Pull-to-refresh model.** OVPhysX state properties are *not* automatically updated each + simulation step. Each property getter pulls fresh data from the OVPhysX ``TensorBinding`` + on first access per timestamp, then caches the result until the next step. This differs + from the Newton backend, where buffers are refreshed automatically by the simulation. + + .. note:: + **CPU-only bindings.** OVPhysX exposes a subset of bindings (``BODY_MASS``, ``BODY_COM_POSE``, + ``BODY_INERTIA``, and most ``DOF_*`` property bindings) on CPU only. These are routed through + pinned-host staging buffers via :meth:`_binding_read` so that GPU-resident consumers see the + data without per-step host allocations. """ __backend_name__: str = "ovphysx" """The name of the backend for the articulation data.""" - def __init__(self, bindings: dict[int, Any], device: str, binding_getter=None): - """Initialize the articulation data. + def __init__(self, bindings: dict[int, Any], device: str) -> None: + """Initialize the articulation data container. Args: - bindings: Mapping from ovphysx tensor type constant to a - live TensorBinding for this articulation. - device: The compute device (``"cpu"`` or ``"cuda:N"``). - binding_getter: Optional callable(tensor_type) -> TensorBinding - that lazily creates bindings on first access. When provided, - ``_get_binding()`` delegates to this instead of only checking - the static ``bindings`` dict. + bindings: Dictionary of OVPhysX :class:`TensorBinding` objects keyed + by :class:`isaaclab_ovphysx.tensor_types.TensorType`. All counts + (instances, bodies, DOFs, fixed/spatial tendons) are derived + from the binding metadata. Name lists are assigned by + :meth:`~isaaclab_ovphysx.assets.Articulation._initialize_impl` + after construction. + device: Simulation device string (e.g., ``"cuda:0"`` or ``"cpu"``). """ super().__init__(root_view=None, device=device) self._bindings = bindings - self._binding_getter = binding_getter - self._sim_timestamp: float = 0.0 - self._is_primed = False - # Metadata from an arbitrary articulation binding. + # Every OVPhysX TensorBinding carries the articulation metadata + # (instance count, dof_count, body_count, fixed/spatial tendon counts); + # any binding will do for the read. sample = next(iter(bindings.values())) - self._num_instances = sample.count - self._num_joints = sample.dof_count - self._num_bodies = sample.body_count - self._is_fixed_base = sample.is_fixed_base - - self.body_names = list(sample.body_names) - self.joint_names = list(sample.dof_names) - self.fixed_tendon_names: list[str] = [] - self.spatial_tendon_names: list[str] = [] - - self._num_fixed_tendons = 0 - self._num_spatial_tendons = 0 + self.num_instances = sample.count + self.num_bodies = sample.body_count + self.num_joints = sample.dof_count + self.num_fixed_tendons = getattr(sample, "fixed_tendon_count", 0) + self.num_spatial_tendons = getattr(sample, "spatial_tendon_count", 0) + # private aliases used throughout _create_buffers and property bodies + self._num_instances = self.num_instances + self._num_bodies = self.num_bodies + self._num_joints = self.num_joints + self._num_fixed_tendons = self.num_fixed_tendons + self._num_spatial_tendons = self.num_spatial_tendons + + # Set initial time stamp + self._sim_timestamp: float = 0.0 + self._is_primed: bool = False + # pinned-host staging buffers for CPU-only bindings (keyed by tensor_type) + self._cpu_staging_buffers: dict[int, wp.array] = {} + # scratch buffers for _get_read_view cache (keyed by (tensor_type, ptr)) + self._read_scratch: dict = {} - # Initialize parametric gravity and forward vectors (matching PhysX/Newton pattern). - # Guard against None sim context (e.g. mock/test environments). + # obtain gravity from the simulation configuration (fall back to standard + # gravity when the simulation has not been configured yet, e.g. in unit tests) + gravity = (0.0, 0.0, -9.81) from isaaclab.physics import PhysicsManager - gravity = (0.0, 0.0, -9.81) if PhysicsManager._sim is not None and hasattr(PhysicsManager._sim, "cfg"): gravity = PhysicsManager._sim.cfg.gravity gravity_np = np.array(gravity, dtype=np.float32) - gravity_mag = np.linalg.norm(gravity_np) + gravity_mag = float(np.linalg.norm(gravity_np)) if gravity_mag == 0.0: gravity_dir = np.array([0.0, 0.0, -1.0], dtype=np.float32) else: @@ -103,28 +126,11 @@ def __init__(self, bindings: dict[int, Any], device: str, binding_getter=None): gravity_dir_tiled = np.tile(gravity_dir, (self._num_instances, 1)) forward_tiled = np.tile(np.array([1.0, 0.0, 0.0], dtype=np.float32), (self._num_instances, 1)) + # Initialize constants self.GRAVITY_VEC_W = ProxyArray(wp.from_numpy(gravity_dir_tiled, dtype=wp.vec3f, device=device)) self.FORWARD_VEC_B = ProxyArray(wp.from_numpy(forward_tiled, dtype=wp.vec3f, device=device)) - def update(self, dt: float) -> None: - """Update the data for the articulation. - - Args: - dt: The time step for the update [s]. This must be a positive value. - """ - self._sim_timestamp += dt - - # Finite-difference joint acceleration from velocity. - if dt > 0.0 and self._previous_joint_vel is not None: - cur_vel = self.joint_vel - wp.launch( - _fd_joint_acc, - dim=(self._num_instances, self._num_joints), - inputs=[cur_vel, self._previous_joint_vel, 1.0 / dt], - outputs=[self._joint_acc.data], - device=self.device, - ) - self._joint_acc.timestamp = self._sim_timestamp + self._create_buffers() @property def is_primed(self) -> bool: @@ -148,6 +154,31 @@ def is_primed(self, value: bool) -> None: raise ValueError("The articulation data is already primed.") self._is_primed = True + def update(self, dt: float) -> None: + """Updates the data for the articulation. + + Args: + dt: The time step for the update. This must be a positive value. + """ + # update the simulation timestamp + self._sim_timestamp += dt + if not self._is_primed: + return + # trigger an update of the joint acceleration buffer via finite differencing + if dt > 0.0 and self._previous_joint_vel is not None: + cur_vel_buf = self._joint_vel_buf + # ensure joint vel buffer is fresh before differencing + self._read_binding_into_buf(TT.DOF_VELOCITY, cur_vel_buf) + wp.launch( + _fd_joint_acc, + dim=(self._num_instances, self._num_joints), + inputs=[cur_vel_buf.data, self._previous_joint_vel, 1.0 / dt], + outputs=[self._joint_acc.data], + device=self.device, + ) + self._joint_acc.timestamp = self._sim_timestamp + wp.copy(self._previous_joint_vel, cur_vel_buf.data) + """ Names. """ @@ -159,10 +190,10 @@ def is_primed(self, value: bool) -> None: """Joint names in the order parsed by the simulation view.""" fixed_tendon_names: list[str] = None - """Fixed tendon names in the order parsed by the simulation view.""" + """Fixed tendon names in the order parsed by USD.""" spatial_tendon_names: list[str] = None - """Spatial tendon names in the order parsed by the simulation view.""" + """Spatial tendon names in the order parsed by USD.""" """ Defaults - Initial state. @@ -170,10 +201,12 @@ def is_primed(self, value: bool) -> None: @property def default_root_pose(self) -> ProxyArray: - """Default root pose ``[pos, quat]`` in the local environment frame. + """Default root pose ``[pos, quat]`` in local environment frame [m, -]. + + Shape is (num_instances,), dtype = wp.transformf. + In torch this resolves to (num_instances, 7). - The position and quaternion are of the articulation root's actor frame. - Shape is (num_instances,), dtype = wp.transformf. In torch this resolves to (num_instances, 7). + Populated from :attr:`ArticulationCfg.init_state` during initialisation. """ if self._default_root_pose_ta is None: self._default_root_pose_ta = ProxyArray(self._default_root_pose) @@ -184,21 +217,23 @@ def default_root_pose(self, value: wp.array) -> None: """Set the default root pose. Args: - value: The default root pose. Shape is (num_instances, 7). + value: The default root pose, shape (num_instances, 7). Raises: ValueError: If the articulation data is already primed. """ - if self.is_primed: + if self._is_primed: raise ValueError("The articulation data is already primed.") self._default_root_pose.assign(value) @property def default_root_vel(self) -> ProxyArray: - """Default root velocity ``[lin_vel, ang_vel]`` in the local environment frame. + """Default root velocity ``[lin_vel, ang_vel]`` in local environment frame [m/s, rad/s]. - The linear and angular velocities are of the articulation root's center of mass frame. - Shape is (num_instances,), dtype = wp.spatial_vectorf. In torch this resolves to (num_instances, 6). + Shape is (num_instances,), dtype = wp.spatial_vectorf. + In torch this resolves to (num_instances, 6). + + Populated from :attr:`ArticulationCfg.init_state` during initialisation. """ if self._default_root_vel_ta is None: self._default_root_vel_ta = ProxyArray(self._default_root_vel) @@ -209,12 +244,12 @@ def default_root_vel(self, value: wp.array) -> None: """Set the default root velocity. Args: - value: The default root velocity. Shape is (num_instances, 6). + value: The default root velocity, shape (num_instances, 6). Raises: ValueError: If the articulation data is already primed. """ - if self.is_primed: + if self._is_primed: raise ValueError("The articulation data is already primed.") self._default_root_vel.assign(value) @@ -222,10 +257,7 @@ def default_root_vel(self, value: wp.array) -> None: def default_joint_pos(self) -> ProxyArray: """Default joint positions of all joints [m or rad, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). - - This quantity is configured through the :attr:`isaaclab.assets.ArticulationCfg.init_state` parameter. + Shape is (num_instances, num_joints), dtype = wp.float32. """ if self._default_joint_pos_ta is None: self._default_joint_pos_ta = ProxyArray(self._default_joint_pos) @@ -236,12 +268,12 @@ def default_joint_pos(self, value: wp.array) -> None: """Set the default joint positions. Args: - value: The default joint positions. Shape is (num_instances, num_joints). + value: The default joint positions, shape (num_instances, num_joints). Raises: ValueError: If the articulation data is already primed. """ - if self.is_primed: + if self._is_primed: raise ValueError("The articulation data is already primed.") self._default_joint_pos.assign(value) @@ -249,10 +281,7 @@ def default_joint_pos(self, value: wp.array) -> None: def default_joint_vel(self) -> ProxyArray: """Default joint velocities of all joints [m/s or rad/s, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). - - This quantity is configured through the :attr:`isaaclab.assets.ArticulationCfg.init_state` parameter. + Shape is (num_instances, num_joints), dtype = wp.float32. """ if self._default_joint_vel_ta is None: self._default_joint_vel_ta = ProxyArray(self._default_joint_vel) @@ -263,12 +292,12 @@ def default_joint_vel(self, value: wp.array) -> None: """Set the default joint velocities. Args: - value: The default joint velocities. Shape is (num_instances, num_joints). + value: The default joint velocities, shape (num_instances, num_joints). Raises: ValueError: If the articulation data is already primed. """ - if self.is_primed: + if self._is_primed: raise ValueError("The articulation data is already primed.") self._default_joint_vel.assign(value) @@ -280,12 +309,7 @@ def default_joint_vel(self, value: wp.array) -> None: def joint_pos_target(self) -> ProxyArray: """Joint position targets commanded by the user [m or rad, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). - - For an implicit actuator model, the targets are directly set into the simulation. - For an explicit actuator model, the targets are used to compute the joint torques - (see :attr:`applied_torque`), which are then set into the simulation. + Shape is (num_instances, num_joints), dtype = wp.float32. """ if self._joint_pos_target_ta is None: self._joint_pos_target_ta = ProxyArray(self._joint_pos_target) @@ -295,12 +319,7 @@ def joint_pos_target(self) -> ProxyArray: def joint_vel_target(self) -> ProxyArray: """Joint velocity targets commanded by the user [m/s or rad/s, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). - - For an implicit actuator model, the targets are directly set into the simulation. - For an explicit actuator model, the targets are used to compute the joint torques - (see :attr:`applied_torque`), which are then set into the simulation. + Shape is (num_instances, num_joints), dtype = wp.float32. """ if self._joint_vel_target_ta is None: self._joint_vel_target_ta = ProxyArray(self._joint_vel_target) @@ -310,12 +329,7 @@ def joint_vel_target(self) -> ProxyArray: def joint_effort_target(self) -> ProxyArray: """Joint effort targets commanded by the user [N or N*m, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). - - For an implicit actuator model, the targets are directly set into the simulation. - For an explicit actuator model, the targets are used to compute the joint torques - (see :attr:`applied_torque`), which are then set into the simulation. + Shape is (num_instances, num_joints), dtype = wp.float32. """ if self._joint_effort_target_ta is None: self._joint_effort_target_ta = ProxyArray(self._joint_effort_target) @@ -329,12 +343,7 @@ def joint_effort_target(self) -> ProxyArray: def computed_torque(self) -> ProxyArray: """Joint torques computed from the actuator model (before clipping) [N*m]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). - - This quantity is the raw torque output from the actuator model, before any clipping is applied. - It is exposed for users who want to inspect the computations inside the actuator model. - For instance, to penalize the learning agent for a difference between the computed and applied torques. + Shape is (num_instances, num_joints), dtype = wp.float32. """ if self._computed_torque_ta is None: self._computed_torque_ta = ProxyArray(self._computed_torque) @@ -344,11 +353,7 @@ def computed_torque(self) -> ProxyArray: def applied_torque(self) -> ProxyArray: """Joint torques applied from the actuator model (after clipping) [N*m]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). - - These torques are set into the simulation, after clipping the :attr:`computed_torque` based on the - actuator model. + Shape is (num_instances, num_joints), dtype = wp.float32. """ if self._applied_torque_ta is None: self._applied_torque_ta = ProxyArray(self._applied_torque) @@ -360,85 +365,132 @@ def applied_torque(self) -> ProxyArray: @property def joint_stiffness(self) -> ProxyArray: - """Joint stiffness provided to the simulation. + """Joint stiffness provided to the simulation [N*m/rad or N/m, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). + Shape is (num_instances, num_joints), dtype = wp.float32. - In the case of explicit actuators, the value for the corresponding joints is zero. + Routed through pinned-host staging because ``DOF_STIFFNESS`` is a + CPU-only OVPhysX binding. """ + self._read_scalar_binding(TT.DOF_STIFFNESS, self._joint_stiffness) if self._joint_stiffness_ta is None: - self._joint_stiffness_ta = ProxyArray(self._joint_stiffness) + self._joint_stiffness_ta = ProxyArray(self._joint_stiffness.data) return self._joint_stiffness_ta @property def joint_damping(self) -> ProxyArray: - """Joint damping provided to the simulation. + """Joint damping provided to the simulation [N*m*s/rad or N*s/m, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). + Shape is (num_instances, num_joints), dtype = wp.float32. - In the case of explicit actuators, the value for the corresponding joints is zero. + Routed through pinned-host staging because ``DOF_DAMPING`` is a + CPU-only OVPhysX binding. """ + self._read_scalar_binding(TT.DOF_DAMPING, self._joint_damping) if self._joint_damping_ta is None: - self._joint_damping_ta = ProxyArray(self._joint_damping) + self._joint_damping_ta = ProxyArray(self._joint_damping.data) return self._joint_damping_ta @property def joint_armature(self) -> ProxyArray: - """Joint armature provided to the simulation. + """Joint armature provided to the simulation [kg*m^2]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). + Shape is (num_instances, num_joints), dtype = wp.float32. + + Routed through pinned-host staging because ``DOF_ARMATURE`` is a + CPU-only OVPhysX binding. """ + self._read_scalar_binding(TT.DOF_ARMATURE, self._joint_armature) if self._joint_armature_ta is None: - self._joint_armature_ta = ProxyArray(self._joint_armature) + self._joint_armature_ta = ProxyArray(self._joint_armature.data) return self._joint_armature_ta @property def joint_friction_coeff(self) -> ProxyArray: - """Joint static friction coefficient provided to the simulation. + """Joint static friction coefficient [dimensionless]. + + Shape is (num_instances, num_joints), dtype = wp.float32. + Component ``[..., 0]`` of the ``DOF_FRICTION_PROPERTIES`` binding. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). + Routed through pinned-host staging because ``DOF_FRICTION_PROPERTIES`` + is a CPU-only OVPhysX binding. """ + self._read_scalar_binding(TT.DOF_FRICTION_PROPERTIES, self._joint_friction_props_buf) if self._joint_friction_coeff_ta is None: self._joint_friction_coeff_ta = ProxyArray(self._joint_friction_coeff) return self._joint_friction_coeff_ta + @property + def joint_dynamic_friction_coeff(self) -> ProxyArray: + """Joint dynamic friction coefficient [dimensionless]. + + Shape is (num_instances, num_joints), dtype = wp.float32. + Component ``[..., 1]`` of the ``DOF_FRICTION_PROPERTIES`` binding. + + Routed through pinned-host staging because ``DOF_FRICTION_PROPERTIES`` + is a CPU-only OVPhysX binding. + """ + self._read_scalar_binding(TT.DOF_FRICTION_PROPERTIES, self._joint_friction_props_buf) + if self._joint_dynamic_friction_coeff_ta is None: + self._joint_dynamic_friction_coeff_ta = ProxyArray(self._joint_dynamic_friction_coeff) + return self._joint_dynamic_friction_coeff_ta + + @property + def joint_viscous_friction_coeff(self) -> ProxyArray: + """Joint viscous friction coefficient [N*m*s/rad or N*s/m, depending on joint type]. + + Shape is (num_instances, num_joints), dtype = wp.float32. + Component ``[..., 2]`` of the ``DOF_FRICTION_PROPERTIES`` binding. + + Routed through pinned-host staging because ``DOF_FRICTION_PROPERTIES`` + is a CPU-only OVPhysX binding. + """ + self._read_scalar_binding(TT.DOF_FRICTION_PROPERTIES, self._joint_friction_props_buf) + if self._joint_viscous_friction_coeff_ta is None: + self._joint_viscous_friction_coeff_ta = ProxyArray(self._joint_viscous_friction_coeff) + return self._joint_viscous_friction_coeff_ta + @property def joint_pos_limits(self) -> ProxyArray: - """Joint position limits provided to the simulation. + """Joint position limits provided to the simulation [m or rad, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.vec2f. In torch this resolves to - (num_instances, num_joints, 2). + Shape is (num_instances, num_joints), dtype = wp.vec2f. + In torch this resolves to (num_instances, num_joints, 2). - The limits are in the order :math:`[lower, upper]`. + The limits are in the order :math:`[lower, upper]`. Routed through + pinned-host staging because ``DOF_LIMIT`` is a CPU-only OVPhysX binding. """ + self._read_scalar_binding(TT.DOF_LIMIT, self._joint_pos_limits) if self._joint_pos_limits_ta is None: - self._joint_pos_limits_ta = ProxyArray(self._joint_pos_limits) + self._joint_pos_limits_ta = ProxyArray(self._joint_pos_limits.data) return self._joint_pos_limits_ta @property def joint_vel_limits(self) -> ProxyArray: """Joint maximum velocity provided to the simulation [m/s or rad/s, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). + Shape is (num_instances, num_joints), dtype = wp.float32. + + Routed through pinned-host staging because ``DOF_MAX_VELOCITY`` is a + CPU-only OVPhysX binding. """ + self._read_scalar_binding(TT.DOF_MAX_VELOCITY, self._joint_vel_limits) if self._joint_vel_limits_ta is None: - self._joint_vel_limits_ta = ProxyArray(self._joint_vel_limits) + self._joint_vel_limits_ta = ProxyArray(self._joint_vel_limits.data) return self._joint_vel_limits_ta @property def joint_effort_limits(self) -> ProxyArray: """Joint maximum effort provided to the simulation [N or N*m, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). + Shape is (num_instances, num_joints), dtype = wp.float32. + + Routed through pinned-host staging because ``DOF_MAX_FORCE`` is a + CPU-only OVPhysX binding. """ + self._read_scalar_binding(TT.DOF_MAX_FORCE, self._joint_effort_limits) if self._joint_effort_limits_ta is None: - self._joint_effort_limits_ta = ProxyArray(self._joint_effort_limits) + self._joint_effort_limits_ta = ProxyArray(self._joint_effort_limits.data) return self._joint_effort_limits_ta """ @@ -447,25 +499,12 @@ def joint_effort_limits(self) -> ProxyArray: @property def soft_joint_pos_limits(self) -> ProxyArray: - r"""Soft joint position limits for all joints. - - Shape is (num_instances, num_joints), dtype = wp.vec2f. In torch this resolves to - (num_instances, num_joints, 2). - - The limits are in the order :math:`[lower, upper]`. The soft joint position limits are computed as - a sub-region of the :attr:`joint_pos_limits` based on the - :attr:`~isaaclab.assets.ArticulationCfg.soft_joint_pos_limit_factor` parameter. + r"""Soft joint position limits for all joints [m or rad, depending on joint type]. - Consider the joint position limits :math:`[lower, upper]` and the soft joint position limits - :math:`[soft\_lower, soft\_upper]`. The soft joint position limits are computed as: + Shape is (num_instances, num_joints), dtype = wp.vec2f. + In torch this resolves to (num_instances, num_joints, 2). - .. math:: - - soft\_lower = (lower + upper) / 2 - factor * (upper - lower) / 2 - soft\_upper = (lower + upper) / 2 + factor * (upper - lower) / 2 - - The soft joint position limits help specify a safety region around the joint limits. It isn't used by the - simulation, but is useful for learning agents to prevent the joint positions from violating the limits. + The limits are in the order :math:`[lower, upper]`. """ if self._soft_joint_pos_limits_ta is None: self._soft_joint_pos_limits_ta = ProxyArray(self._soft_joint_pos_limits) @@ -473,13 +512,9 @@ def soft_joint_pos_limits(self) -> ProxyArray: @property def soft_joint_vel_limits(self) -> ProxyArray: - """Soft joint velocity limits for all joints. + """Soft joint velocity limits for all joints [m/s or rad/s, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). - - These are obtained from the actuator model. It may differ from :attr:`joint_vel_limits` if the actuator model - has a variable velocity limit model. For instance, in a variable gear ratio actuator model. + Shape is (num_instances, num_joints), dtype = wp.float32. """ if self._soft_joint_vel_limits_ta is None: self._soft_joint_vel_limits_ta = ProxyArray(self._soft_joint_vel_limits) @@ -489,8 +524,7 @@ def soft_joint_vel_limits(self) -> ProxyArray: def gear_ratio(self) -> ProxyArray: """Gear ratio for relating motor torques to applied joint torques. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). + Shape is (num_instances, num_joints), dtype = wp.float32. """ if self._gear_ratio_ta is None: self._gear_ratio_ta = ProxyArray(self._gear_ratio) @@ -502,68 +536,84 @@ def gear_ratio(self) -> ProxyArray: @property def fixed_tendon_stiffness(self) -> ProxyArray: - """Fixed tendon stiffness provided to the simulation. + """Fixed-tendon stiffness gains [N*m/rad]. + + Shape is (num_instances, num_fixed_tendons), dtype = ``wp.float32``. - Shape is (num_instances, num_fixed_tendons), dtype = wp.float32. In torch this resolves to - (num_instances, num_fixed_tendons). + Routed through pinned-host staging (CPU-only binding). """ + self._read_scalar_binding(TT.FIXED_TENDON_STIFFNESS, self._fixed_tendon_stiffness) if self._fixed_tendon_stiffness_ta is None: - self._fixed_tendon_stiffness_ta = ProxyArray(self._fixed_tendon_stiffness) + self._fixed_tendon_stiffness_ta = ProxyArray(self._fixed_tendon_stiffness.data) return self._fixed_tendon_stiffness_ta @property def fixed_tendon_damping(self) -> ProxyArray: - """Fixed tendon damping provided to the simulation. + """Fixed-tendon damping coefficients [N*m*s/rad]. + + Shape is (num_instances, num_fixed_tendons), dtype = ``wp.float32``. - Shape is (num_instances, num_fixed_tendons), dtype = wp.float32. In torch this resolves to - (num_instances, num_fixed_tendons). + Routed through pinned-host staging (CPU-only binding). """ + self._read_scalar_binding(TT.FIXED_TENDON_DAMPING, self._fixed_tendon_damping) if self._fixed_tendon_damping_ta is None: - self._fixed_tendon_damping_ta = ProxyArray(self._fixed_tendon_damping) + self._fixed_tendon_damping_ta = ProxyArray(self._fixed_tendon_damping.data) return self._fixed_tendon_damping_ta @property def fixed_tendon_limit_stiffness(self) -> ProxyArray: - """Fixed tendon limit stiffness provided to the simulation. + """Fixed-tendon limit stiffness [N*m/rad]. - Shape is (num_instances, num_fixed_tendons), dtype = wp.float32. In torch this resolves to - (num_instances, num_fixed_tendons). + Shape is (num_instances, num_fixed_tendons), dtype = ``wp.float32``. + + Routed through pinned-host staging (CPU-only binding). """ + self._read_scalar_binding(TT.FIXED_TENDON_LIMIT_STIFFNESS, self._fixed_tendon_limit_stiffness) if self._fixed_tendon_limit_stiffness_ta is None: - self._fixed_tendon_limit_stiffness_ta = ProxyArray(self._fixed_tendon_limit_stiffness) + self._fixed_tendon_limit_stiffness_ta = ProxyArray(self._fixed_tendon_limit_stiffness.data) return self._fixed_tendon_limit_stiffness_ta @property def fixed_tendon_rest_length(self) -> ProxyArray: - """Fixed tendon rest length provided to the simulation. + """Fixed-tendon rest lengths [m]. + + Shape is (num_instances, num_fixed_tendons), dtype = ``wp.float32``. - Shape is (num_instances, num_fixed_tendons), dtype = wp.float32. In torch this resolves to - (num_instances, num_fixed_tendons). + Routed through pinned-host staging (CPU-only binding). """ + self._read_scalar_binding(TT.FIXED_TENDON_REST_LENGTH, self._fixed_tendon_rest_length) if self._fixed_tendon_rest_length_ta is None: - self._fixed_tendon_rest_length_ta = ProxyArray(self._fixed_tendon_rest_length) + self._fixed_tendon_rest_length_ta = ProxyArray(self._fixed_tendon_rest_length.data) return self._fixed_tendon_rest_length_ta @property def fixed_tendon_offset(self) -> ProxyArray: - """Fixed tendon offset provided to the simulation. + """Fixed-tendon offsets [m]. - Shape is (num_instances, num_fixed_tendons), dtype = wp.float32. In torch this resolves to - (num_instances, num_fixed_tendons). + Shape is (num_instances, num_fixed_tendons), dtype = ``wp.float32``. + + Routed through pinned-host staging (CPU-only binding). """ + self._read_scalar_binding(TT.FIXED_TENDON_OFFSET, self._fixed_tendon_offset) if self._fixed_tendon_offset_ta is None: - self._fixed_tendon_offset_ta = ProxyArray(self._fixed_tendon_offset) + self._fixed_tendon_offset_ta = ProxyArray(self._fixed_tendon_offset.data) return self._fixed_tendon_offset_ta @property def fixed_tendon_pos_limits(self) -> ProxyArray: - """Fixed tendon position limits provided to the simulation. + """Fixed tendon position limits provided to the simulation [m or rad]. + + Shape is (num_instances, num_fixed_tendons), dtype = ``wp.vec2f``. + In torch this resolves to (num_instances, num_fixed_tendons, 2). - Shape is (num_instances, num_fixed_tendons), dtype = wp.vec2f. In torch this resolves to - (num_instances, num_fixed_tendons, 2). + .. deprecated:: + Use :attr:`fixed_tendon_limit` (shape ``(N, T, 2)``, dtype + ``wp.float32``) instead. This alias is kept for backwards + compatibility and reads the same underlying data. """ + self._read_scalar_binding(TT.FIXED_TENDON_LIMIT, self._fixed_tendon_pos_limits) if self._fixed_tendon_pos_limits_ta is None: - self._fixed_tendon_pos_limits_ta = ProxyArray(self._fixed_tendon_pos_limits) + self._fixed_tendon_pos_limits_ta = ProxyArray(self._fixed_tendon_pos_limits.data) return self._fixed_tendon_pos_limits_ta """ @@ -572,46 +622,54 @@ def fixed_tendon_pos_limits(self) -> ProxyArray: @property def spatial_tendon_stiffness(self) -> ProxyArray: - """Spatial tendon stiffness provided to the simulation. + """Spatial-tendon stiffness gains [N/m]. - Shape is (num_instances, num_spatial_tendons), dtype = wp.float32. In torch this resolves to - (num_instances, num_spatial_tendons). + Shape is (num_instances, num_spatial_tendons), dtype = ``wp.float32``. + + Routed through pinned-host staging (CPU-only binding). """ + self._read_scalar_binding(TT.SPATIAL_TENDON_STIFFNESS, self._spatial_tendon_stiffness) if self._spatial_tendon_stiffness_ta is None: - self._spatial_tendon_stiffness_ta = ProxyArray(self._spatial_tendon_stiffness) + self._spatial_tendon_stiffness_ta = ProxyArray(self._spatial_tendon_stiffness.data) return self._spatial_tendon_stiffness_ta @property def spatial_tendon_damping(self) -> ProxyArray: - """Spatial tendon damping provided to the simulation. + """Spatial-tendon damping coefficients [N*s/m]. + + Shape is (num_instances, num_spatial_tendons), dtype = ``wp.float32``. - Shape is (num_instances, num_spatial_tendons), dtype = wp.float32. In torch this resolves to - (num_instances, num_spatial_tendons). + Routed through pinned-host staging (CPU-only binding). """ + self._read_scalar_binding(TT.SPATIAL_TENDON_DAMPING, self._spatial_tendon_damping) if self._spatial_tendon_damping_ta is None: - self._spatial_tendon_damping_ta = ProxyArray(self._spatial_tendon_damping) + self._spatial_tendon_damping_ta = ProxyArray(self._spatial_tendon_damping.data) return self._spatial_tendon_damping_ta @property def spatial_tendon_limit_stiffness(self) -> ProxyArray: - """Spatial tendon limit stiffness provided to the simulation. + """Spatial-tendon limit stiffness [N/m]. + + Shape is (num_instances, num_spatial_tendons), dtype = ``wp.float32``. - Shape is (num_instances, num_spatial_tendons), dtype = wp.float32. In torch this resolves to - (num_instances, num_spatial_tendons). + Routed through pinned-host staging (CPU-only binding). """ + self._read_scalar_binding(TT.SPATIAL_TENDON_LIMIT_STIFFNESS, self._spatial_tendon_limit_stiffness) if self._spatial_tendon_limit_stiffness_ta is None: - self._spatial_tendon_limit_stiffness_ta = ProxyArray(self._spatial_tendon_limit_stiffness) + self._spatial_tendon_limit_stiffness_ta = ProxyArray(self._spatial_tendon_limit_stiffness.data) return self._spatial_tendon_limit_stiffness_ta @property def spatial_tendon_offset(self) -> ProxyArray: - """Spatial tendon offset provided to the simulation. + """Spatial-tendon offsets [m]. - Shape is (num_instances, num_spatial_tendons), dtype = wp.float32. In torch this resolves to - (num_instances, num_spatial_tendons). + Shape is (num_instances, num_spatial_tendons), dtype = ``wp.float32``. + + Routed through pinned-host staging (CPU-only binding). """ + self._read_scalar_binding(TT.SPATIAL_TENDON_OFFSET, self._spatial_tendon_offset) if self._spatial_tendon_offset_ta is None: - self._spatial_tendon_offset_ta = ProxyArray(self._spatial_tendon_offset) + self._spatial_tendon_offset_ta = ProxyArray(self._spatial_tendon_offset.data) return self._spatial_tendon_offset_ta """ @@ -620,8 +678,10 @@ def spatial_tendon_offset(self) -> ProxyArray: @property def root_link_pose_w(self) -> ProxyArray: - """Root link pose ``[pos, quat]`` in simulation world frame. - Shape is (num_instances,), dtype = wp.transformf. In torch this resolves to (num_instances, 7). + """Root link pose ``[pos, quat]`` in simulation world frame [m, -]. + + Shape is (num_instances,), dtype = wp.transformf. + In torch this resolves to (num_instances, 7). This quantity is the pose of the articulation root's actor frame relative to the world. The orientation is provided in (x, y, z, w) format. @@ -631,10 +691,21 @@ def root_link_pose_w(self) -> ProxyArray: self._root_link_pose_w_ta = ProxyArray(self._root_link_pose_w.data) return self._root_link_pose_w_ta + @property + def root_pose_w(self) -> ProxyArray: + """Alias for :attr:`root_link_pose_w` matching Newton's convention. + + Shape is (num_instances,), dtype = wp.transformf. + In torch this resolves to (num_instances, 7). + """ + return self.root_link_pose_w + @property def root_link_vel_w(self) -> ProxyArray: - """Root link velocity ``[lin_vel, ang_vel]`` in simulation world frame. - Shape is (num_instances,), dtype = wp.spatial_vectorf. In torch this resolves to (num_instances, 6). + """Root link velocity ``[lin_vel, ang_vel]`` in simulation world frame [m/s, rad/s]. + + Shape is (num_instances,), dtype = wp.spatial_vectorf. + In torch this resolves to (num_instances, 6). This quantity contains the linear and angular velocities of the articulation root's actor frame relative to the world. @@ -645,7 +716,7 @@ def root_link_vel_w(self) -> ProxyArray: if self._root_link_vel_w.timestamp < self._sim_timestamp: wp.launch( _copy_first_body, - dim=self._num_instances, + dim=self.num_instances, inputs=[self._body_link_vel_w.data], outputs=[self._root_link_vel_w.data], device=self.device, @@ -657,8 +728,10 @@ def root_link_vel_w(self) -> ProxyArray: @property def root_com_pose_w(self) -> ProxyArray: - """Root center of mass pose ``[pos, quat]`` in simulation world frame. - Shape is (num_instances,), dtype = wp.transformf. In torch this resolves to (num_instances, 7). + """Root center of mass pose ``[pos, quat]`` in simulation world frame [m, -]. + + Shape is (num_instances,), dtype = wp.transformf. + In torch this resolves to (num_instances, 7). This quantity is the pose of the articulation root's center of mass frame relative to the world. The orientation is provided in (x, y, z, w) format. @@ -666,7 +739,7 @@ def root_com_pose_w(self) -> ProxyArray: if self._root_com_pose_w.timestamp < self._sim_timestamp: wp.launch( _compose_root_com_pose, - dim=self._num_instances, + dim=self.num_instances, inputs=[self.root_link_pose_w, self.body_com_pose_b], outputs=[self._root_com_pose_w.data], device=self.device, @@ -678,8 +751,10 @@ def root_com_pose_w(self) -> ProxyArray: @property def root_com_vel_w(self) -> ProxyArray: - """Root center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame. - Shape is (num_instances,), dtype = wp.spatial_vectorf. In torch this resolves to (num_instances, 6). + """Root center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame [m/s, rad/s]. + + Shape is (num_instances,), dtype = wp.spatial_vectorf. + In torch this resolves to (num_instances, 6). This quantity contains the linear and angular velocities of the articulation root's center of mass frame relative to the world. @@ -695,98 +770,130 @@ def root_com_vel_w(self) -> ProxyArray: @property def body_mass(self) -> ProxyArray: - """Body mass in the world frame [kg]. + """Body masses [kg]. + + Shape is (num_instances, num_bodies), dtype = ``wp.float32``. - Shape is (num_instances, num_bodies), dtype = wp.float32. In torch this resolves to - (num_instances, num_bodies). + Routed through pinned-host staging because the underlying OVPhysX + binding is CPU-only (``ARTICULATION_BODY_MASS``). """ + self._read_scalar_binding(TT.BODY_MASS, self._body_mass) if self._body_mass_ta is None: - self._body_mass_ta = ProxyArray(self._body_mass) + self._body_mass_ta = ProxyArray(self._body_mass.data) return self._body_mass_ta @property def body_inertia(self) -> ProxyArray: - """Flattened body inertia in the world frame [kg*m^2]. + """Body inertia tensors [kg*m^2]. - Shape is (num_instances, num_bodies, 9), dtype = wp.float32. In torch this resolves to - (num_instances, num_bodies, 9). + Shape is (num_instances, num_bodies, 9), dtype = ``wp.float32``; the + trailing 9 is the row-major 3×3 inertia tensor. - Stored as a flattened 3x3 inertia matrix per body. + Routed through pinned-host staging (``ARTICULATION_BODY_INERTIA`` is + a CPU-only binding). """ + self._read_scalar_binding(TT.BODY_INERTIA, self._body_inertia) if self._body_inertia_ta is None: - self._body_inertia_ta = ProxyArray(self._body_inertia) + self._body_inertia_ta = ProxyArray(self._body_inertia.data) return self._body_inertia_ta @property def body_link_pose_w(self) -> ProxyArray: - """Body link pose ``[pos, quat]`` in simulation world frame. - Shape is (num_instances, num_bodies), dtype = wp.transformf. In torch this resolves to - (num_instances, num_bodies, 7). + """Body link pose ``[pos, quat]`` in simulation world frame [m, -]. + + Shape is (num_instances, num_bodies), dtype = wp.transformf. + In torch this resolves to (num_instances, num_bodies, 7). This quantity is the pose of the articulation links' actor frame relative to the world. The orientation is provided in (x, y, z, w) format. """ + if self._body_link_pose_w.timestamp < self._sim_timestamp: + # perform forward kinematics (shouldn't cause overhead if it happened already); + # skip when no physics instance is bound (mocked iface tests) + physx_instance = OvPhysxManager.get_physx_instance() + if physx_instance is not None: + physx_instance.update_articulations_kinematic() self._read_transform_binding(TT.LINK_POSE, self._body_link_pose_w) if self._body_link_pose_w_ta is None: self._body_link_pose_w_ta = ProxyArray(self._body_link_pose_w.data) return self._body_link_pose_w_ta @property - def body_link_vel_w(self) -> ProxyArray: - """Body link velocity ``[lin_vel, ang_vel]`` in simulation world frame. - Shape is (num_instances, num_bodies), dtype = wp.spatial_vectorf. In torch this resolves to - (num_instances, num_bodies, 6). + def body_com_vel_w(self) -> ProxyArray: + """Body center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame [m/s, rad/s]. - This quantity contains the linear and angular velocities of the articulation links' actor frame - relative to the world. + Shape is (num_instances, num_bodies), dtype = wp.spatial_vectorf. + In torch this resolves to (num_instances, num_bodies, 6). """ - self._read_spatial_vector_binding(TT.LINK_VELOCITY, self._body_link_vel_w) + self._read_spatial_vector_binding(TT.LINK_VELOCITY, self._body_com_vel_w) + if self._body_com_vel_w_ta is None: + self._body_com_vel_w_ta = ProxyArray(self._body_com_vel_w.data) + return self._body_com_vel_w_ta + + @property + def body_link_vel_w(self) -> ProxyArray: + """Body link velocity ``[lin_vel, ang_vel]`` in simulation world frame [m/s, rad/s]. + + Shape is (num_instances, num_bodies), dtype = wp.spatial_vectorf. + In torch this resolves to (num_instances, num_bodies, 6). + + Derived from :attr:`body_com_vel_w` and :attr:`body_com_pose_b` via + :func:`~isaaclab_ovphysx.assets.kernels.get_body_link_vel_from_body_com_vel`. + """ + if self._body_link_vel_w.timestamp >= self._sim_timestamp: + if self._body_link_vel_w_ta is None: + self._body_link_vel_w_ta = ProxyArray(self._body_link_vel_w.data) + return self._body_link_vel_w_ta + _ = self.body_com_vel_w + _ = self.body_link_pose_w + _ = self.body_com_pose_b + wp.launch( + get_body_link_vel_from_body_com_vel, + dim=(self.num_instances, self.num_bodies), + inputs=[self._body_com_vel_w.data, self._body_link_pose_w.data, self._body_com_pose_b.data], + outputs=[self._body_link_vel_w.data], + device=self.device, + ) + self._body_link_vel_w.timestamp = self._sim_timestamp if self._body_link_vel_w_ta is None: self._body_link_vel_w_ta = ProxyArray(self._body_link_vel_w.data) return self._body_link_vel_w_ta @property def body_com_pose_w(self) -> ProxyArray: - """Body center of mass pose ``[pos, quat]`` in simulation world frame. - Shape is (num_instances, num_bodies), dtype = wp.transformf. In torch this resolves to - (num_instances, num_bodies, 7). + """Body center of mass pose ``[pos, quat]`` in simulation world frame [m, -]. + + Shape is (num_instances, num_bodies), dtype = wp.transformf. + In torch this resolves to (num_instances, num_bodies, 7). - This quantity is the pose of the center of mass frame of the articulation links relative to the world. + Derived from :attr:`body_link_pose_w` and :attr:`body_com_pose_b` via + :func:`~isaaclab_ovphysx.assets.kernels.get_body_com_pose_from_body_link_pose`. The orientation is provided in (x, y, z, w) format. """ - if self._body_com_pose_w.timestamp < self._sim_timestamp: - wp.launch( - _compose_body_com_poses, - dim=(self._num_instances, self._num_bodies), - inputs=[self.body_link_pose_w, self.body_com_pose_b], - outputs=[self._body_com_pose_w.data], - device=self.device, - ) - self._body_com_pose_w.timestamp = self._sim_timestamp + if self._body_com_pose_w.timestamp >= self._sim_timestamp: + if self._body_com_pose_w_ta is None: + self._body_com_pose_w_ta = ProxyArray(self._body_com_pose_w.data) + return self._body_com_pose_w_ta + _ = self.body_link_pose_w + _ = self.body_com_pose_b + wp.launch( + get_body_com_pose_from_body_link_pose, + dim=(self.num_instances, self.num_bodies), + inputs=[self._body_link_pose_w.data, self._body_com_pose_b.data], + outputs=[self._body_com_pose_w.data], + device=self.device, + ) + self._body_com_pose_w.timestamp = self._sim_timestamp if self._body_com_pose_w_ta is None: self._body_com_pose_w_ta = ProxyArray(self._body_com_pose_w.data) return self._body_com_pose_w_ta - @property - def body_com_vel_w(self) -> ProxyArray: - """Body center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame. - Shape is (num_instances, num_bodies), dtype = wp.spatial_vectorf. In torch this resolves to - (num_instances, num_bodies, 6). - - This quantity contains the linear and angular velocities of the articulation links' center of mass frame - relative to the world. - - .. note:: - This is currently approximated using the link velocity. A proper COM velocity derivation - accounting for the COM offset is not yet implemented. - """ - return self.body_link_vel_w - @property def body_com_acc_w(self) -> ProxyArray: """Acceleration of all bodies center of mass ``[lin_acc, ang_acc]`` [m/s^2, rad/s^2]. - Shape is (num_instances, num_bodies), dtype = wp.spatial_vectorf. In torch this resolves to - (num_instances, num_bodies, 6). + + Shape is (num_instances, num_bodies), dtype = wp.spatial_vectorf. + In torch this resolves to (num_instances, num_bodies, 6). All values are relative to the world. """ @@ -797,9 +904,10 @@ def body_com_acc_w(self) -> ProxyArray: @property def body_com_pose_b(self) -> ProxyArray: - """Center of mass pose ``[pos, quat]`` of all bodies in their respective body's link frames. - Shape is (num_instances, num_bodies), dtype = wp.transformf. In torch this resolves to - (num_instances, num_bodies, 7). + """Center of mass pose ``[pos, quat]`` of all bodies in their respective body's link frames [m, -]. + + Shape is (num_instances, num_bodies), dtype = wp.transformf. + In torch this resolves to (num_instances, num_bodies, 7). This quantity is the pose of the center of mass frame of the rigid body relative to the body's link frame. The orientation is provided in (x, y, z, w) format. @@ -809,6 +917,23 @@ def body_com_pose_b(self) -> ProxyArray: self._body_com_pose_b_ta = ProxyArray(self._body_com_pose_b.data) return self._body_com_pose_b_ta + @property + def body_incoming_joint_wrench_b(self) -> ProxyArray: + """Incoming joint wrenches on each body in the body frame [N, N*m]. + + Shape is (num_instances, num_bodies), dtype = wp.spatial_vectorf. + In torch this resolves to (num_instances, num_bodies, 6). + + All body reaction wrenches are provided including the root body to the world of an articulation. + """ + self._read_spatial_vector_binding( + TT.LINK_INCOMING_JOINT_FORCE, + self._body_incoming_joint_wrench_buf, + ) + if self._body_incoming_joint_wrench_b_ta is None: + self._body_incoming_joint_wrench_b_ta = ProxyArray(self._body_incoming_joint_wrench_buf.data) + return self._body_incoming_joint_wrench_b_ta + """ Joint state properties. """ @@ -817,8 +942,7 @@ def body_com_pose_b(self) -> ProxyArray: def joint_pos(self) -> ProxyArray: """Joint positions of all joints [m or rad, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). + Shape is (num_instances, num_joints), dtype = wp.float32. """ self._read_binding_into_buf(TT.DOF_POSITION, self._joint_pos_buf) if self._joint_pos_ta is None: @@ -829,8 +953,7 @@ def joint_pos(self) -> ProxyArray: def joint_vel(self) -> ProxyArray: """Joint velocities of all joints [m/s or rad/s, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). + Shape is (num_instances, num_joints), dtype = wp.float32. """ self._read_binding_into_buf(TT.DOF_VELOCITY, self._joint_vel_buf) if self._joint_vel_ta is None: @@ -841,8 +964,7 @@ def joint_vel(self) -> ProxyArray: def joint_acc(self) -> ProxyArray: """Joint acceleration of all joints [m/s^2 or rad/s^2, depending on joint type]. - Shape is (num_instances, num_joints), dtype = wp.float32. In torch this resolves to - (num_instances, num_joints). + Shape is (num_instances, num_joints), dtype = wp.float32. .. note:: This quantity is computed via finite differencing of joint velocities. @@ -858,12 +980,13 @@ def joint_acc(self) -> ProxyArray: @property def projected_gravity_b(self) -> ProxyArray: """Projection of the gravity direction on base frame. + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). """ if self._projected_gravity_b.timestamp < self._sim_timestamp: wp.launch( _projected_gravity, - dim=self._num_instances, + dim=self.num_instances, inputs=[self.GRAVITY_VEC_W, self.root_link_pose_w], outputs=[self._projected_gravity_b.data], device=self.device, @@ -875,7 +998,8 @@ def projected_gravity_b(self) -> ProxyArray: @property def heading_w(self) -> ProxyArray: - """Yaw heading of the base frame (in radians). + """Yaw heading of the base frame (in radians) [rad]. + Shape is (num_instances,), dtype = wp.float32. .. note:: @@ -885,7 +1009,7 @@ def heading_w(self) -> ProxyArray: if self._heading_w.timestamp < self._sim_timestamp: wp.launch( _compute_heading, - dim=self._num_instances, + dim=self.num_instances, inputs=[self.FORWARD_VEC_B, self.root_link_pose_w], outputs=[self._heading_w.data], device=self.device, @@ -898,6 +1022,7 @@ def heading_w(self) -> ProxyArray: @property def root_link_lin_vel_b(self) -> ProxyArray: """Root link linear velocity in base frame [m/s]. + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). This quantity is the linear velocity of the articulation root's actor frame with respect to its actor frame. @@ -905,7 +1030,7 @@ def root_link_lin_vel_b(self) -> ProxyArray: if self._root_link_lin_vel_b.timestamp < self._sim_timestamp: wp.launch( _world_vel_to_body_lin, - dim=self._num_instances, + dim=self.num_instances, inputs=[self.root_link_pose_w, self.root_link_vel_w], outputs=[self._root_link_lin_vel_b.data], device=self.device, @@ -918,6 +1043,7 @@ def root_link_lin_vel_b(self) -> ProxyArray: @property def root_link_ang_vel_b(self) -> ProxyArray: """Root link angular velocity in base frame [rad/s]. + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). This quantity is the angular velocity of the articulation root's actor frame with respect to its actor frame. @@ -925,7 +1051,7 @@ def root_link_ang_vel_b(self) -> ProxyArray: if self._root_link_ang_vel_b.timestamp < self._sim_timestamp: wp.launch( _world_vel_to_body_ang, - dim=self._num_instances, + dim=self.num_instances, inputs=[self.root_link_pose_w, self.root_link_vel_w], outputs=[self._root_link_ang_vel_b.data], device=self.device, @@ -938,6 +1064,7 @@ def root_link_ang_vel_b(self) -> ProxyArray: @property def root_com_lin_vel_b(self) -> ProxyArray: """Root center of mass linear velocity in base frame [m/s]. + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). This quantity is the linear velocity of the articulation root's center of mass frame @@ -946,7 +1073,7 @@ def root_com_lin_vel_b(self) -> ProxyArray: if self._root_com_lin_vel_b.timestamp < self._sim_timestamp: wp.launch( _world_vel_to_body_lin, - dim=self._num_instances, + dim=self.num_instances, inputs=[self.root_link_pose_w, self.root_com_vel_w], outputs=[self._root_com_lin_vel_b.data], device=self.device, @@ -959,6 +1086,7 @@ def root_com_lin_vel_b(self) -> ProxyArray: @property def root_com_ang_vel_b(self) -> ProxyArray: """Root center of mass angular velocity in base frame [rad/s]. + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). This quantity is the angular velocity of the articulation root's center of mass frame @@ -967,7 +1095,7 @@ def root_com_ang_vel_b(self) -> ProxyArray: if self._root_com_ang_vel_b.timestamp < self._sim_timestamp: wp.launch( _world_vel_to_body_ang, - dim=self._num_instances, + dim=self.num_instances, inputs=[self.root_link_pose_w, self.root_com_vel_w], outputs=[self._root_com_ang_vel_b.data], device=self.device, @@ -983,10 +1111,9 @@ def root_com_ang_vel_b(self) -> ProxyArray: @property def root_link_pos_w(self) -> ProxyArray: - """Root link position in simulation world frame. - Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + """Root link position in simulation world frame [m]. - This quantity is the position of the actor frame of the root rigid body relative to the world. + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). """ parent = self.root_link_pose_w if self._root_link_pos_w_ta is None: @@ -996,9 +1123,8 @@ def root_link_pos_w(self) -> ProxyArray: @property def root_link_quat_w(self) -> ProxyArray: """Root link orientation (x, y, z, w) in simulation world frame. - Shape is (num_instances,), dtype = wp.quatf. In torch this resolves to (num_instances, 4). - This quantity is the orientation of the actor frame of the root rigid body. + Shape is (num_instances,), dtype = wp.quatf. In torch this resolves to (num_instances, 4). """ parent = self.root_link_pose_w if self._root_link_quat_w_ta is None: @@ -1007,10 +1133,9 @@ def root_link_quat_w(self) -> ProxyArray: @property def root_link_lin_vel_w(self) -> ProxyArray: - """Root linear velocity in simulation world frame [m/s]. - Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + """Root link linear velocity in simulation world frame [m/s]. - This quantity is the linear velocity of the root rigid body's actor frame relative to the world. + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). """ parent = self.root_link_vel_w if self._root_link_lin_vel_w_ta is None: @@ -1020,9 +1145,8 @@ def root_link_lin_vel_w(self) -> ProxyArray: @property def root_link_ang_vel_w(self) -> ProxyArray: """Root link angular velocity in simulation world frame [rad/s]. - Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). - This quantity is the angular velocity of the actor frame of the root rigid body relative to the world. + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). """ parent = self.root_link_vel_w if self._root_link_ang_vel_w_ta is None: @@ -1031,10 +1155,9 @@ def root_link_ang_vel_w(self) -> ProxyArray: @property def root_com_pos_w(self) -> ProxyArray: - """Root center of mass position in simulation world frame. - Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). + """Root center of mass position in simulation world frame [m]. - This quantity is the position of the center of mass frame of the root rigid body relative to the world. + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). """ parent = self.root_com_pose_w if self._root_com_pos_w_ta is None: @@ -1044,9 +1167,8 @@ def root_com_pos_w(self) -> ProxyArray: @property def root_com_quat_w(self) -> ProxyArray: """Root center of mass orientation (x, y, z, w) in simulation world frame. - Shape is (num_instances,), dtype = wp.quatf. In torch this resolves to (num_instances, 4). - This quantity is the orientation of the principal axes of inertia of the root rigid body relative to the world. + Shape is (num_instances,), dtype = wp.quatf. In torch this resolves to (num_instances, 4). """ parent = self.root_com_pose_w if self._root_com_quat_w_ta is None: @@ -1056,9 +1178,8 @@ def root_com_quat_w(self) -> ProxyArray: @property def root_com_lin_vel_w(self) -> ProxyArray: """Root center of mass linear velocity in simulation world frame [m/s]. - Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). - This quantity is the linear velocity of the root rigid body's center of mass frame relative to the world. + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). """ parent = self.root_com_vel_w if self._root_com_lin_vel_w_ta is None: @@ -1068,9 +1189,8 @@ def root_com_lin_vel_w(self) -> ProxyArray: @property def root_com_ang_vel_w(self) -> ProxyArray: """Root center of mass angular velocity in simulation world frame [rad/s]. - Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). - This quantity is the angular velocity of the root rigid body's center of mass frame relative to the world. + Shape is (num_instances,), dtype = wp.vec3f. In torch this resolves to (num_instances, 3). """ parent = self.root_com_vel_w if self._root_com_ang_vel_w_ta is None: @@ -1079,11 +1199,10 @@ def root_com_ang_vel_w(self) -> ProxyArray: @property def body_link_pos_w(self) -> ProxyArray: - """Positions of all bodies in simulation world frame. - Shape is (num_instances, num_bodies), dtype = wp.vec3f. In torch this resolves to - (num_instances, num_bodies, 3). + """Positions of all bodies in simulation world frame [m]. - This quantity is the position of the articulation bodies' actor frame relative to the world. + Shape is (num_instances, num_bodies), dtype = wp.vec3f. + In torch this resolves to (num_instances, num_bodies, 3). """ parent = self.body_link_pose_w if self._body_link_pos_w_ta is None: @@ -1093,10 +1212,9 @@ def body_link_pos_w(self) -> ProxyArray: @property def body_link_quat_w(self) -> ProxyArray: """Orientation (x, y, z, w) of all bodies in simulation world frame. - Shape is (num_instances, num_bodies), dtype = wp.quatf. In torch this resolves to - (num_instances, num_bodies, 4). - This quantity is the orientation of the articulation bodies' actor frame relative to the world. + Shape is (num_instances, num_bodies), dtype = wp.quatf. + In torch this resolves to (num_instances, num_bodies, 4). """ parent = self.body_link_pose_w if self._body_link_quat_w_ta is None: @@ -1106,10 +1224,9 @@ def body_link_quat_w(self) -> ProxyArray: @property def body_link_lin_vel_w(self) -> ProxyArray: """Linear velocity of all bodies in simulation world frame [m/s]. - Shape is (num_instances, num_bodies), dtype = wp.vec3f. In torch this resolves to - (num_instances, num_bodies, 3). - This quantity is the linear velocity of the articulation bodies' actor frame relative to the world. + Shape is (num_instances, num_bodies), dtype = wp.vec3f. + In torch this resolves to (num_instances, num_bodies, 3). """ parent = self.body_link_vel_w if self._body_link_lin_vel_w_ta is None: @@ -1119,10 +1236,9 @@ def body_link_lin_vel_w(self) -> ProxyArray: @property def body_link_ang_vel_w(self) -> ProxyArray: """Angular velocity of all bodies in simulation world frame [rad/s]. - Shape is (num_instances, num_bodies), dtype = wp.vec3f. In torch this resolves to - (num_instances, num_bodies, 3). - This quantity is the angular velocity of the articulation bodies' actor frame relative to the world. + Shape is (num_instances, num_bodies), dtype = wp.vec3f. + In torch this resolves to (num_instances, num_bodies, 3). """ parent = self.body_link_vel_w if self._body_link_ang_vel_w_ta is None: @@ -1131,11 +1247,10 @@ def body_link_ang_vel_w(self) -> ProxyArray: @property def body_com_pos_w(self) -> ProxyArray: - """Positions of all bodies' center of mass in simulation world frame. - Shape is (num_instances, num_bodies), dtype = wp.vec3f. In torch this resolves to - (num_instances, num_bodies, 3). + """Positions of all bodies' center of mass in simulation world frame [m]. - This quantity is the position of the articulation bodies' center of mass frame. + Shape is (num_instances, num_bodies), dtype = wp.vec3f. + In torch this resolves to (num_instances, num_bodies, 3). """ parent = self.body_com_pose_w if self._body_com_pos_w_ta is None: @@ -1145,10 +1260,9 @@ def body_com_pos_w(self) -> ProxyArray: @property def body_com_quat_w(self) -> ProxyArray: """Orientation (x, y, z, w) of the principal axes of inertia of all bodies in simulation world frame. - Shape is (num_instances, num_bodies), dtype = wp.quatf. In torch this resolves to - (num_instances, num_bodies, 4). - This quantity is the orientation of the articulation bodies' principal axes of inertia. + Shape is (num_instances, num_bodies), dtype = wp.quatf. + In torch this resolves to (num_instances, num_bodies, 4). """ parent = self.body_com_pose_w if self._body_com_quat_w_ta is None: @@ -1158,10 +1272,9 @@ def body_com_quat_w(self) -> ProxyArray: @property def body_com_lin_vel_w(self) -> ProxyArray: """Linear velocity of all bodies in simulation world frame [m/s]. - Shape is (num_instances, num_bodies), dtype = wp.vec3f. In torch this resolves to - (num_instances, num_bodies, 3). - This quantity is the linear velocity of the articulation bodies' center of mass frame. + Shape is (num_instances, num_bodies), dtype = wp.vec3f. + In torch this resolves to (num_instances, num_bodies, 3). """ parent = self.body_com_vel_w if self._body_com_lin_vel_w_ta is None: @@ -1171,10 +1284,9 @@ def body_com_lin_vel_w(self) -> ProxyArray: @property def body_com_ang_vel_w(self) -> ProxyArray: """Angular velocity of all bodies in simulation world frame [rad/s]. - Shape is (num_instances, num_bodies), dtype = wp.vec3f. In torch this resolves to - (num_instances, num_bodies, 3). - This quantity is the angular velocity of the articulation bodies' center of mass frame. + Shape is (num_instances, num_bodies), dtype = wp.vec3f. + In torch this resolves to (num_instances, num_bodies, 3). """ parent = self.body_com_vel_w if self._body_com_ang_vel_w_ta is None: @@ -1184,10 +1296,9 @@ def body_com_ang_vel_w(self) -> ProxyArray: @property def body_com_lin_acc_w(self) -> ProxyArray: """Linear acceleration of all bodies in simulation world frame [m/s^2]. - Shape is (num_instances, num_bodies), dtype = wp.vec3f. In torch this resolves to - (num_instances, num_bodies, 3). - This quantity is the linear acceleration of the articulation bodies' center of mass frame. + Shape is (num_instances, num_bodies), dtype = wp.vec3f. + In torch this resolves to (num_instances, num_bodies, 3). """ parent = self.body_com_acc_w if self._body_com_lin_acc_w_ta is None: @@ -1197,10 +1308,9 @@ def body_com_lin_acc_w(self) -> ProxyArray: @property def body_com_ang_acc_w(self) -> ProxyArray: """Angular acceleration of all bodies in simulation world frame [rad/s^2]. - Shape is (num_instances, num_bodies), dtype = wp.vec3f. In torch this resolves to - (num_instances, num_bodies, 3). - This quantity is the angular acceleration of the articulation bodies' center of mass frame. + Shape is (num_instances, num_bodies), dtype = wp.vec3f. + In torch this resolves to (num_instances, num_bodies, 3). """ parent = self.body_com_acc_w if self._body_com_ang_acc_w_ta is None: @@ -1209,11 +1319,10 @@ def body_com_ang_acc_w(self) -> ProxyArray: @property def body_com_pos_b(self) -> ProxyArray: - """Center of mass position of all of the bodies in their respective link frames. - Shape is (num_instances, num_bodies), dtype = wp.vec3f. In torch this resolves to - (num_instances, num_bodies, 3). + """Center of mass position of all of the bodies in their respective link frames [m]. - This quantity is the center of mass location relative to its body's link frame. + Shape is (num_instances, num_bodies), dtype = wp.vec3f. + In torch this resolves to (num_instances, num_bodies, 3). """ parent = self.body_com_pose_b if self._body_com_pos_b_ta is None: @@ -1224,10 +1333,9 @@ def body_com_pos_b(self) -> ProxyArray: def body_com_quat_b(self) -> ProxyArray: """Orientation (x, y, z, w) of the principal axes of inertia of all of the bodies in their respective link frames. - Shape is (num_instances, num_bodies), dtype = wp.quatf. In torch this resolves to - (num_instances, num_bodies, 4). - This quantity is the orientation of the principal axes of inertia relative to its body's link frame. + Shape is (num_instances, num_bodies), dtype = wp.quatf. + In torch this resolves to (num_instances, num_bodies, 4). """ parent = self.body_com_pose_b if self._body_com_quat_b_ta is None: @@ -1235,95 +1343,12 @@ def body_com_quat_b(self) -> ProxyArray: return self._body_com_quat_b_ta """ - Deprecated in base class (required by ABC for backward compatibility). - """ - - @property - def default_root_state(self) -> ProxyArray: - """Deprecated. Use :attr:`default_root_pose` and :attr:`default_root_vel` instead.""" - warnings.warn( - "default_root_state is deprecated. Use default_root_pose and default_root_vel.", - DeprecationWarning, - stacklevel=2, - ) - if self._root_state_w_buf is None: - self._root_state_w_buf = wp.zeros( - self._num_instances, dtype=wp.types.vector(13, wp.float32), device=self.device - ) - if self._default_root_state_ta is None: - self._default_root_state_ta = ProxyArray(self._root_state_w_buf) - return self._default_root_state_ta - - @property - def root_state_w(self) -> ProxyArray: - """Deprecated. Use :attr:`root_link_pose_w` and :attr:`root_com_vel_w` instead.""" - warnings.warn( - "root_state_w is deprecated. Use root_link_pose_w and root_com_vel_w.", - DeprecationWarning, - stacklevel=2, - ) - return self.root_link_pose_w - - @property - def root_link_state_w(self) -> ProxyArray: - """Deprecated. Use :attr:`root_link_pose_w` and :attr:`root_link_vel_w` instead.""" - warnings.warn( - "root_link_state_w is deprecated. Use root_link_pose_w and root_link_vel_w.", - DeprecationWarning, - stacklevel=2, - ) - return self.root_link_pose_w - - @property - def root_com_state_w(self) -> ProxyArray: - """Deprecated. Use :attr:`root_com_pose_w` and :attr:`root_com_vel_w` instead.""" - warnings.warn( - "root_com_state_w is deprecated. Use root_com_pose_w and root_com_vel_w.", - DeprecationWarning, - stacklevel=2, - ) - return self.root_com_pose_w - - @property - def body_state_w(self) -> ProxyArray: - """Deprecated. Use :attr:`body_link_pose_w` and :attr:`body_com_vel_w` instead.""" - warnings.warn( - "body_state_w is deprecated. Use body_link_pose_w and body_com_vel_w.", - DeprecationWarning, - stacklevel=2, - ) - return self.body_link_pose_w - - @property - def body_link_state_w(self) -> ProxyArray: - """Deprecated. Use :attr:`body_link_pose_w` and :attr:`body_link_vel_w` instead.""" - warnings.warn( - "body_link_state_w is deprecated. Use body_link_pose_w and body_link_vel_w.", - DeprecationWarning, - stacklevel=2, - ) - return self.body_link_pose_w - - @property - def body_com_state_w(self) -> ProxyArray: - """Deprecated. Use :attr:`body_com_pose_w` and :attr:`body_com_vel_w` instead.""" - warnings.warn( - "body_com_state_w is deprecated. Use body_com_pose_w and body_com_vel_w.", - DeprecationWarning, - stacklevel=2, - ) - return self.body_com_pose_w - - """ - Internal helper. + Internal helpers. """ def _create_buffers(self) -> None: # noqa: C901 + """Eagerly allocate every TimestampedBuffer and pinned CPU staging buffer.""" super()._create_buffers() - # Scratch buffers for _read_binding_into_* methods, allocated lazily - # on first use and reused every subsequent step to avoid per-step - # allocation overhead on the hot RL path. - self._read_scratch: dict = {} N = self._num_instances D = self._num_joints @@ -1343,26 +1368,33 @@ def _create_buffers(self) -> None: # noqa: C901 self._body_com_pose_w = TimestampedBuffer((N, L), dev, wp.transformf) self._body_com_vel_w = TimestampedBuffer((N, L), dev, wp.spatial_vectorf) self._body_com_acc_w = TimestampedBuffer((N, L), dev, wp.spatial_vectorf) + self._body_incoming_joint_wrench_buf = TimestampedBuffer((N, L), dev, wp.spatial_vectorf) # -- Joint state buffers self._joint_pos_buf = TimestampedBuffer((N, D), dev, wp.float32) self._joint_vel_buf = TimestampedBuffer((N, D), dev, wp.float32) self._joint_acc = TimestampedBuffer((N, D), dev, wp.float32) self._previous_joint_vel = wp.zeros((N, D), dtype=wp.float32, device=dev) - # -- Joint properties - self._joint_stiffness = wp.zeros((N, D), dtype=wp.float32, device=dev) - self._joint_damping = wp.zeros((N, D), dtype=wp.float32, device=dev) - self._joint_armature = wp.zeros((N, D), dtype=wp.float32, device=dev) - self._joint_friction_coeff = wp.zeros((N, D), dtype=wp.float32, device=dev) - self._joint_pos_limits = wp.zeros((N, D), dtype=wp.vec2f, device=dev) - self._joint_vel_limits = wp.zeros((N, D), dtype=wp.float32, device=dev) - self._joint_effort_limits = wp.zeros((N, D), dtype=wp.float32, device=dev) - - # -- Body properties - self._body_mass = wp.zeros((N, L), dtype=wp.float32, device=dev) - self._body_inertia = wp.zeros((N, L, 9), dtype=wp.float32, device=dev) - - # -- Soft limits / custom properties + # -- Joint properties (CPU-only; timestamped so they can be re-read after writes) + self._joint_stiffness = TimestampedBuffer((N, D), dev, wp.float32) + self._joint_damping = TimestampedBuffer((N, D), dev, wp.float32) + self._joint_armature = TimestampedBuffer((N, D), dev, wp.float32) + self._joint_pos_limits = TimestampedBuffer((N, D), dev, wp.vec2f) + self._joint_vel_limits = TimestampedBuffer((N, D), dev, wp.float32) + self._joint_effort_limits = TimestampedBuffer((N, D), dev, wp.float32) + # Friction: single (N, D, 3) TimestampedBuffer; per-component views are created lazily. + self._joint_friction_props_buf = TimestampedBuffer((N, D, 3), dev, wp.float32) + # These are strided wp.array views into _joint_friction_props_buf.data; created in + # _pin_proxy_arrays after the buffer exists. + self._joint_friction_coeff: wp.array | None = None + self._joint_dynamic_friction_coeff: wp.array | None = None + self._joint_viscous_friction_coeff: wp.array | None = None + + # -- Body properties (CPU-only; read once at init, re-read via _read_scalar_binding) + self._body_mass = TimestampedBuffer((N, L), dev, wp.float32) + self._body_inertia = TimestampedBuffer((N, L, 9), dev, wp.float32) + + # -- Soft limits / custom joint properties self._soft_joint_pos_limits = wp.zeros((N, D), dtype=wp.vec2f, device=dev) self._soft_joint_vel_limits = wp.zeros((N, D), dtype=wp.float32, device=dev) self._gear_ratio = wp.ones((N, D), dtype=wp.float32, device=dev) @@ -1388,48 +1420,180 @@ def _create_buffers(self) -> None: # noqa: C901 self._root_com_lin_vel_b = TimestampedBuffer(N, dev, wp.vec3f) self._root_com_ang_vel_b = TimestampedBuffer(N, dev, wp.vec3f) - # -- Deprecated combined state buffers - self._root_state_w_buf = None - self._root_link_state_w_buf = None - self._root_com_state_w_buf = None - self._body_state_w_buf = None - self._body_link_state_w_buf = None - self._body_com_state_w_buf = None - - # -- Tendon property buffers - T_fix = getattr(self, "_num_fixed_tendons", 0) - T_spa = getattr(self, "_num_spatial_tendons", 0) + # -- Deprecated combined state buffers (TimestampedBuffer; lazily filled on first access) + self._root_state_w_buf = TimestampedBuffer(N, dev, vec13f) + self._root_link_state_w_buf = TimestampedBuffer(N, dev, vec13f) + self._root_com_state_w_buf = TimestampedBuffer(N, dev, vec13f) + self._default_root_state_buf = wp.zeros(N, dtype=vec13f, device=dev) + # -- Deprecated body combined state buffers (TimestampedBuffer; lazily filled on first access) + self._body_state_w_buf = TimestampedBuffer((N, L), dev, vec13f) + self._body_link_state_w_buf = TimestampedBuffer((N, L), dev, vec13f) + self._body_com_state_w_buf = TimestampedBuffer((N, L), dev, vec13f) + + # -- Tendon property buffers (always allocated; empty shape when T==0 so + # properties never return None). Routed through _read_scalar_binding. + T_fix = self._num_fixed_tendons + T_spa = self._num_spatial_tendons + self._fixed_tendon_stiffness = TimestampedBuffer((N, T_fix), dev, wp.float32) + self._fixed_tendon_damping = TimestampedBuffer((N, T_fix), dev, wp.float32) + self._fixed_tendon_limit_stiffness = TimestampedBuffer((N, T_fix), dev, wp.float32) + self._fixed_tendon_rest_length = TimestampedBuffer((N, T_fix), dev, wp.float32) + self._fixed_tendon_offset = TimestampedBuffer((N, T_fix), dev, wp.float32) + # Legacy alias kept for any internal callers that used the old vec2f buffer. + self._fixed_tendon_pos_limits = TimestampedBuffer((N, T_fix), dev, wp.vec2f) + self._spatial_tendon_stiffness = TimestampedBuffer((N, T_spa), dev, wp.float32) + self._spatial_tendon_damping = TimestampedBuffer((N, T_spa), dev, wp.float32) + self._spatial_tendon_limit_stiffness = TimestampedBuffer((N, T_spa), dev, wp.float32) + self._spatial_tendon_offset = TimestampedBuffer((N, T_spa), dev, wp.float32) + + # -- CPU staging buffers for CPU-only bindings. + # Pre-allocate all of them so there is no per-step allocation on the hot path. + # These are keyed by tensor_type in self._cpu_staging_buffers; _binding_read + # selects the right one at read time. The sizes must match the binding shapes + # (flat float32). On a GPU sim the buffers are pinned-host (page-locked) so + # the wheel can dispatch async copies; on a CPU sim the staging copy is + # functionally redundant but the buffer must still exist for the write + # helpers, so we allocate unpinned and pay only the intra-CPU memcpy. + pinned = dev != "cpu" + self._cpu_body_mass = wp.zeros((N, L), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_body_coms = wp.zeros((N, L, 7), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_body_inertia = wp.zeros((N, L, 9), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_joint_stiffness = wp.zeros((N, D), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_joint_damping = wp.zeros((N, D), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_joint_position_limit = wp.zeros((N, D, 2), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_joint_velocity_limit = wp.zeros((N, D), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_joint_effort_limit = wp.zeros((N, D), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_joint_armature = wp.zeros((N, D), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_joint_friction_coeff = wp.zeros((N, D), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_joint_dynamic_friction_coeff = wp.zeros((N, D), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_joint_viscous_friction_coeff = wp.zeros((N, D), dtype=wp.float32, device="cpu", pinned=pinned) if T_fix > 0: - self._fixed_tendon_stiffness = wp.zeros((N, T_fix), dtype=wp.float32, device=dev) - self._fixed_tendon_damping = wp.zeros((N, T_fix), dtype=wp.float32, device=dev) - self._fixed_tendon_limit_stiffness = wp.zeros((N, T_fix), dtype=wp.float32, device=dev) - self._fixed_tendon_rest_length = wp.zeros((N, T_fix), dtype=wp.float32, device=dev) - self._fixed_tendon_offset = wp.zeros((N, T_fix), dtype=wp.float32, device=dev) - self._fixed_tendon_pos_limits = wp.zeros((N, T_fix), dtype=wp.vec2f, device=dev) - else: - self._fixed_tendon_stiffness = None - self._fixed_tendon_damping = None - self._fixed_tendon_limit_stiffness = None - self._fixed_tendon_rest_length = None - self._fixed_tendon_offset = None - self._fixed_tendon_pos_limits = None + self._cpu_fixed_tendon_stiffness = wp.zeros((N, T_fix), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_fixed_tendon_damping = wp.zeros((N, T_fix), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_fixed_tendon_limit_stiffness = wp.zeros((N, T_fix), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_fixed_tendon_rest_length = wp.zeros((N, T_fix), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_fixed_tendon_offset = wp.zeros((N, T_fix), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_fixed_tendon_pos_limits = wp.zeros((N, T_fix, 2), dtype=wp.float32, device="cpu", pinned=pinned) if T_spa > 0: - self._spatial_tendon_stiffness = wp.zeros((N, T_spa), dtype=wp.float32, device=dev) - self._spatial_tendon_damping = wp.zeros((N, T_spa), dtype=wp.float32, device=dev) - self._spatial_tendon_limit_stiffness = wp.zeros((N, T_spa), dtype=wp.float32, device=dev) - self._spatial_tendon_offset = wp.zeros((N, T_spa), dtype=wp.float32, device=dev) - else: - self._spatial_tendon_stiffness = None - self._spatial_tendon_damping = None - self._spatial_tendon_limit_stiffness = None - self._spatial_tendon_offset = None + self._cpu_spatial_tendon_stiffness = wp.zeros((N, T_spa), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_spatial_tendon_damping = wp.zeros((N, T_spa), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_spatial_tendon_limit_stiffness = wp.zeros( + (N, T_spa), dtype=wp.float32, device="cpu", pinned=pinned + ) + self._cpu_spatial_tendon_offset = wp.zeros((N, T_spa), dtype=wp.float32, device="cpu", pinned=pinned) - # Read initial joint properties from bindings + # Read initial joint/body properties from bindings (one-time CPU reads). self._read_initial_properties() - - # Initialize ProxyArray wrappers (lazily created on first access) + # Initialize ProxyArray wrappers (lazily created on first property access). self._pin_proxy_arrays() + def _binding_read(self, tensor_type: int, binding: Any, dst: wp.array) -> None: + """Read *binding* into *dst*, staging through a pinned-host buffer for CPU-only bindings. + + For GPU-resident state bindings (pose, velocity, etc.) the read goes directly + into the destination array. For CPU-only property bindings (mass, COM, limits, + stiffness, …) the wheel writes into a pinned-host staging buffer first, then + :func:`wp.copy` moves the data to the simulation device asynchronously. + + Args: + tensor_type: TensorType key identifying the binding. + binding: OVPhysX TensorBinding whose ``read`` method is called. + dst: Destination :class:`wp.array` on the simulation device. + """ + if tensor_type not in TT._CPU_ONLY_TYPES or self.device == "cpu": + binding.read(dst) + return + # Route through a lazily-allocated pinned-host staging buffer. + staging = self._cpu_staging_buffers.get(tensor_type) + if staging is None: + staging = wp.zeros(binding.shape, dtype=wp.float32, device="cpu", pinned=True) + self._cpu_staging_buffers[tensor_type] = staging + binding.read(staging) + # Build a flat float32 view of dst matching the binding's flat shape. + if dst.dtype == wp.float32: + view = dst + else: + view = wp.array( + ptr=dst.ptr, + shape=binding.shape, + dtype=wp.float32, + device=str(dst.device), + copy=False, + ) + wp.copy(view, staging) + + def _binding_write( + self, + tensor_type: int, + binding: Any, + src: wp.array, + *, + indices: wp.array | None = None, + mask: wp.array | None = None, + ) -> None: + """Write *src* to *binding*, staging through pinned-host buffers for CPU-only bindings. + + Args: + tensor_type: TensorType key identifying the binding. + binding: OVPhysX TensorBinding whose ``write`` method is called. + src: Source :class:`wp.array` on the simulation device. + indices: Optional environment indices for partial writes. + mask: Optional boolean mask for partial writes. + """ + if tensor_type not in TT._CPU_ONLY_TYPES or self.device == "cpu": + binding.write(src, indices=indices, mask=mask) + return + # Stage through a pinned-host buffer. + staging = self._cpu_staging_buffers.get(tensor_type) + if staging is None: + staging = wp.zeros(binding.shape, dtype=wp.float32, device="cpu", pinned=True) + self._cpu_staging_buffers[tensor_type] = staging + if src.dtype == wp.float32: + src_view = src + else: + src_view = wp.array( + ptr=src.ptr, + shape=binding.shape, + dtype=wp.float32, + device=str(src.device), + copy=False, + ) + wp.copy(staging, src_view) + binding.write(staging, indices=indices, mask=mask) + + def _stage_to_pinned_cpu(self, tensor_type: int, role: str, src: wp.array) -> wp.array: + """Copy *src* into a lazily-allocated pinned-host :class:`wp.array`. + + Keyed on *(tensor_type, role)* so the same pair always reuses the same + buffer, avoiding per-call allocation on the hot path. + + Args: + tensor_type: TensorType identifying the binding. + role: Disambiguating string when the same tensor_type may serve + multiple purposes (e.g. ``"read"`` vs ``"write"``). + src: Source array on the simulation device. + + Returns: + Pinned-host wp.array containing a copy of *src*. + """ + key = (tensor_type, role) + staging = self._cpu_staging_buffers.get(key) # type: ignore[call-overload] + if staging is None: + if src.dtype == wp.float32: + shape = src.shape + else: + # Flatten to float32 shape matching the element byte size. + elem_floats = src.dtype.size // 4 + shape = src.shape + (elem_floats,) + staging = wp.zeros(shape, dtype=wp.float32, device="cpu", pinned=True) + self._cpu_staging_buffers[key] = staging # type: ignore[index] + if src.dtype == wp.float32: + wp.copy(staging, src) + else: + flat_src = wp.array(ptr=src.ptr, shape=staging.shape, dtype=wp.float32, device=str(src.device), copy=False) + wp.copy(staging, flat_src) + return staging + def _read_initial_properties(self) -> None: """Read static/initial joint and body properties from ovphysx bindings. @@ -1439,7 +1603,6 @@ def _read_initial_properties(self) -> None: simulation device. """ - # Property reads always use CPU numpy (property tensors are host-side). def _read_cpu(tensor_type): binding = self._get_binding(tensor_type) if binding is None: @@ -1448,75 +1611,97 @@ def _read_cpu(tensor_type): binding.read(np_buf) return np_buf - for tt, dst in [ + # Joint scalar properties — write to .data since buffers are now TimestampedBuffer. + for tt, buf in [ (TT.DOF_STIFFNESS, self._joint_stiffness), (TT.DOF_DAMPING, self._joint_damping), (TT.DOF_ARMATURE, self._joint_armature), (TT.DOF_MAX_VELOCITY, self._joint_vel_limits), (TT.DOF_MAX_FORCE, self._joint_effort_limits), - (TT.BODY_MASS, self._body_mass), ]: np_buf = _read_cpu(tt) if np_buf is not None: - wp.copy(dst, wp.from_numpy(np_buf, dtype=wp.float32, device=self.device)) + wp.copy(buf.data, wp.from_numpy(np_buf, dtype=wp.float32, device=self.device)) + buf.timestamp = self._sim_timestamp - # Joint position limits: [N, D, 2] -> (N, D) wp.vec2f + # Body mass (now a TimestampedBuffer). + np_buf = _read_cpu(TT.BODY_MASS) + if np_buf is not None: + wp.copy(self._body_mass.data, wp.from_numpy(np_buf, dtype=wp.float32, device=self.device)) + self._body_mass.timestamp = self._sim_timestamp + + # Joint position limits: [N, D, 2] -> (N, D) wp.vec2f stored in TimestampedBuffer.data np_lim = _read_cpu(TT.DOF_LIMIT) if np_lim is not None: - self._joint_pos_limits = wp.from_numpy( + src = wp.from_numpy( np_lim.reshape(self._num_instances, self._num_joints, 2), dtype=wp.vec2f, device=self.device ) + wp.copy(self._joint_pos_limits.data, src) + self._joint_pos_limits.timestamp = self._sim_timestamp - # Body inertia: [N, L, 9] + # Body inertia (now a TimestampedBuffer): [N, L, 9] np_iner = _read_cpu(TT.BODY_INERTIA) if np_iner is not None: - self._body_inertia = wp.from_numpy(np_iner, dtype=wp.float32, device=self.device) + wp.copy( + self._body_inertia.data, + wp.from_numpy(np_iner, dtype=wp.float32, device=self.device), + ) + self._body_inertia.timestamp = self._sim_timestamp - # Friction: [N, D, 3] -> extract static friction (column 0) + # Friction: [N, D, 3] -> load directly into the combined TimestampedBuffer. + # The strided per-component views (_joint_friction_coeff/dynamic/viscous) are + # created later in _pin_proxy_arrays, so we write to the combined buffer here. np_fric = _read_cpu(TT.DOF_FRICTION_PROPERTIES) if np_fric is not None: - self._joint_friction_coeff = wp.from_numpy(np_fric[..., 0].copy(), dtype=wp.float32, device=self.device) + fric_contiguous = np.ascontiguousarray(np_fric.reshape(self._num_instances, self._num_joints, 3)) + wp.copy( + self._joint_friction_props_buf.data, + wp.from_numpy(fric_contiguous, dtype=wp.float32, device=self.device), + ) + self._joint_friction_props_buf.timestamp = self._sim_timestamp - # Fixed tendon properties (CPU-side, read once) - T_fix = getattr(self, "_num_fixed_tendons", 0) + # Fixed tendon properties. PhysX exposes tendons on the simulation + # device (no ``device="cpu"`` clone in its ``set_fixed_tendon_properties`` + # call); the OVPhysX wheel mirrors that, so we read directly into the + # sim-device buffer rather than via a numpy round-trip. + T_fix = self._num_fixed_tendons if T_fix > 0: - for tt, dst in [ + for tt, buf in [ (TT.FIXED_TENDON_STIFFNESS, self._fixed_tendon_stiffness), (TT.FIXED_TENDON_DAMPING, self._fixed_tendon_damping), (TT.FIXED_TENDON_LIMIT_STIFFNESS, self._fixed_tendon_limit_stiffness), (TT.FIXED_TENDON_REST_LENGTH, self._fixed_tendon_rest_length), (TT.FIXED_TENDON_OFFSET, self._fixed_tendon_offset), ]: - np_buf = _read_cpu(tt) - if np_buf is not None and dst is not None: - wp.copy(dst, wp.from_numpy(np_buf, dtype=wp.float32, device=self.device)) - # Fixed tendon limits: [N, T, 2] -> (N, T) wp.vec2f - np_tlim = _read_cpu(TT.FIXED_TENDON_LIMIT) - if np_tlim is not None and self._fixed_tendon_pos_limits is not None: - self._fixed_tendon_pos_limits = wp.from_numpy( - np_tlim.reshape(self._num_instances, T_fix, 2), dtype=wp.vec2f, device=self.device - ) - - # Spatial tendon properties (CPU-side, read once) - T_spa = getattr(self, "_num_spatial_tendons", 0) + binding = self._get_binding(tt) + if binding is not None: + self._binding_read(tt, binding, buf.data) + buf.timestamp = self._sim_timestamp + binding = self._get_binding(TT.FIXED_TENDON_LIMIT) + if binding is not None: + self._binding_read(TT.FIXED_TENDON_LIMIT, binding, self._fixed_tendon_pos_limits.data) + self._fixed_tendon_pos_limits.timestamp = self._sim_timestamp + + # Spatial tendon properties (sim-device, see fixed-tendon comment above). + T_spa = self._num_spatial_tendons if T_spa > 0: - for tt, dst in [ + for tt, buf in [ (TT.SPATIAL_TENDON_STIFFNESS, self._spatial_tendon_stiffness), (TT.SPATIAL_TENDON_DAMPING, self._spatial_tendon_damping), (TT.SPATIAL_TENDON_LIMIT_STIFFNESS, self._spatial_tendon_limit_stiffness), (TT.SPATIAL_TENDON_OFFSET, self._spatial_tendon_offset), ]: - np_buf = _read_cpu(tt) - if np_buf is not None and dst is not None: - wp.copy(dst, wp.from_numpy(np_buf, dtype=wp.float32, device=self.device)) + binding = self._get_binding(tt) + if binding is not None: + self._binding_read(tt, binding, buf.data) + buf.timestamp = self._sim_timestamp def _pin_proxy_arrays(self) -> None: """Create pinned ProxyArray wrappers for all data buffers. - This is called once from :meth:`_create_buffers` during initialization. + Called once from :meth:`_create_buffers` during initialization. All ``_ta`` fields are lazily populated on first property access. """ - # -- Pinned ProxyArray cache (one per read property, lazily created on first access) # Defaults self._default_root_pose_ta: ProxyArray | None = None self._default_root_vel_ta: ProxyArray | None = None @@ -1534,6 +1719,8 @@ def _pin_proxy_arrays(self) -> None: self._joint_damping_ta: ProxyArray | None = None self._joint_armature_ta: ProxyArray | None = None self._joint_friction_coeff_ta: ProxyArray | None = None + self._joint_dynamic_friction_coeff_ta: ProxyArray | None = None + self._joint_viscous_friction_coeff_ta: ProxyArray | None = None self._joint_pos_limits_ta: ProxyArray | None = None self._joint_vel_limits_ta: ProxyArray | None = None self._joint_effort_limits_ta: ProxyArray | None = None @@ -1562,8 +1749,10 @@ def _pin_proxy_arrays(self) -> None: self._body_link_pose_w_ta: ProxyArray | None = None self._body_link_vel_w_ta: ProxyArray | None = None self._body_com_pose_w_ta: ProxyArray | None = None + self._body_com_vel_w_ta: ProxyArray | None = None self._body_com_acc_w_ta: ProxyArray | None = None self._body_com_pose_b_ta: ProxyArray | None = None + self._body_incoming_joint_wrench_b_ta: ProxyArray | None = None # Body properties self._body_mass_ta: ProxyArray | None = None self._body_inertia_ta: ProxyArray | None = None @@ -1605,42 +1794,76 @@ def _pin_proxy_arrays(self) -> None: self._body_com_quat_b_ta: ProxyArray | None = None # Deprecated state-concat properties self._default_root_state_ta: ProxyArray | None = None + self._root_state_w_ta: ProxyArray | None = None + self._root_link_state_w_ta: ProxyArray | None = None + self._root_com_state_w_ta: ProxyArray | None = None + # Deprecated body state-concat properties + self._body_state_w_ta: ProxyArray | None = None + self._body_link_state_w_ta: ProxyArray | None = None + self._body_com_state_w_ta: ProxyArray | None = None + + # Create strided wp.array views into _joint_friction_props_buf.data so that + # each friction component is accessible without copying data. The combined + # buffer has shape (N, D, 3) and contiguous float32 storage, so component k + # lives at byte offset k*4 with strides (D*3*4, 3*4). + N = self._num_instances + D = self._num_joints + _fp = self._joint_friction_props_buf.data + _float_bytes = 4 # sizeof(float32) + _stride_row = D * 3 * _float_bytes # bytes between rows + _stride_col = 3 * _float_bytes # bytes between columns (elements) + _dev = str(_fp.device) + self._joint_friction_coeff = wp.array( + ptr=_fp.ptr, + shape=(N, D), + strides=(_stride_row, _stride_col), + dtype=wp.float32, + device=_dev, + copy=False, + ) + self._joint_dynamic_friction_coeff = wp.array( + ptr=_fp.ptr + _float_bytes, + shape=(N, D), + strides=(_stride_row, _stride_col), + dtype=wp.float32, + device=_dev, + copy=False, + ) + self._joint_viscous_friction_coeff = wp.array( + ptr=_fp.ptr + 2 * _float_bytes, + shape=(N, D), + strides=(_stride_row, _stride_col), + dtype=wp.float32, + device=_dev, + copy=False, + ) - """ - Internal helpers -- Bindings. - """ + def _invalidate_initialize_callback(self, event) -> None: + """Invalidate cached buffers when the simulation is reinitialized. + + Args: + event: Simulation event (unused). + """ + self._is_primed = False + self._sim_timestamp = 0.0 + # Reset every TimestampedBuffer timestamp so the next property access + # triggers a fresh pull from the binding. + for attr_name in dir(self): + if attr_name.startswith("_") and not attr_name.startswith("__"): + val = getattr(self, attr_name, None) + if isinstance(val, TimestampedBuffer): + val.timestamp = -1.0 def _get_binding(self, tensor_type: int): - """Return a binding, lazily creating it if a binding_getter was provided.""" - b = self._bindings.get(tensor_type) - if b is not None: - return b - if self._binding_getter is not None: - b = self._binding_getter(tensor_type) - if b is not None: - self._bindings[tensor_type] = b - return b - return None - - def _get_read_scratch(self, tensor_type: int) -> wp.array | None: - """Return a pre-allocated flat float32 scratch buffer for a binding. - - Allocated once on first use, then reused every step. CPU-only - bindings (body properties, DOF properties) get CPU scratch; GPU - bindings get GPU scratch. wp.copy handles cross-device transfer - when the destination buffer lives on a different device. - """ - if tensor_type in self._read_scratch: - return self._read_scratch[tensor_type] - binding = self._get_binding(tensor_type) - if binding is None: - return None - from isaaclab_ovphysx.tensor_types import _CPU_ONLY_TYPES + """Return the cached binding for :paramref:`tensor_type`, or ``None`` if absent. - dev = "cpu" if tensor_type in _CPU_ONLY_TYPES else self.device - buf = wp.zeros(binding.shape, dtype=wp.float32, device=dev) - self._read_scratch[tensor_type] = buf - return buf + Args: + tensor_type: TensorType key. + + Returns: + The TensorBinding, or ``None`` if not present in the binding dict. + """ + return self._bindings.get(tensor_type) def _get_read_view(self, tensor_type: int, wp_array: wp.array, floats_per_elem: int = 0) -> wp.array | None: """Return a stable float32 view of a warp buffer for reading from a binding. @@ -1651,6 +1874,16 @@ def _get_read_view(self, tensor_type: int, wp_array: wp.array, floats_per_elem: The returned view is cached so that ``binding.read(view)`` sees the same object on every call, enabling the binding's internal read cache. + + Args: + tensor_type: TensorType key. + wp_array: Destination warp array. + floats_per_elem: Number of float32 elements per logical element + (e.g. 7 for transformf, 6 for spatial_vectorf). Pass 0 to + return the array as-is. + + Returns: + Float32 view suitable for ``binding.read()``, or ``None``. """ if not hasattr(self, "_read_view_cache"): self._read_view_cache = {} @@ -1678,65 +1911,89 @@ def _get_read_view(self, tensor_type: int, wp_array: wp.array, floats_per_elem: self._read_view_cache[cache_key] = view return view - def _read_binding_into_flat(self, tensor_type: int, wp_array: wp.array) -> None: - """Read a flat binding (no structured dtype) into an existing warp array. + def _read_binding_into_buf(self, tensor_type: int, buf: TimestampedBuffer) -> None: + """Read from an ovphysx binding into a :class:`TimestampedBuffer`, skipping if fresh. - Reads directly into the target array -- no scratch buffer, no extra copy. + Args: + tensor_type: TensorType key. + buf: Timestamped buffer to refresh. """ - self._read_binding_into_view(tensor_type, wp_array) - - def _read_binding_into_view(self, tensor_type: int, view: wp.array) -> None: - """Read an ovphysx binding into a float32 warp view.""" - binding = self._get_binding(tensor_type) - if binding is None: - return - - from isaaclab_ovphysx.tensor_types import _CPU_ONLY_TYPES - - if tensor_type in _CPU_ONLY_TYPES and str(view.device) != "cpu": - scratch = self._get_read_scratch(tensor_type) - if scratch is None: - return - binding.read(scratch) - wp.copy(view, scratch) - else: - binding.read(view) - - def _read_binding_into_buf(self, tensor_type: int, buf: TimestampedBuffer) -> None: - """Read from an ovphysx binding into a TimestampedBuffer, skipping if fresh.""" if buf.timestamp >= self._sim_timestamp: return view = self._get_read_view(tensor_type, buf.data) if view is None: return - self._read_binding_into_view(tensor_type, view) + self._get_binding(tensor_type).read(view) buf.timestamp = self._sim_timestamp def _read_transform_binding(self, tensor_type: int, buf: TimestampedBuffer) -> None: - """Read a pose binding (float32 view of transformf buffer), skipping if fresh.""" + """Read a pose binding (float32 view of transformf buffer), skipping if fresh. + + CPU-only bindings (e.g. ``BODY_COM_POSE``) are routed through a + pinned-host staging buffer via :meth:`_binding_read` so the wheel's + device-match requirement is satisfied even on a GPU sim. + + Args: + tensor_type: TensorType key. + buf: Timestamped :class:`wp.transformf` buffer to refresh. + """ if buf.timestamp >= self._sim_timestamp: return + binding = self._get_binding(tensor_type) + if binding is None: + return view = self._get_read_view(tensor_type, buf.data, 7) if view is None: return - self._read_binding_into_view(tensor_type, view) + self._binding_read(tensor_type, binding, view) buf.timestamp = self._sim_timestamp def _read_spatial_vector_binding(self, tensor_type: int, buf: TimestampedBuffer) -> None: - """Read a velocity binding (float32 view of spatial_vectorf buffer), skipping if fresh.""" + """Read a velocity binding (float32 view of spatial_vectorf buffer), skipping if fresh. + + Args: + tensor_type: TensorType key. + buf: Timestamped :class:`wp.spatial_vectorf` buffer to refresh. + """ if buf.timestamp >= self._sim_timestamp: return view = self._get_read_view(tensor_type, buf.data, 6) if view is None: return - self._read_binding_into_view(tensor_type, view) + self._get_binding(tensor_type).read(view) buf.timestamp = self._sim_timestamp - """ - Internal helpers -- Extraction. - """ + def _read_scalar_binding(self, tensor_type: int, buf: TimestampedBuffer) -> None: + """Refresh a scalar or flat float32 buffer from the matching binding if stale. + + Identical timestamp-gating contract as :meth:`_read_transform_binding` + but without a structured-dtype reinterpret cast. CPU-only bindings + (e.g. ``DOF_STIFFNESS``, ``DOF_LIMIT``) are routed through a + pre-allocated pinned-host staging buffer via :meth:`_binding_read` so + the wheel's device-match requirement is satisfied even on a GPU sim. + + Args: + tensor_type: TensorType key identifying the binding. + buf: Timestamped buffer whose :attr:`~TimestampedBuffer.data` field + will be refreshed. + """ + if buf.timestamp >= self._sim_timestamp: + return + binding = self._get_binding(tensor_type) + if binding is None: + return + self._binding_read(tensor_type, binding, buf.data) + buf.timestamp = self._sim_timestamp def _get_pos_from_transform(self, transform: wp.array) -> wp.array: + """Return a position view aliased into a transform array. + + Args: + transform: Source transform array. + + Returns: + vec3f view into the position component. + """ return wp.array( ptr=transform.ptr, shape=transform.shape, @@ -1746,6 +2003,14 @@ def _get_pos_from_transform(self, transform: wp.array) -> wp.array: ) def _get_quat_from_transform(self, transform: wp.array) -> wp.array: + """Return a quaternion view aliased into a transform array. + + Args: + transform: Source transform array. + + Returns: + quatf view into the quaternion component (offset 3 floats = 12 bytes). + """ return wp.array( ptr=transform.ptr + 3 * 4, shape=transform.shape, @@ -1755,6 +2020,14 @@ def _get_quat_from_transform(self, transform: wp.array) -> wp.array: ) def _get_lin_vel_from_spatial_vector(self, sv: wp.array) -> wp.array: + """Return a linear velocity view aliased into a spatial vector array. + + Args: + sv: Source spatial vector array. + + Returns: + vec3f view into the linear velocity component. + """ return wp.array( ptr=sv.ptr, shape=sv.shape, @@ -1764,6 +2037,14 @@ def _get_lin_vel_from_spatial_vector(self, sv: wp.array) -> wp.array: ) def _get_ang_vel_from_spatial_vector(self, sv: wp.array) -> wp.array: + """Return an angular velocity view aliased into a spatial vector array. + + Args: + sv: Source spatial vector array. + + Returns: + vec3f view into the angular velocity component (offset 3 floats = 12 bytes). + """ return wp.array( ptr=sv.ptr + 3 * 4, shape=sv.shape, @@ -1771,3 +2052,197 @@ def _get_ang_vel_from_spatial_vector(self, sv: wp.array) -> wp.array: strides=sv.strides, device=self.device, ) + + """ + Deprecated properties. + """ + + @property + def default_root_state(self) -> ProxyArray: + """Deprecated. Use :attr:`default_root_pose` and :attr:`default_root_vel` instead. + + Shape is (num_instances,), dtype = ``vec13f``. In torch this resolves to (num_instances, 13). + """ + warnings.warn( + "default_root_state is deprecated. Use default_root_pose and default_root_vel.", + DeprecationWarning, + stacklevel=2, + ) + wp.launch( + concat_root_pose_and_vel_to_state, + dim=self.num_instances, + inputs=[self._default_root_pose, self._default_root_vel], + outputs=[self._default_root_state_buf], + device=self.device, + ) + if self._default_root_state_ta is None: + self._default_root_state_ta = ProxyArray(self._default_root_state_buf) + return self._default_root_state_ta + + @property + def root_state_w(self) -> ProxyArray: + """Deprecated. Use :attr:`root_link_pose_w` and :attr:`root_com_vel_w` instead. + + Shape is (num_instances,), dtype = ``vec13f``. In torch this resolves to (num_instances, 13). + """ + warnings.warn( + "The `root_state_w` property will be deprecated in IsaacLab 4.0. Please use `root_link_pose_w` and " + "`root_com_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._root_state_w_buf.timestamp < self._sim_timestamp: + wp.launch( + concat_root_pose_and_vel_to_state, + dim=self.num_instances, + inputs=[self.root_link_pose_w, self.root_com_vel_w], + outputs=[self._root_state_w_buf.data], + device=self.device, + ) + self._root_state_w_buf.timestamp = self._sim_timestamp + if self._root_state_w_ta is None: + self._root_state_w_ta = ProxyArray(self._root_state_w_buf.data) + return self._root_state_w_ta + + @property + def root_link_state_w(self) -> ProxyArray: + """Deprecated. Use :attr:`root_link_pose_w` and :attr:`root_link_vel_w` instead. + + Shape is (num_instances,), dtype = ``vec13f``. In torch this resolves to (num_instances, 13). + """ + warnings.warn( + "The `root_link_state_w` property will be deprecated in IsaacLab 4.0. Please use `root_link_pose_w` and " + "`root_link_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._root_link_state_w_buf.timestamp < self._sim_timestamp: + wp.launch( + concat_root_pose_and_vel_to_state, + dim=self.num_instances, + inputs=[self.root_link_pose_w, self.root_link_vel_w], + outputs=[self._root_link_state_w_buf.data], + device=self.device, + ) + self._root_link_state_w_buf.timestamp = self._sim_timestamp + if self._root_link_state_w_ta is None: + self._root_link_state_w_ta = ProxyArray(self._root_link_state_w_buf.data) + return self._root_link_state_w_ta + + @property + def root_com_state_w(self) -> ProxyArray: + """Deprecated. Use :attr:`root_com_pose_w` and :attr:`root_com_vel_w` instead. + + Shape is (num_instances,), dtype = ``vec13f``. In torch this resolves to (num_instances, 13). + """ + warnings.warn( + "The `root_com_state_w` property will be deprecated in IsaacLab 4.0. Please use `root_com_pose_w` and " + "`root_com_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._root_com_state_w_buf.timestamp < self._sim_timestamp: + wp.launch( + concat_root_pose_and_vel_to_state, + dim=self.num_instances, + inputs=[self.root_com_pose_w, self.root_com_vel_w], + outputs=[self._root_com_state_w_buf.data], + device=self.device, + ) + self._root_com_state_w_buf.timestamp = self._sim_timestamp + if self._root_com_state_w_ta is None: + self._root_com_state_w_ta = ProxyArray(self._root_com_state_w_buf.data) + return self._root_com_state_w_ta + + @property + def body_state_w(self) -> ProxyArray: + """Deprecated. Use :attr:`body_link_pose_w` and :attr:`body_com_vel_w` instead. + + Shape is (num_instances, num_bodies), dtype = ``vec13f``. + In torch this resolves to (num_instances, num_bodies, 13). + """ + warnings.warn( + "The `body_state_w` property will be deprecated in IsaacLab 4.0. Please use `body_link_pose_w` and " + "`body_com_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._body_state_w_buf.timestamp >= self._sim_timestamp: + if self._body_state_w_ta is None: + self._body_state_w_ta = ProxyArray(self._body_state_w_buf.data) + return self._body_state_w_ta + _ = self.body_link_pose_w + _ = self.body_com_vel_w + wp.launch( + concat_body_pose_and_vel_to_state, + dim=(self.num_instances, self.num_bodies), + inputs=[self._body_link_pose_w.data, self._body_com_vel_w.data], + outputs=[self._body_state_w_buf.data], + device=self.device, + ) + self._body_state_w_buf.timestamp = self._sim_timestamp + if self._body_state_w_ta is None: + self._body_state_w_ta = ProxyArray(self._body_state_w_buf.data) + return self._body_state_w_ta + + @property + def body_link_state_w(self) -> ProxyArray: + """Deprecated. Use :attr:`body_link_pose_w` and :attr:`body_link_vel_w` instead. + + Shape is (num_instances, num_bodies), dtype = ``vec13f``. + In torch this resolves to (num_instances, num_bodies, 13). + """ + warnings.warn( + "The `body_link_state_w` property will be deprecated in IsaacLab 4.0. Please use `body_link_pose_w` and " + "`body_link_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._body_link_state_w_buf.timestamp >= self._sim_timestamp: + if self._body_link_state_w_ta is None: + self._body_link_state_w_ta = ProxyArray(self._body_link_state_w_buf.data) + return self._body_link_state_w_ta + _ = self.body_link_pose_w + _ = self.body_link_vel_w + wp.launch( + concat_body_pose_and_vel_to_state, + dim=(self.num_instances, self.num_bodies), + inputs=[self._body_link_pose_w.data, self._body_link_vel_w.data], + outputs=[self._body_link_state_w_buf.data], + device=self.device, + ) + self._body_link_state_w_buf.timestamp = self._sim_timestamp + if self._body_link_state_w_ta is None: + self._body_link_state_w_ta = ProxyArray(self._body_link_state_w_buf.data) + return self._body_link_state_w_ta + + @property + def body_com_state_w(self) -> ProxyArray: + """Deprecated. Use :attr:`body_com_pose_w` and :attr:`body_com_vel_w` instead. + + Shape is (num_instances, num_bodies), dtype = ``vec13f``. + In torch this resolves to (num_instances, num_bodies, 13). + """ + warnings.warn( + "The `body_com_state_w` property will be deprecated in IsaacLab 4.0. Please use `body_com_pose_w` and " + "`body_com_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._body_com_state_w_buf.timestamp >= self._sim_timestamp: + if self._body_com_state_w_ta is None: + self._body_com_state_w_ta = ProxyArray(self._body_com_state_w_buf.data) + return self._body_com_state_w_ta + _ = self.body_com_pose_w + _ = self.body_com_vel_w + wp.launch( + concat_body_pose_and_vel_to_state, + dim=(self.num_instances, self.num_bodies), + inputs=[self._body_com_pose_w.data, self._body_com_vel_w.data], + outputs=[self._body_com_state_w_buf.data], + device=self.device, + ) + self._body_com_state_w_buf.timestamp = self._sim_timestamp + if self._body_com_state_w_ta is None: + self._body_com_state_w_ta = ProxyArray(self._body_com_state_w_buf.data) + return self._body_com_state_w_ta diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/kernels.py index cc9faf15753a..9b9d9ff3d3c5 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/kernels.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/kernels.py @@ -3,17 +3,30 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Warp kernels for the ovphysx articulation.""" - import warp as wp +""" +Articulation-specific warp functions. +""" + @wp.func def compute_soft_joint_pos_limits_func( joint_pos_limits: wp.vec2f, soft_limit_factor: wp.float32, ): - """Compute soft joint position limits from hard limits.""" + """Compute the soft joint position limits. + + Args: + joint_pos_limits: Hard joint position limits as ``(lower, upper)`` [m or rad, + depending on joint type]. + soft_limit_factor: Scale factor in [0, 1] shrinking the soft range around + the midpoint of the hard range; ``1.0`` makes the soft limits equal the + hard limits, smaller values create a tighter window. + + Returns: + The soft joint position limits as ``(lower, upper)``. + """ joint_pos_mean = (joint_pos_limits[0] + joint_pos_limits[1]) / 2.0 joint_pos_range = joint_pos_limits[1] - joint_pos_limits[0] return wp.vec2f( @@ -22,19 +35,8 @@ def compute_soft_joint_pos_limits_func( ) -@wp.kernel -def update_soft_joint_pos_limits( - joint_pos_limits: wp.array2d(dtype=wp.vec2f), - soft_limit_factor: wp.float32, - soft_joint_pos_limits: wp.array2d(dtype=wp.vec2f), -): - """Update soft joint position limits from hard limits and a scale factor.""" - i, j = wp.tid() - soft_joint_pos_limits[i, j] = compute_soft_joint_pos_limits_func(joint_pos_limits[i, j], soft_limit_factor) - - """ -Data-layer kernels (used by ArticulationData). +Articulation-specific warp kernels. """ @@ -45,13 +47,20 @@ def _fd_joint_acc( inv_dt: float, out: wp.array2d(dtype=wp.float32), ): - """Compute joint acceleration via finite differencing and update previous velocity. + """Compute the joint acceleration via finite differencing and update the previous velocity. + + Diverges from PhysX's :func:`get_joint_acc_from_joint_vel` in taking the inverse + time step rather than ``dt`` itself; the multiply-by-reciprocal avoids per-element + division inside the kernel. Args: - cur_vel: Current joint velocities. Shape is (num_envs, num_joints). - prev_vel: Previous joint velocities (updated in-place). Shape is (num_envs, num_joints). - inv_dt: Inverse time step (1/dt) [1/s]. - out: Output joint accelerations. Shape is (num_envs, num_joints). + cur_vel: Current joint velocities [m/s or rad/s, depending on joint type]. + Shape is (num_envs, num_joints). + prev_vel: Previous joint velocities (updated in-place). Same shape and units + as :paramref:`cur_vel`. + inv_dt: Inverse time step ``1 / dt`` [1/s]. + out: Output joint accelerations [m/s^2 or rad/s^2, depending on joint type]. + Shape is (num_envs, num_joints). """ i, j = wp.tid() out[i, j] = (cur_vel[i, j] - prev_vel[i, j]) * inv_dt @@ -67,9 +76,193 @@ def _compose_body_com_poses( """Compose body link poses with body-frame CoM offsets to get world-frame CoM poses. Args: - link_pose: Body link poses in world frame. Shape is (num_envs, num_bodies). - com_pose_b: Body-frame CoM offsets. Shape is (num_envs, num_bodies). - com_pose_w: Output world-frame body CoM poses. Shape is (num_envs, num_bodies). + link_pose: Body link poses in world frame [m, m, m, qx, qy, qz, qw]. + Shape is (num_envs, num_bodies). + com_pose_b: Body-frame CoM offsets [m, m, m, qx, qy, qz, qw]. + Shape is (num_envs, num_bodies). + com_pose_w: Output world-frame body CoM poses [m, m, m, qx, qy, qz, qw]. + Shape is (num_envs, num_bodies). """ i, j = wp.tid() com_pose_w[i, j] = wp.transform_multiply(link_pose[i, j], com_pose_b[i, j]) + + +@wp.kernel +def update_soft_joint_pos_limits( + joint_pos_limits: wp.array2d(dtype=wp.vec2f), + soft_limit_factor: wp.float32, + soft_joint_pos_limits: wp.array2d(dtype=wp.vec2f), +): + """Update soft joint position limits from hard limits and a soft limit factor. + + Soft limits provide a safety margin before reaching the hard joint position + limits. See :func:`compute_soft_joint_pos_limits_func` for the per-joint + formula. + + Args: + joint_pos_limits: Hard joint position limits as vec2f ``(lower, upper)`` + [m or rad, depending on joint type]. Shape is (num_envs, num_joints). + soft_limit_factor: Scale factor in [0, 1]. ``1.0`` makes the soft limits + equal the hard limits; smaller values create a tighter window. + soft_joint_pos_limits: Output array. Shape is (num_envs, num_joints). + """ + i, j = wp.tid() + soft_joint_pos_limits[i, j] = compute_soft_joint_pos_limits_func(joint_pos_limits[i, j], soft_limit_factor) + + +@wp.kernel +def clamp_default_joint_pos_and_update_soft_limits_index( + joint_pos_limits: wp.array2d(dtype=wp.vec2f), + env_ids: wp.array(dtype=wp.int32), + joint_ids: wp.array(dtype=wp.int32), + soft_limit_factor: wp.float32, + default_joint_pos: wp.array2d(dtype=wp.float32), + soft_joint_pos_limits: wp.array2d(dtype=wp.vec2f), + clamped_count: wp.array(dtype=wp.int32), +): + """Clamp default joint positions to new limits and refresh soft limits over (env_ids x joint_ids). + + Mirrors PhysX's :func:`isaaclab_physx.assets.articulation.kernels.write_joint_limit_data_to_buffer` + side-effects, minus the limit-write itself (the existing + :func:`shared_kernels.write_joint_position_limit_to_buffer_index` launch handles that). + + For each ``(i, j)`` thread the kernel: + + * Clamps :paramref:`default_joint_pos` ``[env_ids[i], joint_ids[j]]`` if it falls outside + the new limits, atomically incrementing :paramref:`clamped_count`. + * Recomputes :paramref:`soft_joint_pos_limits` ``[env_ids[i], joint_ids[j]]`` from the new + hard limits and :paramref:`soft_limit_factor`. + + Args: + joint_pos_limits: Hard joint position limits as vec2f ``(lower, upper)`` + [m or rad, depending on joint type]. Shape is (num_envs, num_joints). + env_ids: Environment indices to update. Shape is (num_selected_envs,). + joint_ids: Joint indices to update. Shape is (num_selected_joints,). + soft_limit_factor: Scale factor in [0, 1] for the soft limit window. + default_joint_pos: In/out default joint positions [m or rad, depending on joint type]. + Shape is (num_envs, num_joints). + soft_joint_pos_limits: Out soft joint position limits as vec2f ``(lower, upper)`` + [m or rad, depending on joint type]. Shape is (num_envs, num_joints). + clamped_count: One-element output counter incremented atomically each time a + default joint position was clamped. Shape is (1,). + """ + i, j = wp.tid() + e = env_ids[i] + k = joint_ids[j] + lo = joint_pos_limits[e, k][0] + hi = joint_pos_limits[e, k][1] + if (default_joint_pos[e, k] < lo) or (default_joint_pos[e, k] > hi): + wp.atomic_add(clamped_count, 0, 1) + default_joint_pos[e, k] = wp.clamp(default_joint_pos[e, k], lo, hi) + soft_joint_pos_limits[e, k] = compute_soft_joint_pos_limits_func(joint_pos_limits[e, k], soft_limit_factor) + + +@wp.kernel +def clamp_default_joint_pos_and_update_soft_limits_mask( + joint_pos_limits: wp.array2d(dtype=wp.vec2f), + env_mask: wp.array(dtype=wp.bool), + joint_mask: wp.array(dtype=wp.bool), + soft_limit_factor: wp.float32, + default_joint_pos: wp.array2d(dtype=wp.float32), + soft_joint_pos_limits: wp.array2d(dtype=wp.vec2f), + clamped_count: wp.array(dtype=wp.int32), +): + """Mask variant of :func:`clamp_default_joint_pos_and_update_soft_limits_index`. + + Iterates the full ``(num_envs, num_joints)`` grid and applies the clamp / + soft-limit refresh only where both :paramref:`env_mask` and :paramref:`joint_mask` + are ``True``. + + Args: + joint_pos_limits: Hard joint position limits as vec2f ``(lower, upper)`` + [m or rad, depending on joint type]. Shape is (num_envs, num_joints). + env_mask: Boolean mask over environments. Shape is (num_envs,). + joint_mask: Boolean mask over joints. Shape is (num_joints,). + soft_limit_factor: Scale factor in [0, 1] for the soft limit window. + default_joint_pos: In/out default joint positions [m or rad, depending on joint type]. + Shape is (num_envs, num_joints). + soft_joint_pos_limits: Out soft joint position limits as vec2f ``(lower, upper)`` + [m or rad, depending on joint type]. Shape is (num_envs, num_joints). + clamped_count: One-element output counter incremented atomically each time a + default joint position was clamped. Shape is (1,). + """ + i, j = wp.tid() + if not env_mask[i] or not joint_mask[j]: + return + lo = joint_pos_limits[i, j][0] + hi = joint_pos_limits[i, j][1] + if (default_joint_pos[i, j] < lo) or (default_joint_pos[i, j] > hi): + wp.atomic_add(clamped_count, 0, 1) + default_joint_pos[i, j] = wp.clamp(default_joint_pos[i, j], lo, hi) + soft_joint_pos_limits[i, j] = compute_soft_joint_pos_limits_func(joint_pos_limits[i, j], soft_limit_factor) + + +@wp.kernel +def write_joint_friction_data_to_buffer_index( + in_static: wp.array2d(dtype=wp.float32), + in_dynamic: wp.array2d(dtype=wp.float32), + in_viscous: wp.array2d(dtype=wp.float32), + env_ids: wp.array(dtype=wp.int32), + joint_ids: wp.array(dtype=wp.int32), + out_buffer: wp.array3d(dtype=wp.float32), +): + """Conditionally update the static / dynamic / viscous slots of the friction buffer. + + Mirrors :func:`isaaclab_physx.assets.articulation.kernels.write_joint_friction_data_to_buffer`: + each of the three input arrays is optional (``None`` translates to a null pointer + which evaluates ``False`` inside the kernel), so callers can update any subset + of the friction components without disturbing the others. + + Args: + in_static: Static friction coefficients, or ``None`` to leave that component + unchanged. Shape is (num_selected_envs, num_selected_joints). + in_dynamic: Dynamic friction coefficients, or ``None``. Same shape as + :paramref:`in_static`. + in_viscous: Viscous friction coefficients [N·s/m or N·m·s/rad, depending on + joint type], or ``None``. Same shape as :paramref:`in_static`. + env_ids: Environment indices to write. Shape is (num_selected_envs,). + joint_ids: Joint indices to write. Shape is (num_selected_joints,). + out_buffer: Combined friction buffer. Shape is (num_envs, num_joints, 3) with + slots [0] static, [1] dynamic, [2] viscous. + """ + i, j = wp.tid() + if in_static: + out_buffer[env_ids[i], joint_ids[j], 0] = in_static[i, j] + if in_dynamic: + out_buffer[env_ids[i], joint_ids[j], 1] = in_dynamic[i, j] + if in_viscous: + out_buffer[env_ids[i], joint_ids[j], 2] = in_viscous[i, j] + + +@wp.kernel +def write_joint_friction_data_to_buffer_mask( + in_static: wp.array2d(dtype=wp.float32), + in_dynamic: wp.array2d(dtype=wp.float32), + in_viscous: wp.array2d(dtype=wp.float32), + env_mask: wp.array(dtype=wp.bool), + joint_mask: wp.array(dtype=wp.bool), + out_buffer: wp.array3d(dtype=wp.float32), +): + """Mask variant of :func:`write_joint_friction_data_to_buffer_index`. + + Args: + in_static: Static friction coefficients, or ``None`` to leave that component + unchanged. Shape is (num_envs, num_joints). + in_dynamic: Dynamic friction coefficients, or ``None``. Same shape as + :paramref:`in_static`. + in_viscous: Viscous friction coefficients [N·s/m or N·m·s/rad, depending on + joint type], or ``None``. Same shape as :paramref:`in_static`. + env_mask: Boolean mask over environments. Shape is (num_envs,). + joint_mask: Boolean mask over joints. Shape is (num_joints,). + out_buffer: Combined friction buffer. Shape is (num_envs, num_joints, 3) with + slots [0] static, [1] dynamic, [2] viscous. + """ + i, j = wp.tid() + if not env_mask[i] or not joint_mask[j]: + return + if in_static: + out_buffer[i, j, 0] = in_static[i, j] + if in_dynamic: + out_buffer[i, j, 1] = in_dynamic[i, j] + if in_viscous: + out_buffer[i, j, 2] = in_viscous[i, j] diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py index cf49c8362636..9d1b6ca1b8aa 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py @@ -1106,3 +1106,271 @@ def write_body_com_pose_to_buffer_mask( i, j = wp.tid() if env_mask[i] and body_mask[j]: out_data[i, j] = in_data[i, j] + + +""" +Articulation-only kernels (used by isaaclab_ovphysx.assets.articulation). +""" + + +@wp.kernel +def _copy_first_body( + body_vel: wp.array(dtype=wp.spatial_vectorf, ndim=2), + root_vel: wp.array(dtype=wp.spatial_vectorf), +): + """Copy the first body's spatial velocity to the root velocity buffer. + + For single rigid-body assets, index 0 is always the root body. This + kernel extracts that slice without allocating an intermediate buffer. + + Args: + body_vel: Body spatial velocities ``[m/s, rad/s]``. Shape is + ``(num_envs, num_bodies)`` with dtype ``wp.spatial_vectorf``. + root_vel: Output root spatial velocities ``[m/s, rad/s]``. Shape is + ``(num_envs,)`` with dtype ``wp.spatial_vectorf``. + """ + i = wp.tid() + root_vel[i] = body_vel[i, 0] + + +@wp.kernel +def _compose_root_com_pose( + link_pose: wp.array(dtype=wp.transformf), + com_pose_b: wp.array(dtype=wp.transformf, ndim=2), + com_pose_w: wp.array(dtype=wp.transformf), +): + """Compose root link pose with the body-frame COM offset to get the world-frame COM pose. + + Implements the forward transform: + + ``com_pose_w = link_pose_w * com_pose_b[0]`` + + where ``*`` denotes ``wp.transform_multiply``. Only the first body + (index ``0``) is used; for articulations this is the base link body. + + Args: + link_pose: Root link poses in world frame ``[m, -]``. Shape is + ``(num_envs,)`` with dtype ``wp.transformf``. + com_pose_b: Body-frame COM offsets ``[m, -]`` from the + ``RIGID_BODY_COM_POSE`` binding. Shape is ``(num_envs, num_bodies)`` + with dtype ``wp.transformf``. + com_pose_w: Output world-frame root COM poses ``[m, -]``. Shape is + ``(num_envs,)`` with dtype ``wp.transformf``. + """ + i = wp.tid() + com_pose_w[i] = wp.transform_multiply(link_pose[i], com_pose_b[i, 0]) + + +@wp.kernel +def _projected_gravity( + gravity_vec_w: wp.array(dtype=wp.vec3f), + root_pose: wp.array(dtype=wp.transformf), + out: wp.array(dtype=wp.vec3f), +): + """Project the world-frame gravity direction into the root body frame. + + Applies the inverse of the root orientation quaternion to the world-frame + gravity vector, yielding the gravity direction expressed in the body frame. + The magnitude is preserved (unit vector in, unit vector out if input is a + unit vector). + + Args: + gravity_vec_w: Gravity direction per instance in world frame ``[-]`` + (typically the normalised ``(0, 0, -1)`` gravitational acceleration + direction). Shape is ``(num_envs,)`` with dtype ``wp.vec3f``. + root_pose: Root link poses in world frame ``[m, -]``. Only the + rotation component is used. Shape is ``(num_envs,)`` with dtype + ``wp.transformf``. + out: Output gravity direction in body frame ``[-]``. Shape is + ``(num_envs,)`` with dtype ``wp.vec3f``. + """ + i = wp.tid() + q = wp.transform_get_rotation(root_pose[i]) + out[i] = wp.quat_rotate_inv(q, gravity_vec_w[i]) + + +@wp.kernel +def _compute_heading( + forward_vec_b: wp.array(dtype=wp.vec3f), + root_pose: wp.array(dtype=wp.transformf), + out: wp.array(dtype=wp.float32), +): + """Compute the yaw heading angle by rotating a body-frame forward vector to world frame. + + Rotates ``forward_vec_b`` by the root orientation quaternion and then computes the + heading as ``atan2(forward_w.y, forward_w.x)`` ``[rad]``, i.e. the signed angle + from the world X-axis to the projected forward direction in the XY plane. + + Args: + forward_vec_b: Forward direction in body frame per instance ``[-]``. + Shape is ``(num_envs,)`` with dtype ``wp.vec3f``. + root_pose: Root link poses in world frame ``[m, -]``. Only the rotation + component is used. Shape is ``(num_envs,)`` with dtype ``wp.transformf``. + out: Output heading angles ``[rad]`` in ``[-π, π]``. Shape is + ``(num_envs,)`` with dtype ``wp.float32``. + """ + i = wp.tid() + q = wp.transform_get_rotation(root_pose[i]) + forward = wp.quat_rotate(q, forward_vec_b[i]) + out[i] = wp.atan2(forward[1], forward[0]) + + +@wp.kernel +def _world_vel_to_body_lin( + root_pose: wp.array(dtype=wp.transformf), + vel_w: wp.array(dtype=wp.spatial_vectorf), + out: wp.array(dtype=wp.vec3f), +): + """Rotate the world-frame linear velocity component into the root body frame. + + Extracts the linear velocity from the top three components of the spatial + velocity vector (``wp.spatial_top``) and rotates it by the inverse of the + root orientation quaternion. + + Args: + root_pose: Root link poses in world frame ``[m, -]``. Only the rotation + component is used. Shape is ``(num_envs,)`` with dtype ``wp.transformf``. + vel_w: Root spatial velocities in world frame ``[m/s, rad/s]``. + Shape is ``(num_envs,)`` with dtype ``wp.spatial_vectorf``. + out: Output linear velocity in body frame ``[m/s]``. Shape is + ``(num_envs,)`` with dtype ``wp.vec3f``. + """ + i = wp.tid() + q = wp.transform_get_rotation(root_pose[i]) + lin = wp.spatial_top(vel_w[i]) + out[i] = wp.quat_rotate_inv(q, lin) + + +@wp.kernel +def _world_vel_to_body_ang( + root_pose: wp.array(dtype=wp.transformf), + vel_w: wp.array(dtype=wp.spatial_vectorf), + out: wp.array(dtype=wp.vec3f), +): + """Rotate the world-frame angular velocity component into the root body frame. + + Extracts the angular velocity from the bottom three components of the spatial + velocity vector (``wp.spatial_bottom``) and rotates it by the inverse of the + root orientation quaternion. + + Args: + root_pose: Root link poses in world frame ``[m, -]``. Only the rotation + component is used. Shape is ``(num_envs,)`` with dtype ``wp.transformf``. + vel_w: Root spatial velocities in world frame ``[m/s, rad/s]``. + Shape is ``(num_envs,)`` with dtype ``wp.spatial_vectorf``. + out: Output angular velocity in body frame ``[rad/s]``. Shape is + ``(num_envs,)`` with dtype ``wp.vec3f``. + """ + i = wp.tid() + q = wp.transform_get_rotation(root_pose[i]) + ang = wp.spatial_bottom(vel_w[i]) + out[i] = wp.quat_rotate_inv(q, ang) + + +@wp.kernel +def write_joint_position_limit_to_buffer_index( + in_data: wp.array3d(dtype=wp.float32), + env_ids: wp.array(dtype=wp.int32), + joint_ids: wp.array(dtype=wp.int32), + out_data: wp.array(dtype=wp.vec2f, ndim=2), +): + """Write joint position-limit data to a vec2f buffer at specified indices. + + This kernel copies ``[lower, upper]`` limit pairs from a partial float32 input + array into the output ``wp.vec2f`` buffer at the specified environment and joint + indices. + + Args: + in_data: Input array containing limit pairs ``[lower, upper]`` [m or rad]. + Shape is (num_selected_envs, num_selected_joints, 2). + env_ids: Input array of environment indices to write to. + Shape is (num_selected_envs,). + joint_ids: Input array of joint indices to write to. + Shape is (num_selected_joints,). + out_data: Output array where limit data is written. Shape is + (num_envs, num_joints) with dtype ``wp.vec2f``. + """ + i, j = wp.tid() + out_data[env_ids[i], joint_ids[j]] = wp.vec2f(in_data[i, j, 0], in_data[i, j, 1]) + + +@wp.kernel +def write_joint_position_limit_to_buffer_mask( + in_data: wp.array3d(dtype=wp.float32), + env_mask: wp.array(dtype=wp.bool), + joint_mask: wp.array(dtype=wp.bool), + out_data: wp.array(dtype=wp.vec2f, ndim=2), +): + """Mask-scatter joint position-limit data into the vec2f cache buffer. + + Copies ``[lower, upper]`` limit pairs where both ``env_mask[i]`` and + ``joint_mask[j]`` are True. + + Args: + in_data: Input array containing limit pairs ``[lower, upper]`` [m or rad]. + Shape is (num_envs, num_joints, 2). + env_mask: Boolean environment mask. Shape is (num_envs,). + joint_mask: Boolean joint mask. Shape is (num_joints,). + out_data: Output array where limit data is written. Shape is + (num_envs, num_joints) with dtype ``wp.vec2f``. + """ + i, j = wp.tid() + if env_mask[i] and joint_mask[j]: + out_data[i, j] = wp.vec2f(in_data[i, j, 0], in_data[i, j, 1]) + + +@wp.kernel +def write_joint_friction_to_buffer_index( + in_data: wp.array2d(dtype=wp.float32), + env_ids: wp.array(dtype=wp.int32), + joint_ids: wp.array(dtype=wp.int32), + out_data: wp.array3d(dtype=wp.float32), +): + """Write joint friction coefficient to all three components of the friction buffer. + + Broadcasts a single friction value into the static (index 0), dynamic (index 1), + and viscous (index 2) components of the ``(N, D, 3)`` friction properties buffer + at the specified environment and joint indices. + + Args: + in_data: Input friction coefficients [dimensionless]. Shape is + (num_selected_envs, num_selected_joints). + env_ids: Input array of environment indices to write to. + Shape is (num_selected_envs,). + joint_ids: Input array of joint indices to write to. + Shape is (num_selected_joints,). + out_data: Output friction properties buffer. Shape is (num_envs, num_joints, 3). + """ + i, j = wp.tid() + val = in_data[i, j] + out_data[env_ids[i], joint_ids[j], 0] = val + out_data[env_ids[i], joint_ids[j], 1] = val + out_data[env_ids[i], joint_ids[j], 2] = val + + +@wp.kernel +def write_joint_friction_to_buffer_mask( + in_data: wp.array2d(dtype=wp.float32), + env_mask: wp.array(dtype=wp.bool), + joint_mask: wp.array(dtype=wp.bool), + out_data: wp.array3d(dtype=wp.float32), +): + """Mask-scatter joint friction coefficient into all three components of the friction buffer. + + Broadcasts a single friction value into the static (index 0), dynamic (index 1), + and viscous (index 2) components where both ``env_mask[i]`` and ``joint_mask[j]`` + are True. + + Args: + in_data: Input friction coefficients [dimensionless]. Shape is + (num_envs, num_joints). + env_mask: Boolean environment mask. Shape is (num_envs,). + joint_mask: Boolean joint mask. Shape is (num_joints,). + out_data: Output friction properties buffer. Shape is (num_envs, num_joints, 3). + """ + i, j = wp.tid() + if env_mask[i] and joint_mask[j]: + val = in_data[i, j] + out_data[i, j, 0] = val + out_data[i, j, 1] = val + out_data[i, j, 2] = val diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 9be415fed577..e96154bacf59 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -138,6 +138,7 @@ def step(cls) -> None: dt = cls.get_physics_dt() sim_time = PhysicsManager._sim_time cls._physx.step_sync(dt=dt, sim_time=sim_time) + cls._physx.update_articulations_kinematic() PhysicsManager._sim_time += dt @classmethod diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py index 41afe07cf09c..9e7df44aef9a 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py @@ -365,6 +365,11 @@ # fmt: on # DOF/body property tensor types are CPU-resident even in GPU simulations. # Write helpers check this set to route data through CPU, not self._device. +# +# Tendon tensor types are NOT in this set: PhysX exposes tendons on the +# simulation device (its ``set_fixed_tendon_properties`` takes ``data.warp`` +# without a ``device="cpu"`` clone, unlike ``set_dof_stiffnesses``), and the +# OVPhysX wheel mirrors that — tendon bindings are GPU-resident on a GPU sim. _CPU_ONLY_TYPES_CANDIDATES: tuple = ( DOF_STIFFNESS, DOF_DAMPING, @@ -378,16 +383,6 @@ BODY_INERTIA, BODY_INV_MASS, BODY_INV_INERTIA, - FIXED_TENDON_STIFFNESS, - FIXED_TENDON_DAMPING, - FIXED_TENDON_LIMIT_STIFFNESS, - FIXED_TENDON_LIMIT, - FIXED_TENDON_REST_LENGTH, - FIXED_TENDON_OFFSET, - SPATIAL_TENDON_STIFFNESS, - SPATIAL_TENDON_DAMPING, - SPATIAL_TENDON_LIMIT_STIFFNESS, - SPATIAL_TENDON_OFFSET, # Rigid-body CPU-only entries (always available) RIGID_BODY_MASS, RIGID_BODY_COM_POSE, diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation.py b/source/isaaclab_ovphysx/test/assets/test_articulation.py index 52998a2ec5f6..e4b052065127 100644 --- a/source/isaaclab_ovphysx/test/assets/test_articulation.py +++ b/source/isaaclab_ovphysx/test/assets/test_articulation.py @@ -3,114 +3,2435 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Unit tests for ovphysx articulation helpers.""" +# ignore private usage of variables warning +# pyright: reportPrivateUsage=none + + +"""Real-backend tests for the OVPhysX Articulation. + +Mirrors :mod:`isaaclab_physx.test.assets.test_articulation` 1-to-1: same set +of test functions, names, parametrizations, and assertions. + +OVPhysX runs kitless under ``./scripts/run_ovphysx.sh`` so there is no +``AppLauncher`` boot — :class:`~isaaclab.sim.SimulationContext` is driven +directly via ``build_simulation_context(sim_cfg=SimulationCfg(physics=OvPhysxCfg(), ...))`` +which works because :func:`isaaclab.app.has_kit` returns False in this +environment. + +PhysX-specific ``cube_object.root_view.set_X(...)`` / ``get_X(...)`` calls are +adapted to OVPhysX by going through the backend's per-tensor-type binding +dictionary (``cube_object._bindings`` / :meth:`~isaaclab_ovphysx.assets.Articulation._get_binding`) +and the public setters (:meth:`set_masses_index`, :meth:`set_coms_index`, +:meth:`set_inertias_index`). Reads use the data-class properties +(``cube_object.data.body_mass``, ``body_inertia``, ``body_com_pose_b``). + +Process-global device lock +-------------------------- + +``ovphysx<=0.3.7`` binds device mode (CPU vs GPU) at the C++ layer on the +first ``ovphysx.PhysX(device=...)`` call and cannot release/swap it without a +process restart. :class:`~isaaclab_ovphysx.physics.OvPhysxManager` tracks +this on ``_locked_device`` and raises :exc:`RuntimeError` if a later +:class:`SimulationContext` requests a different device. The +``_ovphysx_skip_other_device`` autouse fixture below preempts that error in +parametrized tests by ``pytest.skip``-ing on the unlocked device, so the +session finishes cleanly when only one device is exercised. + +CI note +------- +Because the lock is process-global, full coverage requires **two separate +``./scripts/run_ovphysx.sh -m pytest`` invocations** -- once with ``-k 'cpu'`` +and once with ``-k 'cuda:0'``. Tracked as gap G5 in +``docs/superpowers/specs/2026-04-28-ovphysx-wheel-gaps-for-marco.md``; until +the wheel exposes a way to reset Carbonite device state, this is the supported +pattern. +""" from __future__ import annotations -from types import SimpleNamespace +import sys +import numpy as np import pytest +import torch import warp as wp -from pxr import Sdf, Usd, UsdPhysics - # The CI isaaclab_ov* pattern unintentionally collects isaaclab_ovphysx tests, # but the ovphysx wheel is not installed in that environment. Skip gracefully # so the isaaclab_ov CI pipeline is not blocked by an unrelated dependency. pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") -from isaaclab_ovphysx.assets.articulation.articulation import Articulation # noqa: E402 -from isaaclab_ovphysx.physics import OvPhysxManager # noqa: E402 -from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet # noqa: E402 +from isaaclab_ovphysx import tensor_types as TT # noqa: E402 +from isaaclab_ovphysx.assets import Articulation # noqa: E402 +from isaaclab_ovphysx.physics import OvPhysxCfg # noqa: E402 + +import isaaclab.sim as sim_utils # noqa: E402 +import isaaclab.utils.math as math_utils # noqa: E402 +import isaaclab.utils.string as string_utils # noqa: E402 +from isaaclab.actuators import ActuatorBase, IdealPDActuatorCfg, ImplicitActuatorCfg # noqa: E402 +from isaaclab.assets import ArticulationCfg # noqa: E402 +from isaaclab.envs.mdp.terminations import joint_effort_out_of_limit # noqa: E402 +from isaaclab.managers import SceneEntityCfg # noqa: E402 +from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR # noqa: E402 +from isaaclab.utils.version import get_isaac_sim_version, has_kit # noqa: E402 + +## +# Pre-defined configs +## +from isaaclab_assets import ANYMAL_C_CFG, FRANKA_PANDA_CFG, SHADOW_HAND_CFG # isort:skip wp.init() -def _define_tendon_joint(stage: Usd.Stage, path: str, schema_name: str) -> None: - """Define a revolute joint prim with a tendon schema marker.""" - joint = UsdPhysics.RevoluteJoint.Define(stage, path) - schemas = Sdf.TokenListOp() - schemas.explicitItems = [schema_name] - joint.GetPrim().SetMetadata("apiSchemas", schemas) - - -def _make_articulation_root_stage(tmp_path) -> str: - """Create a stage with one relevant articulation subtree and unrelated joints elsewhere.""" - stage = Usd.Stage.CreateInMemory() - stage.DefinePrim("/World", "Xform") - stage.DefinePrim("/World/envs", "Xform") - stage.DefinePrim("/World/envs/env_0", "Xform") - stage.DefinePrim("/World/envs/env_0/Robot", "Xform") - stage.DefinePrim("/World/envs/env_0/Robot/root", "Xform") - stage.DefinePrim("/World/unrelated", "Xform") - - _define_tendon_joint( - stage, - "/World/envs/env_0/Robot/root/fixed_joint", - "PhysxTendonAxisRootAPI:inst0", - ) - _define_tendon_joint( - stage, - "/World/envs/env_0/Robot/root/spatial_joint", - "PhysxTendonAttachmentRootAPI:inst0", - ) - _define_tendon_joint( - stage, - "/World/unrelated/unrelated_fixed_joint", - "PhysxTendonAxisRootAPI:inst0", - ) - _define_tendon_joint( - stage, - "/World/unrelated/unrelated_spatial_joint", - "PhysxTendonAttachmentLeafAPI:inst0", - ) - - stage_path = tmp_path / "scene.usda" - stage.Export(str(stage_path)) - return str(stage_path) - - -def _make_articulation_shell() -> Articulation: - """Create a minimal ovphysx articulation shell for tendon processing tests.""" - articulation = object.__new__(Articulation) - bindings = MockOvPhysxBindingSet( - num_instances=1, - num_joints=2, - num_bodies=2, - num_fixed_tendons=1, - num_spatial_tendons=1, - ) - object.__setattr__(articulation, "_bindings", bindings.bindings) - object.__setattr__(articulation, "_articulation_root_path", "/World/envs/env_0/Robot/root") - object.__setattr__(articulation, "_initialize_handle", None) - object.__setattr__(articulation, "_invalidate_initialize_handle", None) - object.__setattr__(articulation, "_prim_deletion_handle", None) - object.__setattr__(articulation, "_debug_vis_handle", None) - object.__setattr__( - articulation, - "_data", - SimpleNamespace( - _num_fixed_tendons=0, - _num_spatial_tendons=0, - fixed_tendon_names=[], - spatial_tendon_names=[], - ), - ) - return articulation - - -def test_process_tendons_scopes_to_articulation_root(tmp_path): - """Tendon discovery should ignore joints that live outside the current articulation subtree.""" - articulation = _make_articulation_shell() - stage_path = _make_articulation_root_stage(tmp_path) - old_stage_path = OvPhysxManager._stage_path - OvPhysxManager._stage_path = stage_path - try: - articulation._process_tendons() - finally: - OvPhysxManager._stage_path = old_stage_path - - assert articulation.fixed_tendon_names == ["fixed_joint"] - assert articulation.spatial_tendon_names == ["spatial_joint"] - assert articulation._data.fixed_tendon_names == ["fixed_joint"] - assert articulation._data.spatial_tendon_names == ["spatial_joint"] +_OMNI_PHYSX_SCHEMAS_GAP_REASON = ( + "Schema-level fixed-joint creation in :mod:`isaaclab.sim.schemas` imports " + "``omni.physx.scripts.utils``, which is a Kit-only module not shipped by " + "the ovphysx wheel. See " + "docs/superpowers/specs/2026-04-28-ovphysx-wheel-gaps-for-marco.md." +) + +_MATERIAL_GAP_REASON = ( + "Requires a ``RIGID_BODY_MATERIAL`` TensorType (or a view-helper) on the " + "ovphysx wheel side. ``Articulation.root_view`` is a per-tensor-type " + "bindings dict on OVPhysX, so ``root_view.get_material_properties()`` / " + "``set_material_properties()`` / ``max_shapes`` are not available. See " + "docs/superpowers/specs/2026-04-28-ovphysx-wheel-gaps-for-marco.md." +) + + +def _read_binding_to_torch(articulation: Articulation, tensor_type: int, device: str | torch.device) -> torch.Tensor: + """Read an OVPhysX TensorBinding into a torch tensor on *device*. + + Test-side adapter for the verbatim PhysX mirror. PhysX cross-checks the + data class against the simulation via ``articulation.root_view.get_X()`` + accessors; on OVPhysX, ``root_view`` is a per-tensor-type bindings dict + (no view-level getters), so we read the binding directly into a CPU + numpy buffer (CPU-only types) and move the result to *device*. + """ + binding = articulation.root_view[tensor_type] + np_buf = np.zeros(binding.shape, dtype=np.float32) + binding.read(np_buf) + return torch.from_numpy(np_buf).to(device) + + +# Session-locked device. Set on the first parametrized test that runs and +# never reassigned -- ovphysx's process-global device lock means subsequent +# tests on the other device must skip. +_LOCKED_DEVICE: list[str | None] = [None] + + +@pytest.fixture(autouse=True) +def _ovphysx_skip_other_device(request): + """Skip tests whose ``device`` parameter mismatches the session-locked device. + + ``ovphysx<=0.3.7`` locks the process-global device mode on the first + ``ovphysx.PhysX(device=...)`` call, so any test parametrized to a different + device after the first ``sim.reset()`` would hit + :exc:`ovphysx.types.PhysXDeviceError`. We detect the locked device on the + first encounter and skip subsequent tests on the other device with a clear + message so the run finishes cleanly rather than producing spurious failures. + """ + callspec = getattr(request.node, "callspec", None) + device = callspec.params.get("device") if callspec is not None else None + if device is None: + # Test does not parametrize on device (e.g. test_warmup_attach_stage_not_called_for_cpu). + return + locked = _LOCKED_DEVICE[0] + if locked is None: + _LOCKED_DEVICE[0] = device + return + if device != locked: + pytest.skip( + f"ovphysx process-global device lock is held by '{locked}'; cannot run '{device}' " + "tests in the same session. Run pytest twice (once per device) for full coverage." + ) + + +def _ovphysx_sim_context(device: str, **kwargs): + """Wrapper around :func:`build_simulation_context` that injects OVPhysX cfg. + + PhysX tests pass ``device=device`` directly and let + :func:`build_simulation_context` build a default :class:`SimulationCfg`. + OVPhysX needs ``physics=OvPhysxCfg()`` set on the cfg so the manager + dispatches to OVPhysX rather than PhysX, so we build the cfg here and + pass it through. ``gravity_enabled`` is consumed locally (it is ignored + by ``build_simulation_context`` once a ``sim_cfg`` is provided). + ``add_ground_plane``, ``auto_add_lighting``, and other kwargs continue + to flow through ``build_simulation_context`` as before. + """ + dt = kwargs.pop("dt", 1.0 / 60.0) + gravity_enabled = kwargs.pop("gravity_enabled", True) + gravity = (0.0, 0.0, -9.81) if gravity_enabled else (0.0, 0.0, 0.0) + sim_cfg = SimulationCfg(physics=OvPhysxCfg(), device=device, dt=dt, gravity=gravity) + return build_simulation_context(device=device, sim_cfg=sim_cfg, **kwargs) + + +def generate_articulation_cfg( + articulation_type: str, + stiffness: float | None = 10.0, + damping: float | None = 2.0, + velocity_limit: float | None = None, + effort_limit: float | None = None, + velocity_limit_sim: float | None = None, + effort_limit_sim: float | None = None, +) -> ArticulationCfg: + """Generate an articulation configuration. + + Args: + articulation_type: Type of articulation to generate. + It should be one of: "humanoid", "panda", "anymal", "shadow_hand", "single_joint_implicit", + "single_joint_explicit". + stiffness: Stiffness value for the articulation's actuators. Only currently used for "humanoid". + Defaults to 10.0. + damping: Damping value for the articulation's actuators. Only currently used for "humanoid". + Defaults to 2.0. + velocity_limit: Velocity limit for the actuators. Only currently used for "single_joint_implicit" + and "single_joint_explicit". + effort_limit: Effort limit for the actuators. Only currently used for "single_joint_implicit" + and "single_joint_explicit". + velocity_limit_sim: Velocity limit for the actuators (set into the simulation). + Only currently used for "single_joint_implicit" and "single_joint_explicit". + effort_limit_sim: Effort limit for the actuators (set into the simulation). + Only currently used for "single_joint_implicit" and "single_joint_explicit". + + Returns: + The articulation configuration for the requested articulation type. + + """ + if articulation_type == "humanoid": + articulation_cfg = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/Humanoid/humanoid_instanceable.usd" + ), + init_state=ArticulationCfg.InitialStateCfg(pos=(0.0, 0.0, 1.34)), + actuators={"body": ImplicitActuatorCfg(joint_names_expr=[".*"], stiffness=stiffness, damping=damping)}, + ) + elif articulation_type == "panda": + articulation_cfg = FRANKA_PANDA_CFG + elif articulation_type == "anymal": + articulation_cfg = ANYMAL_C_CFG + elif articulation_type == "shadow_hand": + articulation_cfg = SHADOW_HAND_CFG + elif articulation_type == "single_joint_implicit": + articulation_cfg = ArticulationCfg( + # we set 80.0 default for max force because default in USD is 10e10 which makes testing annoying. + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/SimpleArticulation/revolute_articulation.usd", + joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_effort=80.0, max_velocity=5.0), + ), + actuators={ + "joint": ImplicitActuatorCfg( + joint_names_expr=[".*"], + effort_limit_sim=effort_limit_sim, + velocity_limit_sim=velocity_limit_sim, + effort_limit=effort_limit, + velocity_limit=velocity_limit, + stiffness=2000.0, + damping=100.0, + ), + }, + init_state=ArticulationCfg.InitialStateCfg( + pos=(0.0, 0.0, 0.0), + joint_pos=({"RevoluteJoint": 1.5708}), + rot=(0.7071081, 0, 0, 0.7071055), + ), + ) + elif articulation_type == "single_joint_explicit": + # we set 80.0 default for max force because default in USD is 10e10 which makes testing annoying. + articulation_cfg = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/SimpleArticulation/revolute_articulation.usd", + joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_effort=80.0, max_velocity=5.0), + ), + actuators={ + "joint": IdealPDActuatorCfg( + joint_names_expr=[".*"], + effort_limit_sim=effort_limit_sim, + velocity_limit_sim=velocity_limit_sim, + effort_limit=effort_limit, + velocity_limit=velocity_limit, + stiffness=0.0, + damping=10.0, + ), + }, + ) + elif articulation_type == "spatial_tendon_test_asset": + # we set 80.0 default for max force because default in USD is 10e10 which makes testing annoying. + articulation_cfg = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/IsaacLab/Tests/spatial_tendons.usd", + ), + actuators={ + "joint": ImplicitActuatorCfg( + joint_names_expr=[".*"], + stiffness=2000.0, + damping=100.0, + ), + }, + ) + else: + raise ValueError( + f"Invalid articulation type: {articulation_type}, valid options are 'humanoid', 'panda', 'anymal'," + " 'shadow_hand', 'single_joint_implicit', 'single_joint_explicit' or 'spatial_tendon_test_asset'." + ) + + return articulation_cfg + + +def generate_articulation( + articulation_cfg: ArticulationCfg, num_articulations: int, device: str +) -> tuple[Articulation, torch.tensor]: + """Generate an articulation from a configuration. + + Handles the creation of the articulation, the environment prims and the articulation's environment + translations + + Args: + articulation_cfg: Articulation configuration. + num_articulations: Number of articulations to generate. + device: Device to use for the tensors. + + Returns: + The articulation and environment translations. + + """ + # Generate translations of 2.5 m in x for each articulation + translations = torch.zeros(num_articulations, 3, device=device) + translations[:, 0] = torch.arange(num_articulations) * 2.5 + + # Create Top-level Xforms, one for each articulation + for i in range(num_articulations): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=translations[i][:3]) + articulation = Articulation(articulation_cfg.replace(prim_path="/World/Env_.*/Robot")) + + return articulation, translations + + +@pytest.fixture +def sim(request): + """Create simulation context with the specified device.""" + device = request.getfixturevalue("device") + if "gravity_enabled" in request.fixturenames: + gravity_enabled = request.getfixturevalue("gravity_enabled") + else: + gravity_enabled = True # default to gravity enabled + if "add_ground_plane" in request.fixturenames: + add_ground_plane = request.getfixturevalue("add_ground_plane") + else: + add_ground_plane = False # default to no ground plane + with _ovphysx_sim_context( + device=device, auto_add_lighting=True, gravity_enabled=gravity_enabled, add_ground_plane=add_ground_plane + ) as sim: + sim._app_control_on_stop_handle = None + yield sim + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.isaacsim_ci +def test_initialization_floating_base_non_root(sim, num_articulations, device, add_ground_plane): + """Test initialization for a floating-base with articulation root on a rigid body. + + This test verifies that: + 1. The articulation is properly initialized + 2. The articulation is not fixed base + 3. All buffers have correct shapes + 4. The articulation can be simulated + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + device: The device to run the simulation on + """ + articulation_cfg = generate_articulation_cfg(articulation_type="humanoid", stiffness=0.0, damping=0.0) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + sim.reset() + + # Check if articulation is initialized + assert articulation.is_initialized + # Check that is fixed base + assert not articulation.is_fixed_base + # Check buffers that exists and have correct shapes + assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) + assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) + assert articulation.data.joint_pos.torch.shape == (num_articulations, 21) + + # Cross-check binding shapes against cached counts. PhysX does this via + # ``root_view.max_dofs == shared_metatype.dof_count``; on OVPhysX + # ``root_view`` is the per-tensor-type bindings dict, so the equivalent + # invariant is that each per-DOF / per-link binding's shape agrees with + # the count cached on the asset. + for tt in (TT.DOF_POSITION, TT.DOF_VELOCITY, TT.DOF_STIFFNESS): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_joints + for tt in (TT.BODY_MASS, TT.BODY_COM_POSE): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_bodies + # Body-name ordering check is degenerate on OVPhysX: ``body_names`` is + # sourced from binding metadata (``sample.body_names``), so the PhysX + # ``link_paths[0]`` round-trip is a no-op here and is omitted. + # -- actuator type + for actuator_name, actuator in articulation.actuators.items(): + is_implicit_model_cfg = isinstance(articulation_cfg.actuators[actuator_name], ImplicitActuatorCfg) + assert actuator.is_implicit_model == is_implicit_model_cfg + + # Simulate physics + for _ in range(10): + # perform rendering + sim.step() + # update articulation + articulation.update(sim.cfg.dt) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.isaacsim_ci +def test_initialization_floating_base(sim, num_articulations, device, add_ground_plane): + """Test initialization for a floating-base with articulation root on provided prim path. + + This test verifies that: + 1. The articulation is properly initialized + 2. The articulation is not fixed base + 3. All buffers have correct shapes + 4. The articulation can be simulated + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + device: The device to run the simulation on + """ + articulation_cfg = generate_articulation_cfg(articulation_type="anymal", stiffness=0.0, damping=0.0) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + sim.reset() + # Check if articulation is initialized + assert articulation.is_initialized + # Check that floating base + assert not articulation.is_fixed_base + # Check buffers that exists and have correct shapes + assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) + assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) + assert articulation.data.joint_pos.torch.shape == (num_articulations, 12) + assert articulation.data.body_mass.torch.shape == (num_articulations, articulation.num_bodies) + assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) + + # Cross-check binding shapes against cached counts. PhysX does this via + # ``root_view.max_dofs == shared_metatype.dof_count``; on OVPhysX + # ``root_view`` is the per-tensor-type bindings dict, so the equivalent + # invariant is that each per-DOF / per-link binding's shape agrees with + # the count cached on the asset. + for tt in (TT.DOF_POSITION, TT.DOF_VELOCITY, TT.DOF_STIFFNESS): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_joints + for tt in (TT.BODY_MASS, TT.BODY_COM_POSE): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_bodies + # Body-name ordering check is degenerate on OVPhysX: ``body_names`` is + # sourced from binding metadata (``sample.body_names``), so the PhysX + # ``link_paths[0]`` round-trip is a no-op here and is omitted. + # -- actuator type + for actuator_name, actuator in articulation.actuators.items(): + is_implicit_model_cfg = isinstance(articulation_cfg.actuators[actuator_name], ImplicitActuatorCfg) + assert actuator.is_implicit_model == is_implicit_model_cfg + + # Simulate physics + for _ in range(10): + # perform rendering + sim.step() + # update articulation + articulation.update(sim.cfg.dt) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_initialization_fixed_base(sim, num_articulations, device): + """Test initialization for fixed base. + + This test verifies that: + 1. The articulation is properly initialized + 2. The articulation is fixed base + 3. All buffers have correct shapes + 4. The articulation maintains its default state + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + device: The device to run the simulation on + """ + articulation_cfg = generate_articulation_cfg(articulation_type="panda") + articulation, translations = generate_articulation(articulation_cfg, num_articulations, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + sim.reset() + # Check if articulation is initialized + assert articulation.is_initialized + # Check that fixed base + assert articulation.is_fixed_base + # Check buffers that exists and have correct shapes + assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) + assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) + assert articulation.data.joint_pos.torch.shape == (num_articulations, 9) + assert articulation.data.body_mass.torch.shape == (num_articulations, articulation.num_bodies) + assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) + + # Cross-check binding shapes against cached counts. PhysX does this via + # ``root_view.max_dofs == shared_metatype.dof_count``; on OVPhysX + # ``root_view`` is the per-tensor-type bindings dict, so the equivalent + # invariant is that each per-DOF / per-link binding's shape agrees with + # the count cached on the asset. + for tt in (TT.DOF_POSITION, TT.DOF_VELOCITY, TT.DOF_STIFFNESS): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_joints + for tt in (TT.BODY_MASS, TT.BODY_COM_POSE): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_bodies + # Body-name ordering check is degenerate on OVPhysX: ``body_names`` is + # sourced from binding metadata (``sample.body_names``), so the PhysX + # ``link_paths[0]`` round-trip is a no-op here and is omitted. + # -- actuator type + for actuator_name, actuator in articulation.actuators.items(): + is_implicit_model_cfg = isinstance(articulation_cfg.actuators[actuator_name], ImplicitActuatorCfg) + assert actuator.is_implicit_model == is_implicit_model_cfg + + # Simulate physics + for _ in range(10): + # perform rendering + sim.step() + # update articulation + articulation.update(sim.cfg.dt) + + # check that the root is at the correct state - its default state as it is fixed base + default_root_pose = articulation.data.default_root_pose.torch.clone() + default_root_vel = articulation.data.default_root_vel.torch.clone() + default_root_pose[:, :3] = default_root_pose[:, :3] + translations + + torch.testing.assert_close(articulation.data.root_link_pose_w.torch, default_root_pose) + torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.isaacsim_ci +def test_initialization_fixed_base_single_joint(sim, num_articulations, device, add_ground_plane): + """Test initialization for fixed base articulation with a single joint. + + This test verifies that: + 1. The articulation is properly initialized + 2. The articulation is fixed base + 3. All buffers have correct shapes + 4. The articulation maintains its default state + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + device: The device to run the simulation on + """ + articulation_cfg = generate_articulation_cfg(articulation_type="single_joint_implicit") + articulation, translations = generate_articulation(articulation_cfg, num_articulations, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + sim.reset() + # Check if articulation is initialized + assert articulation.is_initialized + # Check that fixed base + assert articulation.is_fixed_base + # Check buffers that exists and have correct shapes + assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) + assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) + assert articulation.data.joint_pos.torch.shape == (num_articulations, 1) + assert articulation.data.body_mass.torch.shape == (num_articulations, articulation.num_bodies) + assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) + + # Cross-check binding shapes against cached counts. PhysX does this via + # ``root_view.max_dofs == shared_metatype.dof_count``; on OVPhysX + # ``root_view`` is the per-tensor-type bindings dict, so the equivalent + # invariant is that each per-DOF / per-link binding's shape agrees with + # the count cached on the asset. + for tt in (TT.DOF_POSITION, TT.DOF_VELOCITY, TT.DOF_STIFFNESS): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_joints + for tt in (TT.BODY_MASS, TT.BODY_COM_POSE): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_bodies + # Body-name ordering check is degenerate on OVPhysX: ``body_names`` is + # sourced from binding metadata (``sample.body_names``), so the PhysX + # ``link_paths[0]`` round-trip is a no-op here and is omitted. + # -- actuator type + for actuator_name, actuator in articulation.actuators.items(): + is_implicit_model_cfg = isinstance(articulation_cfg.actuators[actuator_name], ImplicitActuatorCfg) + assert actuator.is_implicit_model == is_implicit_model_cfg + + # Simulate physics + for _ in range(10): + # perform rendering + sim.step() + # update articulation + articulation.update(sim.cfg.dt) + + # check that the root is at the correct state - its default state as it is fixed base + default_root_pose = articulation.data.default_root_pose.torch.clone() + default_root_vel = articulation.data.default_root_vel.torch.clone() + default_root_pose[:, :3] = default_root_pose[:, :3] + translations + + torch.testing.assert_close(articulation.data.root_link_pose_w.torch, default_root_pose) + torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_initialization_hand_with_tendons(sim, num_articulations, device): + """Test initialization for fixed base articulated hand with tendons. + + This test verifies that: + 1. The articulation is properly initialized + 2. The articulation is fixed base + 3. All buffers have correct shapes + 4. The articulation can be simulated + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + device: The device to run the simulation on + """ + articulation_cfg = generate_articulation_cfg(articulation_type="shadow_hand") + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + sim.reset() + # Check if articulation is initialized + assert articulation.is_initialized + # Check that fixed base + assert articulation.is_fixed_base + # Check buffers that exists and have correct shapes + assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) + assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) + assert articulation.data.joint_pos.torch.shape == (num_articulations, 24) + assert articulation.data.body_mass.torch.shape == (num_articulations, articulation.num_bodies) + assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) + + # Cross-check binding shapes against cached counts. See the equivalent + # block in test_initialization_fixed_base_single_joint for why the verbatim + # PhysX ``root_view.max_dofs == shared_metatype.dof_count`` identity is + # replaced with binding-shape checks on OVPhysX. + for tt in (TT.DOF_POSITION, TT.DOF_VELOCITY, TT.DOF_STIFFNESS): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_joints + for tt in (TT.BODY_MASS, TT.BODY_COM_POSE): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_bodies + # -- actuator type + for actuator_name, actuator in articulation.actuators.items(): + is_implicit_model_cfg = isinstance(articulation_cfg.actuators[actuator_name], ImplicitActuatorCfg) + assert actuator.is_implicit_model == is_implicit_model_cfg + + # Simulate physics + for _ in range(10): + # perform rendering + sim.step() + # update articulation + articulation.update(sim.cfg.dt) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.isaacsim_ci +@pytest.mark.xfail(reason=_OMNI_PHYSX_SCHEMAS_GAP_REASON, strict=False) +def test_initialization_floating_base_made_fixed_base(sim, num_articulations, device, add_ground_plane): + """Test initialization for a floating-base articulation made fixed-base using schema properties. + + This test verifies that: + 1. The articulation is properly initialized + 2. The articulation is fixed base after modification + 3. All buffers have correct shapes + 4. The articulation maintains its default state + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + articulation_cfg = generate_articulation_cfg(articulation_type="anymal").copy() + # Fix root link by making it kinematic + articulation_cfg.spawn.articulation_props.fix_root_link = True + articulation, translations = generate_articulation(articulation_cfg, num_articulations, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + sim.reset() + # Check if articulation is initialized + assert articulation.is_initialized + # Check that is fixed base + assert articulation.is_fixed_base + # Check buffers that exists and have correct shapes + assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) + assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) + assert articulation.data.joint_pos.torch.shape == (num_articulations, 12) + + # Cross-check binding shapes against cached counts. PhysX does this via + # ``root_view.max_dofs == shared_metatype.dof_count``; on OVPhysX + # ``root_view`` is the per-tensor-type bindings dict, so the equivalent + # invariant is that each per-DOF / per-link binding's shape agrees with + # the count cached on the asset. + for tt in (TT.DOF_POSITION, TT.DOF_VELOCITY, TT.DOF_STIFFNESS): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_joints + for tt in (TT.BODY_MASS, TT.BODY_COM_POSE): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_bodies + # Body-name ordering check is degenerate on OVPhysX: ``body_names`` is + # sourced from binding metadata (``sample.body_names``), so the PhysX + # ``link_paths[0]`` round-trip is a no-op here and is omitted. + + # Simulate physics + for _ in range(10): + # perform rendering + sim.step() + # update articulation + articulation.update(sim.cfg.dt) + + # check that the root is at the correct state - its default state as it is fixed base + default_root_pose = articulation.data.default_root_pose.torch.clone() + default_root_vel = articulation.data.default_root_vel.torch.clone() + default_root_pose[:, :3] = default_root_pose[:, :3] + translations + + torch.testing.assert_close(articulation.data.root_link_pose_w.torch, default_root_pose) + torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.isaacsim_ci +def test_initialization_fixed_base_made_floating_base(sim, num_articulations, device, add_ground_plane): + """Test initialization for fixed base made floating-base using schema properties. + + This test verifies that: + 1. The articulation is properly initialized + 2. The articulation is floating base after modification + 3. All buffers have correct shapes + 4. The articulation can be simulated + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + articulation_cfg = generate_articulation_cfg(articulation_type="panda") + # Unfix root link by making it non-kinematic + articulation_cfg.spawn.articulation_props.fix_root_link = False + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + sim.reset() + # Check if articulation is initialized + assert articulation.is_initialized + # Check that is floating base + assert not articulation.is_fixed_base + # Check buffers that exists and have correct shapes + assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) + assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) + assert articulation.data.joint_pos.torch.shape == (num_articulations, 9) + + # Cross-check binding shapes against cached counts. PhysX does this via + # ``root_view.max_dofs == shared_metatype.dof_count``; on OVPhysX + # ``root_view`` is the per-tensor-type bindings dict, so the equivalent + # invariant is that each per-DOF / per-link binding's shape agrees with + # the count cached on the asset. + for tt in (TT.DOF_POSITION, TT.DOF_VELOCITY, TT.DOF_STIFFNESS): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_joints + for tt in (TT.BODY_MASS, TT.BODY_COM_POSE): + if tt in articulation.root_view: + assert articulation.root_view[tt].shape[1] == articulation.num_bodies + # Body-name ordering check is degenerate on OVPhysX: ``body_names`` is + # sourced from binding metadata (``sample.body_names``), so the PhysX + # ``link_paths[0]`` round-trip is a no-op here and is omitted. + + # Simulate physics + for _ in range(10): + # perform rendering + sim.step() + # update articulation + articulation.update(sim.cfg.dt) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.isaacsim_ci +def test_out_of_range_default_joint_pos(sim, num_articulations, device, add_ground_plane): + """Test that the default joint position from configuration is out of range. + + This test verifies that: + 1. The articulation fails to initialize when joint positions are out of range + 2. The error is properly handled + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + # Create articulation + articulation_cfg = generate_articulation_cfg(articulation_type="panda").copy() + articulation_cfg.init_state.joint_pos = { + "panda_joint1": 10.0, + "panda_joint[2, 4]": -20.0, + } + + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + with pytest.raises(ValueError): + sim.reset() + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_out_of_range_default_joint_vel(sim, device): + """Test that the default joint velocity from configuration is out of range. + + This test verifies that: + 1. The articulation fails to initialize when joint velocities are out of range + 2. The error is properly handled + """ + articulation_cfg = FRANKA_PANDA_CFG.replace(prim_path="/World/Robot") + articulation_cfg.init_state.joint_vel = { + "panda_joint1": 100.0, + "panda_joint[2, 4]": -60.0, + } + articulation = Articulation(articulation_cfg) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + with pytest.raises(ValueError): + sim.reset() + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.isaacsim_ci +def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane): + """Test write_joint_limits_to_sim API and when default pos falls outside of the new limits. + + This test verifies that: + 1. Joint limits can be set correctly + 2. Default positions are preserved when setting new limits + 3. Joint limits can be set with indexing + 4. Invalid joint positions are properly handled + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + # Create articulation + articulation_cfg = generate_articulation_cfg(articulation_type="panda") + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device) + + # Play sim + sim.reset() + # Check if articulation is initialized + assert articulation.is_initialized + + # Get current default joint pos + default_joint_pos = articulation._data.default_joint_pos.torch.clone() + + # Set new joint limits + limits = torch.zeros(num_articulations, articulation.num_joints, 2, device=device) + limits[..., 0] = (torch.rand(num_articulations, articulation.num_joints, device=device) + 5.0) * -1.0 + limits[..., 1] = torch.rand(num_articulations, articulation.num_joints, device=device) + 5.0 + articulation.write_joint_position_limit_to_sim_index(limits=limits) + + # Check new limits are in place + torch.testing.assert_close(articulation._data.joint_pos_limits.torch, limits) + torch.testing.assert_close(articulation._data.default_joint_pos.torch, default_joint_pos) + + # Set new joint limits with indexing + env_ids = torch.arange(1, device=device, dtype=torch.int32) + joint_ids = torch.arange(2, device=device, dtype=torch.int32) + limits = torch.zeros(env_ids.shape[0], joint_ids.shape[0], 2, device=device) + limits[..., 0] = (torch.rand(env_ids.shape[0], joint_ids.shape[0], device=device) + 5.0) * -1.0 + limits[..., 1] = torch.rand(env_ids.shape[0], joint_ids.shape[0], device=device) + 5.0 + articulation.write_joint_position_limit_to_sim_index(limits=limits, env_ids=env_ids, joint_ids=joint_ids) + + # Check new limits are in place + torch.testing.assert_close(articulation._data.joint_pos_limits.torch[env_ids][:, joint_ids], limits) + torch.testing.assert_close(articulation._data.default_joint_pos.torch, default_joint_pos) + + # Set new joint limits that invalidate default joint pos + limits = torch.zeros(num_articulations, articulation.num_joints, 2, device=device) + limits[..., 0] = torch.rand(num_articulations, articulation.num_joints, device=device) * -0.1 + limits[..., 1] = torch.rand(num_articulations, articulation.num_joints, device=device) * 0.1 + articulation.write_joint_position_limit_to_sim_index(limits=limits) + + # Check if all values are within the bounds + default_joint_pos_torch = articulation._data.default_joint_pos.torch + within_bounds = (default_joint_pos_torch >= limits[..., 0]) & (default_joint_pos_torch <= limits[..., 1]) + assert torch.all(within_bounds) + + # Set new joint limits that invalidate default joint pos with indexing + limits = torch.zeros(env_ids.shape[0], joint_ids.shape[0], 2, device=device) + limits[..., 0] = torch.rand(env_ids.shape[0], joint_ids.shape[0], device=device) * -0.1 + limits[..., 1] = torch.rand(env_ids.shape[0], joint_ids.shape[0], device=device) * 0.1 + articulation.write_joint_position_limit_to_sim_index(limits=limits, env_ids=env_ids, joint_ids=joint_ids) + + # Check if all values are within the bounds + default_joint_pos_torch = articulation._data.default_joint_pos.torch + within_bounds = (default_joint_pos_torch[env_ids][:, joint_ids] >= limits[..., 0]) & ( + default_joint_pos_torch[env_ids][:, joint_ids] <= limits[..., 1] + ) + assert torch.all(within_bounds) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +def test_joint_effort_limits(sim, num_articulations, device, add_ground_plane): + """Validate joint effort limits via joint_effort_out_of_limit().""" + # Create articulation + articulation_cfg = generate_articulation_cfg(articulation_type="panda") + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device) + + # Minimal env wrapper exposing scene["robot"] + class _Env: + def __init__(self, art): + self.scene = {"robot": art} + + env = _Env(articulation) + robot_all = SceneEntityCfg(name="robot") + + sim.reset() + assert articulation.is_initialized + + # Case A: no clipping → should NOT terminate + articulation._data.computed_torque.torch.zero_() + articulation._data.applied_torque.torch.zero_() + out = joint_effort_out_of_limit(env, robot_all) # [N] + assert torch.all(~out) + + # Case B: simulate clipping → should terminate + articulation._data.computed_torque.torch.fill_(100.0) # pretend controller commanded 100 + articulation._data.applied_torque.torch.fill_(50.0) # pretend actuator clipped to 50 + out = joint_effort_out_of_limit(env, robot_all) # [N] + assert torch.all(out) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_external_force_buffer(sim, num_articulations, device): + """Test if external force buffer correctly updates in the force value is zero case. + + This test verifies that: + 1. External forces can be applied correctly + 2. Force buffers are updated properly + 3. Zero forces are handled correctly + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + articulation_cfg = generate_articulation_cfg(articulation_type="anymal") + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) + + # play the simulator + sim.reset() + + # find bodies to apply the force + body_ids, _ = articulation.find_bodies("base") + + # reset root state + articulation.write_root_pose_to_sim_index(root_pose=articulation.data.default_root_pose.torch.clone()) + articulation.write_root_velocity_to_sim_index(root_velocity=articulation.data.default_root_vel.torch.clone()) + + # reset dof state + joint_pos, joint_vel = ( + articulation.data.default_joint_pos.torch, + articulation.data.default_joint_vel.torch, + ) + articulation.write_joint_position_to_sim_index(position=joint_pos) + articulation.write_joint_velocity_to_sim_index(velocity=joint_vel) + + # reset articulation + articulation.reset() + + # perform simulation + for step in range(5): + # initiate force tensor + external_wrench_b = torch.zeros(articulation.num_instances, len(body_ids), 6, device=sim.device) + + if step == 0 or step == 3: + # set a non-zero force + force = 1 + else: + # set a zero force + force = 0 + + # set force value + external_wrench_b[:, :, 0] = force + external_wrench_b[:, :, 3] = force + + # apply force + articulation.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + body_ids=body_ids, + ) + + # check if the articulation's force and torque buffers are correctly updated + for i in range(num_articulations): + assert articulation.permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force + assert articulation.permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force + + # Check if the instantaneous wrench is correctly added to the permanent wrench + articulation.instantaneous_wrench_composer.add_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + body_ids=body_ids, + ) + + # apply action to the articulation + articulation.set_joint_position_target_index(target=articulation.data.default_joint_pos.torch.clone()) + articulation.write_data_to_sim() + + # perform step + sim.step() + + # update buffers + articulation.update(sim.cfg.dt) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_external_force_on_single_body(sim, num_articulations, device): + """Test application of external force on the base of the articulation. + + This test verifies that: + 1. External forces can be applied to specific bodies + 2. The forces affect the articulation's motion correctly + 3. The articulation responds to the forces as expected + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + articulation_cfg = generate_articulation_cfg(articulation_type="anymal") + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) + # Play the simulator + sim.reset() + + # Find bodies to apply the force + body_ids, _ = articulation.find_bodies("base") + # Sample a large force + external_wrench_b = torch.zeros(articulation.num_instances, len(body_ids), 6, device=sim.device) + external_wrench_b[..., 1] = 1000.0 + + # Now we are ready! + for _ in range(5): + # reset root state + articulation.write_root_pose_to_sim_index(root_pose=articulation.data.default_root_pose.torch.clone()) + articulation.write_root_velocity_to_sim_index(root_velocity=articulation.data.default_root_vel.torch.clone()) + # reset dof state + joint_pos, joint_vel = ( + articulation.data.default_joint_pos.torch, + articulation.data.default_joint_vel.torch, + ) + articulation.write_joint_position_to_sim_index(position=joint_pos) + articulation.write_joint_velocity_to_sim_index(velocity=joint_vel) + # reset articulation + articulation.reset() + # apply force + articulation.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], torques=external_wrench_b[..., 3:], body_ids=body_ids + ) + # perform simulation + for _ in range(100): + # apply action to the articulation + articulation.set_joint_position_target_index(target=articulation.data.default_joint_pos.torch.clone()) + articulation.write_data_to_sim() + # perform step + sim.step() + # update buffers + articulation.update(sim.cfg.dt) + # check condition that the articulations have fallen down + for i in range(num_articulations): + assert articulation.data.root_pos_w.torch[i, 2].item() < 0.2 + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_external_force_on_single_body_at_position(sim, num_articulations, device): + """Test application of external force on the base of the articulation at a given position. + + This test verifies that: + 1. External forces can be applied to specific bodies at a given position + 2. External forces can be applied to specific bodies in the global frame + 3. External forces are calculated and composed correctly + 4. The forces affect the articulation's motion correctly + 5. The articulation responds to the forces as expected + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + articulation_cfg = generate_articulation_cfg(articulation_type="anymal") + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) + # Play the simulator + sim.reset() + + # Find bodies to apply the force + body_ids, _ = articulation.find_bodies("base") + # Sample a large force + external_wrench_b = torch.zeros(articulation.num_instances, len(body_ids), 6, device=sim.device) + external_wrench_b[..., 2] = 500.0 + external_wrench_positions_b = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) + external_wrench_positions_b[..., 1] = 1.0 + + desired_force = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) + desired_force[..., 2] = 1000.0 + desired_torque = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) + desired_torque[..., 0] = 1000.0 + + # Now we are ready! + for i in range(5): + # reset root state + root_pose = articulation.data.default_root_pose.torch.clone() + root_pose[0, 0] = 2.5 # space them apart by 2.5m + + articulation.write_root_pose_to_sim_index(root_pose=root_pose) + articulation.write_root_velocity_to_sim_index(root_velocity=articulation.data.default_root_vel.torch.clone()) + # reset dof state + joint_pos, joint_vel = ( + articulation.data.default_joint_pos.torch, + articulation.data.default_joint_vel.torch, + ) + articulation.write_joint_position_to_sim_index(position=joint_pos) + articulation.write_joint_velocity_to_sim_index(velocity=joint_vel) + # reset articulation + articulation.reset() + # apply force + is_global = False + + if i % 2 == 0: + body_com_pos_w = articulation.data.body_com_pos_w.torch[:, body_ids, :3] + # is_global = True + external_wrench_positions_b[..., 0] = 0.0 + external_wrench_positions_b[..., 1] = 1.0 + external_wrench_positions_b[..., 2] = 0.0 + external_wrench_positions_b += body_com_pos_w + else: + external_wrench_positions_b[..., 0] = 0.0 + external_wrench_positions_b[..., 1] = 1.0 + external_wrench_positions_b[..., 2] = 0.0 + + articulation.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + positions=external_wrench_positions_b, + body_ids=body_ids, + is_global=is_global, + ) + articulation.permanent_wrench_composer.add_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + positions=external_wrench_positions_b, + body_ids=body_ids, + is_global=is_global, + ) + # perform simulation + for _ in range(100): + # apply action to the articulation + articulation.set_joint_position_target_index(target=articulation.data.default_joint_pos.torch.clone()) + articulation.write_data_to_sim() + # perform step + sim.step() + # update buffers + articulation.update(sim.cfg.dt) + # check condition that the articulations have fallen down + for i in range(num_articulations): + assert articulation.data.root_pos_w.torch[i, 2].item() < 0.2 + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_external_force_on_multiple_bodies(sim, num_articulations, device): + """Test application of external force on the legs of the articulation. + + This test verifies that: + 1. External forces can be applied to multiple bodies + 2. The forces affect the articulation's motion correctly + 3. The articulation responds to the forces as expected + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + articulation_cfg = generate_articulation_cfg(articulation_type="anymal") + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) + + # Play the simulator + sim.reset() + + # Find bodies to apply the force + body_ids, _ = articulation.find_bodies(".*_SHANK") + # Sample a large force + external_wrench_b = torch.zeros(articulation.num_instances, len(body_ids), 6, device=sim.device) + external_wrench_b[..., 1] = 100.0 + + # Now we are ready! + for _ in range(5): + # reset root state + articulation.write_root_pose_to_sim_index(root_pose=articulation.data.default_root_pose.torch.clone()) + articulation.write_root_velocity_to_sim_index(root_velocity=articulation.data.default_root_vel.torch.clone()) + # reset dof state + joint_pos, joint_vel = ( + articulation.data.default_joint_pos.torch, + articulation.data.default_joint_vel.torch, + ) + articulation.write_joint_position_to_sim_index(position=joint_pos) + articulation.write_joint_velocity_to_sim_index(velocity=joint_vel) + # reset articulation + articulation.reset() + # apply force + articulation.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], torques=external_wrench_b[..., 3:], body_ids=body_ids + ) + # perform simulation + for _ in range(100): + # apply action to the articulation + articulation.set_joint_position_target_index(target=articulation.data.default_joint_pos.torch.clone()) + articulation.write_data_to_sim() + # perform step + sim.step() + # update buffers + articulation.update(sim.cfg.dt) + # check condition + for i in range(num_articulations): + # since there is a moment applied on the articulation, the articulation should rotate + assert articulation.data.root_ang_vel_w.torch[i, 2].item() > 0.1 + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_external_force_on_multiple_bodies_at_position(sim, num_articulations, device): + """Test application of external force on the legs of the articulation at a given position. + + This test verifies that: + 1. External forces can be applied to multiple bodies at a given position + 2. External forces can be applied to multiple bodies in the global frame + 3. External forces are calculated and composed correctly + 4. The forces affect the articulation's motion correctly + 5. The articulation responds to the forces as expected + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + articulation_cfg = generate_articulation_cfg(articulation_type="anymal") + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) + + # Play the simulator + sim.reset() + + # Find bodies to apply the force + body_ids, _ = articulation.find_bodies(".*_SHANK") + # Sample a large force + external_wrench_b = torch.zeros(articulation.num_instances, len(body_ids), 6, device=sim.device) + external_wrench_b[..., 2] = 500.0 + external_wrench_positions_b = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) + external_wrench_positions_b[..., 1] = 1.0 + + desired_force = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) + desired_force[..., 2] = 1000.0 + desired_torque = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) + desired_torque[..., 0] = 1000.0 + + # Now we are ready! + for i in range(5): + # reset root state + articulation.write_root_pose_to_sim_index(root_pose=articulation.data.default_root_pose.torch.clone()) + articulation.write_root_velocity_to_sim_index(root_velocity=articulation.data.default_root_vel.torch.clone()) + # reset dof state + joint_pos, joint_vel = ( + articulation.data.default_joint_pos.torch, + articulation.data.default_joint_vel.torch, + ) + articulation.write_joint_position_to_sim_index(position=joint_pos) + articulation.write_joint_velocity_to_sim_index(velocity=joint_vel) + # reset articulation + articulation.reset() + + is_global = False + if i % 2 == 0: + body_com_pos_w = articulation.data.body_com_pos_w.torch[:, body_ids, :3] + is_global = True + external_wrench_positions_b[..., 0] = 0.0 + external_wrench_positions_b[..., 1] = 1.0 + external_wrench_positions_b[..., 2] = 0.0 + external_wrench_positions_b += body_com_pos_w + else: + external_wrench_positions_b[..., 0] = 0.0 + external_wrench_positions_b[..., 1] = 1.0 + external_wrench_positions_b[..., 2] = 0.0 + + # apply force + articulation.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + positions=external_wrench_positions_b, + body_ids=body_ids, + is_global=is_global, + ) + articulation.permanent_wrench_composer.add_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + positions=external_wrench_positions_b, + body_ids=body_ids, + is_global=is_global, + ) + # perform simulation + for _ in range(100): + # apply action to the articulation + articulation.set_joint_position_target_index(target=articulation.data.default_joint_pos.torch.clone()) + articulation.write_data_to_sim() + # perform step + sim.step() + # update buffers + articulation.update(sim.cfg.dt) + # check condition + for i in range(num_articulations): + # since there is a moment applied on the articulation, the articulation should rotate + assert torch.abs(articulation.data.root_ang_vel_w.torch[i, 2]).item() > 0.1 + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_loading_gains_from_usd(sim, num_articulations, device): + """Test that gains are loaded from USD file if actuator model has them as None. + + This test verifies that: + 1. Gains are loaded correctly from USD file + 2. Default gains are applied when not specified + 3. The gains match the expected values + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + articulation_cfg = generate_articulation_cfg(articulation_type="humanoid", stiffness=None, damping=None) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) + + # Play sim + sim.reset() + + # Expected gains + # -- Stiffness values + expected_stiffness = { + ".*_waist.*": 20.0, + ".*_upper_arm.*": 10.0, + "pelvis": 10.0, + ".*_lower_arm": 2.0, + ".*_thigh:0": 10.0, + ".*_thigh:1": 20.0, + ".*_thigh:2": 10.0, + ".*_shin": 5.0, + ".*_foot.*": 2.0, + } + indices_list, _, values_list = string_utils.resolve_matching_names_values( + expected_stiffness, articulation.joint_names + ) + expected_stiffness = torch.zeros(articulation.num_instances, articulation.num_joints, device=articulation.device) + expected_stiffness[:, indices_list] = torch.tensor(values_list, device=articulation.device) + # -- Damping values + expected_damping = { + ".*_waist.*": 5.0, + ".*_upper_arm.*": 5.0, + "pelvis": 5.0, + ".*_lower_arm": 1.0, + ".*_thigh:0": 5.0, + ".*_thigh:1": 5.0, + ".*_thigh:2": 5.0, + ".*_shin": 0.1, + ".*_foot.*": 1.0, + } + indices_list, _, values_list = string_utils.resolve_matching_names_values( + expected_damping, articulation.joint_names + ) + expected_damping = torch.zeros_like(expected_stiffness) + expected_damping[:, indices_list] = torch.tensor(values_list, device=articulation.device) + + # Check that gains are loaded from USD file + torch.testing.assert_close(articulation.actuators["body"].stiffness, expected_stiffness) + torch.testing.assert_close(articulation.actuators["body"].damping, expected_damping) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.isaacsim_ci +def test_setting_gains_from_cfg(sim, num_articulations, device, add_ground_plane): + """Test that gains are loaded from the configuration correctly. + + This test verifies that: + 1. Gains are loaded correctly from configuration + 2. The gains match the expected values + 3. The gains are applied correctly to the actuators + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + articulation_cfg = generate_articulation_cfg(articulation_type="humanoid") + articulation, _ = generate_articulation( + articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=sim.device + ) + + # Play sim + sim.reset() + + # Expected gains + expected_stiffness = torch.full( + (articulation.num_instances, articulation.num_joints), 10.0, device=articulation.device + ) + expected_damping = torch.full_like(expected_stiffness, 2.0) + + # Check that gains are loaded from USD file + torch.testing.assert_close(articulation.actuators["body"].stiffness, expected_stiffness) + torch.testing.assert_close(articulation.actuators["body"].damping, expected_damping) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_setting_gains_from_cfg_dict(sim, num_articulations, device): + """Test that gains are loaded from the configuration dictionary correctly. + + This test verifies that: + 1. Gains are loaded correctly from configuration dictionary + 2. The gains match the expected values + 3. The gains are applied correctly to the actuators + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + """ + articulation_cfg = generate_articulation_cfg(articulation_type="humanoid") + articulation, _ = generate_articulation( + articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=sim.device + ) + # Play sim + sim.reset() + + # Expected gains + expected_stiffness = torch.full( + (articulation.num_instances, articulation.num_joints), 10.0, device=articulation.device + ) + expected_damping = torch.full_like(expected_stiffness, 2.0) + + # Check that gains are loaded from USD file + torch.testing.assert_close(articulation.actuators["body"].stiffness, expected_stiffness) + torch.testing.assert_close(articulation.actuators["body"].damping, expected_damping) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("vel_limit_sim", [1e5, None]) +@pytest.mark.parametrize("vel_limit", [1e2, None]) +@pytest.mark.parametrize("add_ground_plane", [False]) +@pytest.mark.isaacsim_ci +def test_setting_velocity_limit_implicit(sim, num_articulations, device, vel_limit_sim, vel_limit, add_ground_plane): + """Test setting of velocity limit for implicit actuators. + + This test verifies that: + 1. Velocity limits can be set correctly for implicit actuators + 2. The limits are applied correctly to the simulation + 3. The limits are handled correctly when both sim and non-sim limits are set + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + device: The device to run the simulation on + vel_limit_sim: The velocity limit to set in simulation + vel_limit: The velocity limit to set in actuator + """ + # create simulation + articulation_cfg = generate_articulation_cfg( + articulation_type="single_joint_implicit", + velocity_limit_sim=vel_limit_sim, + velocity_limit=vel_limit, + ) + articulation, _ = generate_articulation( + articulation_cfg=articulation_cfg, + num_articulations=num_articulations, + device=device, + ) + # Play sim + if vel_limit_sim is not None and vel_limit is not None: + with pytest.raises(ValueError): + sim.reset() + return + sim.reset() + + # read the values set into the simulation + physx_vel_limit = _read_binding_to_torch(articulation, TT.DOF_MAX_VELOCITY, device) + # check data buffer + torch.testing.assert_close(articulation.data.joint_velocity_limits.torch, physx_vel_limit) + # check actuator has simulation velocity limit + torch.testing.assert_close(articulation.actuators["joint"].velocity_limit_sim, physx_vel_limit) + # check that both values match for velocity limit + torch.testing.assert_close( + articulation.actuators["joint"].velocity_limit_sim, + articulation.actuators["joint"].velocity_limit, + ) + + if vel_limit_sim is None: + # Case 2: both velocity limit and velocity limit sim are not set + # This is the case where the velocity limit keeps its USD default value + # Case 3: velocity limit sim is not set but velocity limit is set + # For backwards compatibility, we do not set velocity limit to simulation + # Thus, both default to USD default value. + limit = articulation_cfg.spawn.joint_drive_props.max_joint_velocity + else: + # Case 4: only velocity limit sim is set + # In this case, the velocity limit is set to the USD value + limit = vel_limit_sim + + # check max velocity is what we set + expected_velocity_limit = torch.full_like(physx_vel_limit, limit) + torch.testing.assert_close(physx_vel_limit, expected_velocity_limit) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("vel_limit_sim", [1e5, None]) +@pytest.mark.parametrize("vel_limit", [1e2, None]) +@pytest.mark.isaacsim_ci +def test_setting_velocity_limit_explicit(sim, num_articulations, device, vel_limit_sim, vel_limit): + """Test setting of velocity limit for explicit actuators.""" + articulation_cfg = generate_articulation_cfg( + articulation_type="single_joint_explicit", + velocity_limit_sim=vel_limit_sim, + velocity_limit=vel_limit, + ) + articulation, _ = generate_articulation( + articulation_cfg=articulation_cfg, + num_articulations=num_articulations, + device=device, + ) + # Play sim + sim.reset() + + # collect limit init values + physx_vel_limit = _read_binding_to_torch(articulation, TT.DOF_MAX_VELOCITY, device) + actuator_vel_limit = articulation.actuators["joint"].velocity_limit + actuator_vel_limit_sim = articulation.actuators["joint"].velocity_limit_sim + + # check data buffer for joint_velocity_limits_sim + torch.testing.assert_close(articulation.data.joint_velocity_limits.torch, physx_vel_limit) + # check actuator velocity_limit_sim is set to physx + torch.testing.assert_close(actuator_vel_limit_sim, physx_vel_limit) + + if vel_limit is not None: + expected_actuator_vel_limit = torch.full( + (articulation.num_instances, articulation.num_joints), + vel_limit, + device=articulation.device, + ) + # check actuator is set + torch.testing.assert_close(actuator_vel_limit, expected_actuator_vel_limit) + # check physx is not velocity_limit + assert not torch.allclose(actuator_vel_limit, physx_vel_limit) + else: + # check actuator velocity_limit is the same as the PhysX default + torch.testing.assert_close(actuator_vel_limit, physx_vel_limit) + + # simulation velocity limit is set to USD value unless user overrides + if vel_limit_sim is not None: + limit = vel_limit_sim + else: + limit = articulation_cfg.spawn.joint_drive_props.max_joint_velocity + # check physx is set to expected value + expected_vel_limit = torch.full_like(physx_vel_limit, limit) + torch.testing.assert_close(physx_vel_limit, expected_vel_limit) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("effort_limit_sim", [1e5, None]) +@pytest.mark.parametrize("effort_limit", [1e2, 80.0, None]) +@pytest.mark.isaacsim_ci +def test_setting_effort_limit_implicit(sim, num_articulations, device, effort_limit_sim, effort_limit): + """Test setting of effort limit for implicit actuators. + + This test verifies the effort limit resolution logic for actuator models implemented in :class:`ActuatorBase`: + - Case 1: If USD value == actuator config value: values match correctly + - Case 2: If USD value != actuator config value: actuator config value is used + - Case 3: If actuator config value is None: USD value is used as default + """ + articulation_cfg = generate_articulation_cfg( + articulation_type="single_joint_implicit", + effort_limit_sim=effort_limit_sim, + effort_limit=effort_limit, + ) + articulation, _ = generate_articulation( + articulation_cfg=articulation_cfg, + num_articulations=num_articulations, + device=device, + ) + # Play sim + if effort_limit_sim is not None and effort_limit is not None: + with pytest.raises(ValueError): + sim.reset() + return + sim.reset() + + # obtain the physx effort limits + physx_effort_limit = _read_binding_to_torch(articulation, TT.DOF_MAX_FORCE, device) + + # check that the two are equivalent + torch.testing.assert_close( + articulation.actuators["joint"].effort_limit_sim, + articulation.actuators["joint"].effort_limit, + ) + torch.testing.assert_close(articulation.actuators["joint"].effort_limit_sim, physx_effort_limit) + + # decide the limit based on what is set + if effort_limit_sim is None and effort_limit is None: + limit = articulation_cfg.spawn.joint_drive_props.max_force + elif effort_limit_sim is not None and effort_limit is None: + limit = effort_limit_sim + elif effort_limit_sim is None and effort_limit is not None: + limit = effort_limit + + # check that the max force is what we set + expected_effort_limit = torch.full_like(physx_effort_limit, limit) + torch.testing.assert_close(physx_effort_limit, expected_effort_limit) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("effort_limit_sim", [1e5, None]) +@pytest.mark.parametrize("effort_limit", [80.0, 1e2, None]) +@pytest.mark.isaacsim_ci +def test_setting_effort_limit_explicit(sim, num_articulations, device, effort_limit_sim, effort_limit): + """Test setting of effort limit for explicit actuators. + + This test verifies the effort limit resolution logic for actuator models implemented in :class:`ActuatorBase`: + - Case 1: If USD value == actuator config value: values match correctly + - Case 2: If USD value != actuator config value: actuator config value is used + - Case 3: If actuator config value is None: USD value is used as default + + """ + + articulation_cfg = generate_articulation_cfg( + articulation_type="single_joint_explicit", + effort_limit_sim=effort_limit_sim, + effort_limit=effort_limit, + ) + articulation, _ = generate_articulation( + articulation_cfg=articulation_cfg, + num_articulations=num_articulations, + device=device, + ) + # Play sim + sim.reset() + + # usd default effort limit is set to 80 + usd_default_effort_limit = 80.0 + + # collect limit init values + physx_effort_limit = _read_binding_to_torch(articulation, TT.DOF_MAX_FORCE, device) + actuator_effort_limit = articulation.actuators["joint"].effort_limit + actuator_effort_limit_sim = articulation.actuators["joint"].effort_limit_sim + + # check actuator effort_limit_sim is set to physx + torch.testing.assert_close(actuator_effort_limit_sim, physx_effort_limit) + + if effort_limit is not None: + expected_actuator_effort_limit = torch.full_like(actuator_effort_limit, effort_limit) + # check actuator is set + torch.testing.assert_close(actuator_effort_limit, expected_actuator_effort_limit) + + # check physx effort limit does not match the one explicit actuator has + assert not (torch.allclose(actuator_effort_limit, physx_effort_limit)) + else: + # When effort_limit is None, actuator should use USD default values + expected_actuator_effort_limit = torch.full_like(physx_effort_limit, usd_default_effort_limit) + torch.testing.assert_close(actuator_effort_limit, expected_actuator_effort_limit) + + # when using explicit actuators, the limits are set to high unless user overrides + if effort_limit_sim is not None: + limit = effort_limit_sim + else: + limit = ActuatorBase._DEFAULT_MAX_EFFORT_SIM # type: ignore + # check physx internal value matches the expected sim value + expected_effort_limit = torch.full_like(physx_effort_limit, limit) + torch.testing.assert_close(actuator_effort_limit_sim, expected_effort_limit) + torch.testing.assert_close(physx_effort_limit, expected_effort_limit) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_reset(sim, num_articulations, device): + """Test that reset method works properly.""" + articulation_cfg = generate_articulation_cfg(articulation_type="humanoid") + articulation, _ = generate_articulation( + articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device + ) + + # Play the simulator + sim.reset() + + # Now we are ready! + # reset articulation + articulation.reset() + + # Reset should zero external forces and torques + assert not articulation._instantaneous_wrench_composer.active + assert not articulation._permanent_wrench_composer.active + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_force.torch) == 0 + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_torque.torch) == 0 + assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_force.torch) == 0 + assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_torque.torch) == 0 + + if num_articulations > 1: + num_bodies = articulation.num_bodies + articulation.permanent_wrench_composer.set_forces_and_torques_index( + forces=torch.ones((num_articulations, num_bodies, 3), device=device), + torques=torch.ones((num_articulations, num_bodies, 3), device=device), + ) + articulation.instantaneous_wrench_composer.add_forces_and_torques_index( + forces=torch.ones((num_articulations, num_bodies, 3), device=device), + torques=torch.ones((num_articulations, num_bodies, 3), device=device), + ) + articulation.reset(env_ids=torch.tensor([0], device=device)) + assert articulation._instantaneous_wrench_composer.active + assert articulation._permanent_wrench_composer.active + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_force.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_torque.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_force.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_torque.torch) == num_bodies * 3 + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.isaacsim_ci +def test_apply_joint_command(sim, num_articulations, device, add_ground_plane): + """Test applying of joint position target functions correctly for a robotic arm.""" + articulation_cfg = generate_articulation_cfg(articulation_type="panda") + articulation, _ = generate_articulation( + articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device + ) + + # Play the simulator + sim.reset() + + for _ in range(100): + # perform step + sim.step() + # update buffers + articulation.update(sim.cfg.dt) + + # reset dof state + joint_pos = articulation.data.default_joint_pos.torch.clone() + joint_pos[:, 3] = 0.0 + + # apply action to the articulation + articulation.set_joint_position_target_index(target=joint_pos) + articulation.write_data_to_sim() + + for _ in range(100): + # perform step + sim.step() + # update buffers + articulation.update(sim.cfg.dt) + + # Check that current joint position is not the same as default joint position, meaning + # the articulation moved. We can't check that it reached its desired joint position as the gains + # are not properly tuned + assert not torch.allclose(articulation.data.joint_pos.torch, joint_pos) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("with_offset", [True, False]) +@pytest.mark.isaacsim_ci +def test_body_root_state(sim, num_articulations, device, with_offset): + """Test for reading the `body_state_w` property. + + This test verifies that: + 1. Body states can be read correctly + 2. States are correct with and without offsets + 3. States are consistent across different devices + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + device: The device to run the simulation on + with_offset: Whether to test with offset + """ + sim._app_control_on_stop_handle = None + articulation_cfg = generate_articulation_cfg(articulation_type="single_joint_implicit") + articulation, env_pos = generate_articulation(articulation_cfg, num_articulations, device) + env_idx = torch.tensor([x for x in range(num_articulations)], device=device, dtype=torch.int32) + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10, "Possible reference leak for articulation" + # Play sim + sim.reset() + # Check if articulation is initialized + assert articulation.is_initialized, "Articulation is not initialized" + # Check that fixed base + assert articulation.is_fixed_base, "Articulation is not a fixed base" + + # Resolve body indices by name (ordering may differ across physics backends) + root_idx = articulation.body_names.index("CenterPivot") + arm_idx = articulation.body_names.index("Arm") + + # change center of mass offset from link frame + if with_offset: + offset = [0.5, 0.0, 0.0] + else: + offset = [0.0, 0.0, 0.0] + + # create com offsets — apply offset to the Arm body + num_bodies = articulation.num_bodies + com = _read_binding_to_torch(articulation, TT.BODY_COM_POSE, device) + link_offset = [1.0, 0.0, 0.0] # the offset from CenterPivot to Arm frames + new_com = torch.tensor(offset, device=device).repeat(num_articulations, 1, 1) + com[:, arm_idx, :3] = new_com.squeeze(-2) + # PhysX uses ``root_view.set_coms``; OVPhysX wraps the wheel + # ``BODY_COM_POSE`` write in :meth:`set_coms_index` (wp.transformf contract). + articulation.set_coms_index( + coms=wp.from_torch(com.contiguous(), dtype=wp.transformf), + env_ids=wp.from_torch(env_idx, dtype=wp.int32), + ) + + # check they are set + torch.testing.assert_close(_read_binding_to_torch(articulation, TT.BODY_COM_POSE, device), com) + + for i in range(50): + # perform step + sim.step() + # update buffers + articulation.update(sim.cfg.dt) + + # get state properties + root_link_pose_w = articulation.data.root_link_pose_w.torch + root_link_vel_w = articulation.data.root_link_vel_w.torch + root_com_pose_w = articulation.data.root_com_pose_w.torch + root_com_vel_w = articulation.data.root_com_vel_w.torch + body_link_pose_w = articulation.data.body_link_pose_w.torch + body_link_vel_w = articulation.data.body_link_vel_w.torch + body_com_pose_w = articulation.data.body_com_pose_w.torch + body_com_vel_w = articulation.data.body_com_vel_w.torch + + if with_offset: + # get joint state + joint_pos = articulation.data.joint_pos.torch.unsqueeze(-1) + joint_vel = articulation.data.joint_vel.torch.unsqueeze(-1) + + # LINK state + # angular velocity should be the same for both COM and link frames + torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) + torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) + + # lin_vel arm + lin_vel_gt = torch.zeros(num_articulations, num_bodies, 3, device=device) + vx = -(link_offset[0]) * joint_vel * torch.sin(joint_pos) + vy = torch.zeros(num_articulations, 1, 1, device=device) + vz = (link_offset[0]) * joint_vel * torch.cos(joint_pos) + lin_vel_gt[:, arm_idx, :] = torch.cat([vx, vy, vz], dim=-1).squeeze(-2) + + # linear velocity of root link should be zero + torch.testing.assert_close(lin_vel_gt[:, root_idx, :], root_link_vel_w[..., :3], atol=1e-3, rtol=1e-1) + # linear velocity of pendulum link should be + torch.testing.assert_close(lin_vel_gt, body_link_vel_w[..., :3], atol=1e-3, rtol=1e-1) + + # ang_vel + torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) + torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) + + # COM state + # position and orientation shouldn't match for the _state_com_w but everything else will + pos_gt = torch.zeros(num_articulations, num_bodies, 3, device=device) + px = (link_offset[0] + offset[0]) * torch.cos(joint_pos) + py = torch.zeros(num_articulations, 1, 1, device=device) + pz = (link_offset[0] + offset[0]) * torch.sin(joint_pos) + pos_gt[:, arm_idx, :] = torch.cat([px, py, pz], dim=-1).squeeze(-2) + pos_gt += env_pos.unsqueeze(-2).repeat(1, num_bodies, 1) + torch.testing.assert_close(pos_gt[:, root_idx, :], root_com_pose_w[..., :3], atol=1e-3, rtol=1e-1) + torch.testing.assert_close(pos_gt, body_com_pose_w[..., :3], atol=1e-3, rtol=1e-1) + + # orientation + com_quat_b = articulation.data.body_com_quat_b.torch + com_quat_w = math_utils.quat_mul(body_link_pose_w[..., 3:], com_quat_b) + torch.testing.assert_close(com_quat_w, body_com_pose_w[..., 3:]) + torch.testing.assert_close(com_quat_w[:, root_idx, :], root_com_pose_w[..., 3:]) + + # angular velocity should be the same for both COM and link frames + torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) + torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) + else: + # single joint center of masses are at link frames so they will be the same + torch.testing.assert_close(root_link_pose_w, root_com_pose_w) + torch.testing.assert_close(root_com_vel_w, root_link_vel_w) + torch.testing.assert_close(body_link_pose_w, body_com_pose_w) + torch.testing.assert_close(body_com_vel_w, body_link_vel_w) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("with_offset", [True, False]) +@pytest.mark.parametrize("state_location", ["com", "link"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_write_root_state(sim, num_articulations, device, with_offset, state_location, gravity_enabled): + """Test the setters for root_state using both the link frame and center of mass as reference frame. + + This test verifies that: + 1. Root states can be written correctly + 2. States are correct with and without offsets + 3. States can be written for both COM and link frames + 4. States are consistent across different devices + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + device: The device to run the simulation on + with_offset: Whether to test with offset + state_location: Whether to test COM or link frame + """ + sim._app_control_on_stop_handle = None + articulation_cfg = generate_articulation_cfg(articulation_type="anymal") + articulation, env_pos = generate_articulation(articulation_cfg, num_articulations, device) + env_idx = torch.tensor([x for x in range(num_articulations)], device=device, dtype=torch.int32) + + # Play sim + sim.reset() + + # change center of mass offset from link frame + if with_offset: + offset = torch.tensor([1.0, 0.0, 0.0]).repeat(num_articulations, 1, 1) + else: + offset = torch.tensor([0.0, 0.0, 0.0]).repeat(num_articulations, 1, 1) + + # create com offsets + com = _read_binding_to_torch(articulation, TT.BODY_COM_POSE, device) + new_com = offset.to(device) + com[:, 0, :3] = new_com.squeeze(-2) + # See test_body_root_state for the PhysX → OVPhysX setter substitution. + articulation.set_coms_index( + coms=wp.from_torch(com.contiguous(), dtype=wp.transformf), + env_ids=wp.from_torch(env_idx, dtype=wp.int32), + ) + + # check they are set + torch.testing.assert_close(_read_binding_to_torch(articulation, TT.BODY_COM_POSE, device), com) + + rand_state = torch.zeros(num_articulations, 13, device=device) + rand_state[..., :7] = articulation.data.default_root_pose.torch + rand_state[..., :3] += env_pos + # make quaternion a unit vector + rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) + + env_idx = env_idx.to(device) + for i in range(10): + # perform step + sim.step() + # update buffers + articulation.update(sim.cfg.dt) + + if state_location == "com": + if i % 2 == 0: + articulation.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7]) + articulation.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) + else: + articulation.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=env_idx) + articulation.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:], env_ids=env_idx) + elif state_location == "link": + if i % 2 == 0: + articulation.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7]) + articulation.write_root_link_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) + else: + articulation.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=env_idx) + articulation.write_root_link_velocity_to_sim_index(root_velocity=rand_state[..., 7:], env_ids=env_idx) + + if state_location == "com": + torch.testing.assert_close(rand_state[..., :7], articulation.data.root_com_pose_w.torch) + torch.testing.assert_close(rand_state[..., 7:], articulation.data.root_com_vel_w.torch) + elif state_location == "link": + torch.testing.assert_close(rand_state[..., :7], articulation.data.root_link_pose_w.torch) + torch.testing.assert_close(rand_state[..., 7:], articulation.data.root_link_vel_w.torch) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_body_incoming_joint_wrench_b_single_joint(sim, num_articulations, device): + """Test the data.body_incoming_joint_wrench_b buffer is populated correctly and statically correct for single joint. + + This test verifies that: + 1. The body incoming joint wrench buffer has correct shape + 2. The wrench values are statically correct for a single joint + 3. The wrench values match expected values from gravity and external forces + + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + device: The device to run the simulation on + """ + articulation_cfg = generate_articulation_cfg(articulation_type="single_joint_implicit") + articulation, _ = generate_articulation( + articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device + ) + + # Play the simulator + sim.reset() + + # Resolve body indices by name (ordering may differ across physics backends) + arm_idx = articulation.body_names.index("Arm") + root_idx = articulation.body_names.index("CenterPivot") + # apply external force + external_force_vector_b = torch.zeros((num_articulations, articulation.num_bodies, 3), device=device) + external_force_vector_b[:, arm_idx, 1] = 10.0 # 10 N in Y direction + external_torque_vector_b = torch.zeros((num_articulations, articulation.num_bodies, 3), device=device) + external_torque_vector_b[:, arm_idx, 2] = 10.0 # 10 Nm in z direction + + # apply action to the articulation + joint_pos = torch.ones_like(articulation.data.joint_pos.torch) * 1.5708 / 2.0 + articulation.write_joint_position_to_sim_index( + position=torch.ones_like(articulation.data.joint_pos.torch), + ) + articulation.write_joint_velocity_to_sim_index( + velocity=torch.zeros_like(articulation.data.joint_vel.torch), + ) + articulation.set_joint_position_target_index(target=joint_pos) + articulation.write_data_to_sim() + for _ in range(50): + articulation.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_force_vector_b, torques=external_torque_vector_b + ) + articulation.write_data_to_sim() + # perform step + sim.step() + # update buffers + articulation.update(sim.cfg.dt) + + # check shape + assert articulation.data.body_incoming_joint_wrench_b.torch.shape == ( + num_articulations, + articulation.num_bodies, + 6, + ) + + # calculate expected static + mass = articulation.data.body_mass.torch.to("cpu") + pos_w = articulation.data.body_pos_w.torch + quat_w = articulation.data.body_quat_w.torch + + mass_link2 = mass[:, arm_idx].view(num_articulations, -1) + gravity = torch.tensor(sim.cfg.gravity, device="cpu").repeat(num_articulations, 1).view((num_articulations, 3)) + + # NOTE: the com and link pose for single joint are colocated + weight_vector_w = mass_link2 * gravity + # expected wrench from link mass and external wrench + # PhysX reports the incoming joint wrench as the force FROM body0 ONTO body1 (body1's frame). + # The USD asset defines body0=CenterPivot, body1=Arm, so the wrench is the constraint/support + # force from CenterPivot onto Arm, expressed in Arm's frame. + # In static equilibrium this equals -(gravity + external forces on Arm). + total_force_w = weight_vector_w.to(device) + math_utils.quat_apply( + quat_w[:, arm_idx, :], external_force_vector_b[:, arm_idx, :] + ) + total_torque_w = torch.cross( + pos_w[:, arm_idx, :].to(device) - pos_w[:, root_idx, :].to(device), + total_force_w, + dim=-1, + ) + math_utils.quat_apply(quat_w[:, arm_idx, :], external_torque_vector_b[:, arm_idx, :]) + expected_wrench = torch.zeros((num_articulations, 6), device=device) + expected_wrench[:, :3] = math_utils.quat_apply( + math_utils.quat_conjugate(quat_w[:, arm_idx, :]), + -total_force_w, + ) + expected_wrench[:, 3:] = math_utils.quat_apply( + math_utils.quat_conjugate(quat_w[:, arm_idx, :]), + -total_torque_w, + ) + + # check value of last joint wrench + torch.testing.assert_close( + expected_wrench, + articulation.data.body_incoming_joint_wrench_b.torch[:, arm_idx, :].squeeze(1), + atol=1e-2, + rtol=1e-3, + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_setting_articulation_root_prim_path(sim, device): + """Test that the articulation root prim path can be set explicitly.""" + sim._app_control_on_stop_handle = None + # Create articulation + articulation_cfg = generate_articulation_cfg(articulation_type="humanoid") + articulation_cfg.articulation_root_prim_path = "/torso" + articulation, _ = generate_articulation(articulation_cfg, 1, device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + sim.reset() + # Check if articulation is initialized + assert articulation._is_initialized + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_setting_invalid_articulation_root_prim_path(sim, device): + """Test that the articulation root prim path can be set explicitly.""" + sim._app_control_on_stop_handle = None + # Create articulation + articulation_cfg = generate_articulation_cfg(articulation_type="humanoid") + articulation_cfg.articulation_root_prim_path = "/non_existing_prim_path" + articulation, _ = generate_articulation(articulation_cfg, 1, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + with pytest.raises(RuntimeError): + sim.reset() + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_write_joint_state_data_consistency(sim, num_articulations, device, gravity_enabled): + """Test the setters for root_state using both the link frame and center of mass as reference frame. + + This test verifies that after write_joint_state_to_sim operations: + 1. state, com_state, link_state value consistency + 2. body_pose, link + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + device: The device to run the simulation on + """ + sim._app_control_on_stop_handle = None + articulation_cfg = generate_articulation_cfg(articulation_type="anymal") + articulation, env_pos = generate_articulation(articulation_cfg, num_articulations, device) + env_idx = torch.tensor([x for x in range(num_articulations)]) + + # Play sim + sim.reset() + + limits = torch.zeros(num_articulations, articulation.num_joints, 2, device=device) + limits[..., 0] = (torch.rand(num_articulations, articulation.num_joints, device=device) + 5.0) * -1.0 + limits[..., 1] = torch.rand(num_articulations, articulation.num_joints, device=device) + 5.0 + articulation.write_joint_position_limit_to_sim_index(limits=limits) + + from torch.distributions import Uniform + + joint_pos_limits = articulation.data.joint_pos_limits.torch + joint_vel_limits = articulation.data.joint_vel_limits.torch + pos_dist = Uniform(joint_pos_limits[..., 0], joint_pos_limits[..., 1]) + vel_dist = Uniform(-joint_vel_limits, joint_vel_limits) + + original_body_link_pose_w = articulation.data.body_link_pose_w.torch.clone() + original_body_com_vel_w = articulation.data.body_com_vel_w.torch.clone() + + rand_joint_pos = pos_dist.sample() + rand_joint_vel = vel_dist.sample() + + articulation.write_joint_position_to_sim_index(position=rand_joint_pos) + articulation.write_joint_velocity_to_sim_index(velocity=rand_joint_vel) + # make sure valued updated + body_link_pose_w = articulation.data.body_link_pose_w.torch + body_com_vel_w = articulation.data.body_com_vel_w.torch + original_body_states = torch.cat([original_body_link_pose_w, original_body_com_vel_w], dim=-1) + body_state_w = torch.cat([body_link_pose_w, body_com_vel_w], dim=-1) + assert torch.count_nonzero(original_body_states[:, 1:] != body_state_w[:, 1:]) > ( + len(original_body_states[:, 1:]) / 2 + ) + # validate body - link consistency + body_link_vel_w = articulation.data.body_link_vel_w.torch + torch.testing.assert_close(body_link_pose_w, articulation.data.body_link_pose_w.torch) + # skip lin_vel because it differs from link frame, this should be fine because we are only checking + # if velocity update is triggered, which can be determined by comparing angular velocity + torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) + + # validate link - com conistency + body_com_pos_b = articulation.data.body_com_pos_b.torch + body_com_quat_b = articulation.data.body_com_quat_b.torch + expected_com_pos, expected_com_quat = math_utils.combine_frame_transforms( + body_link_pose_w[..., :3].view(-1, 3), + body_link_pose_w[..., 3:].view(-1, 4), + body_com_pos_b.view(-1, 3), + body_com_quat_b.view(-1, 4), + ) + body_com_pos_w = articulation.data.body_com_pos_w.torch + body_com_quat_w = articulation.data.body_com_quat_w.torch + torch.testing.assert_close(expected_com_pos.view(len(env_idx), -1, 3), body_com_pos_w) + torch.testing.assert_close(expected_com_quat.view(len(env_idx), -1, 4), body_com_quat_w) + + # validate body - com consistency + body_com_lin_vel_w = articulation.data.body_com_lin_vel_w.torch + body_com_ang_vel_w = articulation.data.body_com_ang_vel_w.torch + torch.testing.assert_close(body_com_vel_w[..., :3], body_com_lin_vel_w) + torch.testing.assert_close(body_com_vel_w[..., 3:], body_com_ang_vel_w) + + # validate pos_w, quat_w, pos_b, quat_b is consistent with pose_w and pose_b + expected_com_pose_w = torch.cat((body_com_pos_w, body_com_quat_w), dim=2) + expected_com_pose_b = torch.cat((body_com_pos_b, body_com_quat_b), dim=2) + body_pos_w = articulation.data.body_pos_w.torch + body_quat_w = articulation.data.body_quat_w.torch + expected_body_pose_w = torch.cat((body_pos_w, body_quat_w), dim=2) + body_link_pos_w = articulation.data.body_link_pos_w.torch + body_link_quat_w = articulation.data.body_link_quat_w.torch + expected_body_link_pose_w = torch.cat((body_link_pos_w, body_link_quat_w), dim=2) + body_com_pose_w = articulation.data.body_com_pose_w.torch + body_com_pose_b = articulation.data.body_com_pose_b.torch + body_pose_w = articulation.data.body_pose_w.torch + body_link_pose_w_fresh = articulation.data.body_link_pose_w.torch + torch.testing.assert_close(body_com_pose_w, expected_com_pose_w) + torch.testing.assert_close(body_com_pose_b, expected_com_pose_b) + torch.testing.assert_close(body_pose_w, expected_body_pose_w) + torch.testing.assert_close(body_link_pose_w_fresh, expected_body_link_pose_w) + + # validate pose_w is consistent with individual properties + body_vel_w = articulation.data.body_vel_w.torch + body_com_vel_w_fresh = articulation.data.body_com_vel_w.torch + torch.testing.assert_close(body_pose_w, body_link_pose_w) + torch.testing.assert_close(body_vel_w, body_com_vel_w) + torch.testing.assert_close(body_link_pose_w_fresh, body_link_pose_w) + torch.testing.assert_close(body_com_pose_w, articulation.data.body_com_pose_w.torch) + torch.testing.assert_close(body_vel_w, body_com_vel_w_fresh) + + +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_spatial_tendons(sim, num_articulations, device): + """Test spatial tendons apis. + This test verifies that: + 1. The articulation is properly initialized + 2. The articulation has spatial tendons + 3. All buffers have correct shapes + 4. The articulation can be simulated + Args: + sim: The simulation fixture + num_articulations: Number of articulations to test + device: The device to run the simulation on + """ + # skip test if Isaac Sim version is less than 5.0 + if has_kit() and get_isaac_sim_version().major < 5: + pytest.skip("Spatial tendons are not supported in Isaac Sim < 5.0. Please update to Isaac Sim 5.0 or later.") + return + articulation_cfg = generate_articulation_cfg(articulation_type="spatial_tendon_test_asset") + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(articulation) < 10 + + # Play sim + sim.reset() + # Check if articulation is initialized + assert articulation.is_initialized + # Check that fixed base + assert articulation.is_fixed_base + # Check buffers that exists and have correct shapes + assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) + assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) + assert articulation.data.joint_pos.torch.shape == (num_articulations, 3) + assert articulation.data.body_mass.torch.shape == (num_articulations, articulation.num_bodies) + assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) + assert articulation.num_spatial_tendons == 1 + + articulation.set_spatial_tendon_stiffness_index(stiffness=10.0) + articulation.set_spatial_tendon_limit_stiffness_index(limit_stiffness=10.0) + articulation.set_spatial_tendon_damping_index(damping=10.0) + articulation.set_spatial_tendon_offset_index(offset=10.0) + + # Simulate physics + for _ in range(10): + # perform rendering + sim.step() + # update articulation + articulation.update(sim.cfg.dt) + + +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_write_joint_frictions_to_sim(sim, num_articulations, device, add_ground_plane): + """Test applying of joint position target functions correctly for a robotic arm.""" + articulation_cfg = generate_articulation_cfg(articulation_type="panda") + articulation, _ = generate_articulation( + articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device + ) + + # Play the simulator + sim.reset() + + for _ in range(100): + # perform step + sim.step() + # update buffers + articulation.update(sim.cfg.dt) + + # apply action to the articulation + dynamic_friction = torch.rand(num_articulations, articulation.num_joints, device=device) + viscous_friction = torch.rand(num_articulations, articulation.num_joints, device=device) + friction = torch.rand(num_articulations, articulation.num_joints, device=device) + + # Guarantee that the dynamic friction is not greater than the static friction + dynamic_friction = torch.min(dynamic_friction, friction) + + # The static friction must be set first to be sure the dynamic friction is not greater than static + # when both are set. + articulation.write_joint_friction_coefficient_to_sim_index( + joint_friction_coeff=friction, + joint_dynamic_friction_coeff=dynamic_friction, + joint_viscous_friction_coeff=viscous_friction, + ) + articulation.write_data_to_sim() + + for _ in range(100): + # perform step + sim.step() + # update buffers + articulation.update(sim.cfg.dt) + + friction_props_from_sim = _read_binding_to_torch(articulation, TT.DOF_FRICTION_PROPERTIES, "cpu") + joint_friction_coeff_sim = friction_props_from_sim[:, :, 0] + joint_dynamic_friction_coeff_sim = friction_props_from_sim[:, :, 1] + joint_viscous_friction_coeff_sim = friction_props_from_sim[:, :, 2] + assert torch.allclose(joint_dynamic_friction_coeff_sim, dynamic_friction.cpu()) + assert torch.allclose(joint_viscous_friction_coeff_sim, viscous_friction.cpu()) + assert torch.allclose(joint_friction_coeff_sim, friction.cpu()) + + # For Isaac Sim >= 5.0: also test the combined API that can set dynamic and viscous via + # write_joint_friction_coefficient_to_sim; reset the sim to isolate this path. + if has_kit() and get_isaac_sim_version().major >= 5: + # Reset simulator to ensure a clean state for the alternative API path + sim.reset() + + # Warm up a few steps to populate buffers + for _ in range(100): + sim.step() + articulation.update(sim.cfg.dt) + + # New random coefficients + dynamic_friction_2 = torch.rand(num_articulations, articulation.num_joints, device=device) + viscous_friction_2 = torch.rand(num_articulations, articulation.num_joints, device=device) + friction_2 = torch.rand(num_articulations, articulation.num_joints, device=device) + + # Guarantee that the dynamic friction is not greater than the static friction + dynamic_friction_2 = torch.min(dynamic_friction_2, friction_2) + + # Use the combined setter to write all three at once + articulation.write_joint_friction_coefficient_to_sim_index( + joint_friction_coeff=friction_2, + joint_dynamic_friction_coeff=dynamic_friction_2, + joint_viscous_friction_coeff=viscous_friction_2, + ) + articulation.write_data_to_sim() + + # Step to let sim ingest new params and refresh data buffers + for _ in range(100): + sim.step() + articulation.update(sim.cfg.dt) + + friction_props_from_sim_2 = _read_binding_to_torch(articulation, TT.DOF_FRICTION_PROPERTIES, "cpu") + joint_friction_coeff_sim_2 = friction_props_from_sim_2[:, :, 0] + friction_dynamic_coef_sim_2 = friction_props_from_sim_2[:, :, 1] + friction_viscous_coeff_sim_2 = friction_props_from_sim_2[:, :, 2] + + # Validate values propagated + assert torch.allclose(friction_viscous_coeff_sim_2, viscous_friction_2.cpu()) + assert torch.allclose(friction_dynamic_coef_sim_2, dynamic_friction_2.cpu()) + assert torch.allclose(joint_friction_coeff_sim_2, friction_2.cpu()) + + +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("articulation_type", ["panda"]) +@pytest.mark.isaacsim_ci +@pytest.mark.xfail(reason=_MATERIAL_GAP_REASON, strict=False) +def test_set_material_properties(sim, num_articulations, device, add_ground_plane, articulation_type): + """Test getting and setting material properties (friction/restitution) of articulation shapes.""" + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation, _ = generate_articulation( + articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device + ) + + # Play the simulator + sim.reset() + + # Get number of shapes from the articulation + max_shapes = articulation.root_view.max_shapes + + # Generate random material properties: (static_friction, dynamic_friction, restitution) + materials = torch.empty(num_articulations, max_shapes, 3, device="cpu").uniform_(0.0, 1.0) + # Ensure dynamic friction <= static friction + materials[..., 1] = torch.min(materials[..., 0], materials[..., 1]) + + # Set material properties via the PhysX view-level API + env_ids = torch.arange(num_articulations, dtype=torch.int32) + articulation.root_view.set_material_properties( + wp.from_torch(materials, dtype=wp.float32), wp.from_torch(env_ids, dtype=wp.int32) + ) + + # Simulate physics + sim.step() + articulation.update(sim.cfg.dt) + + # Get material properties from simulation + materials_check = wp.to_torch(articulation.root_view.get_material_properties()) + + # Check if material properties are set correctly + torch.testing.assert_close(materials_check, materials) + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--maxfail=1"]) diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation_data.py b/source/isaaclab_ovphysx/test/assets/test_articulation_data.py deleted file mode 100644 index 390e5defa0f2..000000000000 --- a/source/isaaclab_ovphysx/test/assets/test_articulation_data.py +++ /dev/null @@ -1,58 +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 - -"""Unit tests for ovphysx articulation data helpers.""" - -import numpy as np -import pytest -import warp as wp - -# The CI isaaclab_ov* pattern unintentionally collects isaaclab_ovphysx tests, -# but the ovphysx wheel is not installed in that environment. Skip gracefully -# so the isaaclab_ov CI pipeline is not blocked by an unrelated dependency. -pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") - -from isaaclab_ovphysx import tensor_types as TT # noqa: E402 -from isaaclab_ovphysx.assets.articulation.articulation_data import ArticulationData -from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet - -wp.init() - - -class TestArticulationData: - """Unit tests for deterministic ArticulationData behavior.""" - - def test_joint_acc_uses_inverse_dt(self): - """Finite-difference joint acceleration should divide by ``dt``.""" - mock_bindings = MockOvPhysxBindingSet(num_instances=1, num_joints=2, num_bodies=1) - data = ArticulationData(mock_bindings.bindings, device="cpu") - data._create_buffers() - - mock_bindings.bindings[TT.DOF_VELOCITY]._data[...] = np.array([[1.0, -2.0]], dtype=np.float32) - - data.update(dt=0.25) - - np.testing.assert_allclose( - data.joint_acc.warp.numpy(), - np.array([[4.0, -8.0]], dtype=np.float32), - atol=1e-6, - err_msg="Joint acceleration should be computed as delta_velocity / dt.", - ) - - def test_cpu_only_binding_read_stages_to_gpu_view(self): - """CPU-only bindings should be staged before copying into GPU-backed data buffers.""" - if not wp.is_cuda_available(): - pytest.skip("CUDA is required to test CPU-to-GPU staging.") - - mock_bindings = MockOvPhysxBindingSet(num_instances=1, num_joints=2, num_bodies=1) - data = ArticulationData(mock_bindings.bindings, device="cuda") - data._create_buffers() - - expected = np.array([[[1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0]]], dtype=np.float32) - mock_bindings.bindings[TT.BODY_COM_POSE]._data[...] = expected - - data._read_transform_binding(TT.BODY_COM_POSE, data._body_com_pose_b) - - np.testing.assert_allclose(data._body_com_pose_b.data.numpy(), expected, atol=1e-6) diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation_helpers.py b/source/isaaclab_ovphysx/test/assets/test_articulation_helpers.py new file mode 100644 index 000000000000..db0a2cc89403 --- /dev/null +++ b/source/isaaclab_ovphysx/test/assets/test_articulation_helpers.py @@ -0,0 +1,140 @@ +# 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 + +"""OVPhysX-only unit tests for articulation helpers. + +These tests cover OVPhysX-specific scaffolding (USD tendon-scope resolution, +mock binding-set shape contracts) that has no PhysX equivalent and therefore +does not appear in the PhysX-mirrored ``test_articulation.py``. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +import warp as wp + +from pxr import Sdf, Usd, UsdPhysics + +# The CI isaaclab_ov* pattern unintentionally collects isaaclab_ovphysx tests, +# but the ovphysx wheel is not installed in that environment. Skip gracefully +# so the isaaclab_ov CI pipeline is not blocked by an unrelated dependency. +pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") + +from isaaclab_ovphysx.assets.articulation.articulation import Articulation # noqa: E402 +from isaaclab_ovphysx.physics import OvPhysxManager # noqa: E402 +from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet # noqa: E402 + +wp.init() + + +def _define_tendon_joint(stage: Usd.Stage, path: str, schema_name: str) -> None: + """Define a revolute joint prim with a tendon schema marker.""" + joint = UsdPhysics.RevoluteJoint.Define(stage, path) + schemas = Sdf.TokenListOp() + schemas.explicitItems = [schema_name] + joint.GetPrim().SetMetadata("apiSchemas", schemas) + + +def _make_articulation_root_stage(tmp_path) -> str: + """Create a stage with one relevant articulation subtree and unrelated joints elsewhere.""" + stage = Usd.Stage.CreateInMemory() + stage.DefinePrim("/World", "Xform") + stage.DefinePrim("/World/envs", "Xform") + stage.DefinePrim("/World/envs/env_0", "Xform") + stage.DefinePrim("/World/envs/env_0/Robot", "Xform") + stage.DefinePrim("/World/envs/env_0/Robot/root", "Xform") + stage.DefinePrim("/World/unrelated", "Xform") + + _define_tendon_joint( + stage, + "/World/envs/env_0/Robot/root/fixed_joint", + "PhysxTendonAxisRootAPI:inst0", + ) + _define_tendon_joint( + stage, + "/World/envs/env_0/Robot/root/spatial_joint", + "PhysxTendonAttachmentRootAPI:inst0", + ) + _define_tendon_joint( + stage, + "/World/unrelated/unrelated_fixed_joint", + "PhysxTendonAxisRootAPI:inst0", + ) + _define_tendon_joint( + stage, + "/World/unrelated/unrelated_spatial_joint", + "PhysxTendonAttachmentLeafAPI:inst0", + ) + + stage_path = tmp_path / "scene.usda" + stage.Export(str(stage_path)) + return str(stage_path) + + +def _make_articulation_shell() -> Articulation: + """Create a minimal ovphysx articulation shell for tendon processing tests.""" + articulation = object.__new__(Articulation) + bindings = MockOvPhysxBindingSet( + num_instances=1, + num_joints=2, + num_bodies=2, + num_fixed_tendons=1, + num_spatial_tendons=1, + ) + object.__setattr__(articulation, "_bindings", bindings.bindings) + object.__setattr__(articulation, "_articulation_root_path", "/World/envs/env_0/Robot/root") + object.__setattr__(articulation, "_initialize_handle", None) + object.__setattr__(articulation, "_invalidate_initialize_handle", None) + object.__setattr__(articulation, "_prim_deletion_handle", None) + object.__setattr__(articulation, "_debug_vis_handle", None) + object.__setattr__( + articulation, + "_data", + SimpleNamespace( + _num_fixed_tendons=0, + _num_spatial_tendons=0, + fixed_tendon_names=[], + spatial_tendon_names=[], + ), + ) + return articulation + + +def test_process_tendons_scopes_to_articulation_root(tmp_path): + """Tendon discovery should ignore joints that live outside the current articulation subtree.""" + articulation = _make_articulation_shell() + stage_path = _make_articulation_root_stage(tmp_path) + old_stage_path = OvPhysxManager._stage_path + OvPhysxManager._stage_path = stage_path + try: + articulation._process_tendons() + finally: + OvPhysxManager._stage_path = old_stage_path + + assert articulation.fixed_tendon_names == ["fixed_joint"] + assert articulation.spatial_tendon_names == ["spatial_joint"] + + +def test_mock_binding_set_rigid_object_shapes(): + pytest.importorskip("isaaclab_ovphysx.tensor_types").RIGID_BODY_POSE # gates on wheel + from isaaclab_ovphysx import tensor_types as TT + from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet + + bindings = MockOvPhysxBindingSet( + num_instances=4, + num_joints=0, + num_bodies=1, + asset_kind="rigid_object", + ) + assert bindings.bindings[TT.RIGID_BODY_POSE].shape == (4, 7) + assert bindings.bindings[TT.RIGID_BODY_VELOCITY].shape == (4, 6) + assert bindings.bindings[TT.RIGID_BODY_WRENCH].shape == (4, 9) + assert bindings.bindings[TT.RIGID_BODY_MASS].shape == (4,) + assert bindings.bindings[TT.RIGID_BODY_INERTIA].shape == (4, 9) + # Articulation-only bindings must be absent + assert TT.DOF_POSITION not in bindings.bindings + assert TT.LINK_WRENCH not in bindings.bindings diff --git a/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_articulation.rst b/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_articulation.rst new file mode 100644 index 000000000000..26c7d5b8f9ca --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_articulation.rst @@ -0,0 +1,10 @@ +Added +^^^^^ + +* Added the ``ovphysx`` preset to ``Isaac-Repose-Cube-Allegro-Direct-v0`` + (``ObjectCfg`` and ``PhysicsCfg`` in + :mod:`isaaclab_tasks.direct.allegro_hand.allegro_hand_env_cfg`), so the + task can be selected with ``presets=ovphysx`` against the OVPhysX + backend. Exercises the OVPhysX :class:`~isaaclab_ovphysx.assets.Articulation` + (Allegro hand) and :class:`~isaaclab_ovphysx.assets.RigidObject` (cube) + in the same scene. diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/allegro_hand_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/allegro_hand_env_cfg.py index bd982c5d7105..6065f81a0868 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/allegro_hand_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/allegro_hand_env_cfg.py @@ -5,6 +5,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_ovphysx.physics import OvPhysxCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.sim as sim_utils @@ -56,6 +57,25 @@ class ObjectCfg(PresetCfg): actuators={}, articulation_root_prim_path="", ) + ovphysx = RigidObjectCfg( + prim_path="/World/envs/env_.*/object", + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/DexCube/dex_cube_instanceable.usd", + rigid_props=sim_utils.RigidBodyPropertiesCfg( + kinematic_enabled=False, + disable_gravity=False, + enable_gyroscopic_forces=True, + solver_position_iteration_count=8, + solver_velocity_iteration_count=0, + sleep_threshold=0.005, + stabilization_threshold=0.0025, + max_depenetration_velocity=1000.0, + ), + mass_props=sim_utils.MassPropertiesCfg(density=400.0), + scale=(1.2, 1.2, 1.2), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, -0.17, 0.56), rot=(0.0, 0.0, 0.0, 1.0)), + ) default = physx @@ -79,6 +99,7 @@ class PhysicsCfg(PresetCfg): num_substeps=2, debug_mode=False, ) + ovphysx = OvPhysxCfg() default = physx From bed2bf93ab4f8185fb33c8f6dff215cb2aaaf1da Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Fri, 15 May 2026 23:42:36 -0700 Subject: [PATCH 086/133] Fix lazy import for configclass and provide upper bound for python (#5647) 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 --------- Co-authored-by: ooctipus Co-authored-by: Mustafa H <34825877+StafaH@users.noreply.github.com> --- .github/workflows/build.yaml | 6 +++--- .github/workflows/install-ci.yml | 6 +++--- docs/source/features/hydra.rst | 4 ++-- docs/source/how-to/cloning.rst | 2 +- docs/source/how-to/record_video.rst | 2 +- docs/source/migration/migrating_to_isaaclab_3-0.rst | 2 +- .../overview/core-concepts/multi_backend_architecture.rst | 4 ++-- docs/source/refs/contributing.rst | 2 +- docs/source/setup/walkthrough/api_env_design.rst | 2 +- docs/source/setup/walkthrough/technical_env_design.rst | 2 +- pyproject.toml | 2 +- scripts/benchmarks/benchmark_load_robot.py | 2 +- scripts/benchmarks/benchmark_view_comparison.py | 2 +- scripts/benchmarks/benchmark_xform_prim_view.py | 2 +- scripts/demos/bin_packing.py | 3 ++- scripts/demos/haply_teleoperation.py | 2 +- scripts/demos/multi_asset.py | 3 ++- scripts/demos/pick_and_place.py | 2 +- scripts/demos/sensors/cameras.py | 2 +- scripts/demos/sensors/contact_sensor.py | 2 +- scripts/demos/sensors/frame_transformer_sensor.py | 2 +- scripts/demos/sensors/imu_sensor.py | 2 +- scripts/demos/sensors/multi_mesh_raycaster.py | 2 +- scripts/demos/sensors/multi_mesh_raycaster_camera.py | 2 +- scripts/demos/sensors/pva_sensor.py | 2 +- scripts/demos/sensors/raycaster_sensor.py | 2 +- scripts/demos/sensors/tacsl_sensor.py | 2 +- scripts/imitation_learning/isaaclab_mimic/annotate_demos.py | 2 +- .../imitation_learning/isaaclab_mimic/consolidated_demo.py | 2 +- .../locomanipulation_sdg/generate_data.py | 2 +- scripts/tutorials/02_scene/create_scene.py | 2 +- scripts/tutorials/03_envs/create_cartpole_base_env.py | 2 +- scripts/tutorials/03_envs/create_cube_base_env.py | 2 +- scripts/tutorials/03_envs/create_quadruped_base_env.py | 2 +- scripts/tutorials/04_sensors/add_sensors_on_robot.py | 2 +- scripts/tutorials/05_controllers/run_diff_ik.py | 2 +- scripts/tutorials/05_controllers/run_osc.py | 2 +- source/isaaclab/changelog.d/pbarejko-fix-lazy-import.skip | 0 source/isaaclab/isaaclab/actuators/actuator_base_cfg.py | 2 +- source/isaaclab/isaaclab/actuators/actuator_net_cfg.py | 2 +- source/isaaclab/isaaclab/actuators/actuator_pd_cfg.py | 2 +- .../isaaclab/assets/articulation/articulation_cfg.py | 2 +- source/isaaclab/isaaclab/assets/asset_base_cfg.py | 2 +- .../isaaclab/assets/rigid_object/rigid_object_cfg.py | 2 +- .../rigid_object_collection/rigid_object_collection_cfg.py | 2 +- source/isaaclab/isaaclab/cloner/cloner_cfg.py | 2 +- source/isaaclab/isaaclab/controllers/differential_ik_cfg.py | 2 +- source/isaaclab/isaaclab/controllers/joint_impedance_cfg.py | 2 +- .../isaaclab/isaaclab/controllers/operational_space_cfg.py | 2 +- source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py | 2 +- .../isaaclab/isaaclab/controllers/pink_ik/pink_task_cfg.py | 2 +- source/isaaclab/isaaclab/controllers/rmp_flow_cfg.py | 2 +- source/isaaclab/isaaclab/devices/device_base.py | 2 +- source/isaaclab/isaaclab/devices/gamepad/se2_gamepad_cfg.py | 2 +- source/isaaclab/isaaclab/devices/gamepad/se3_gamepad_cfg.py | 2 +- source/isaaclab/isaaclab/devices/haply/se3_haply.py | 2 +- .../isaaclab/isaaclab/devices/keyboard/se2_keyboard_cfg.py | 2 +- .../isaaclab/isaaclab/devices/keyboard/se3_keyboard_cfg.py | 2 +- source/isaaclab/isaaclab/devices/retargeter_base.py | 2 +- .../isaaclab/devices/spacemouse/se2_spacemouse_cfg.py | 2 +- .../isaaclab/devices/spacemouse/se3_spacemouse_cfg.py | 2 +- source/isaaclab/isaaclab/envs/common.py | 2 +- source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py | 2 +- source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py | 2 +- source/isaaclab/isaaclab/envs/manager_based_env_cfg.py | 2 +- source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py | 2 +- source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py | 2 +- .../isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py | 2 +- .../isaaclab/envs/mdp/actions/rmpflow_actions_cfg.py | 2 +- source/isaaclab/isaaclab/envs/mdp/commands/commands_cfg.py | 2 +- .../isaaclab/isaaclab/envs/mdp/recorders/recorders_cfg.py | 2 +- source/isaaclab/isaaclab/envs/mimic_env_cfg.py | 2 +- source/isaaclab/isaaclab/envs/utils/io_descriptors.py | 2 +- source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py | 2 +- source/isaaclab/isaaclab/managers/manager_base.py | 2 +- source/isaaclab/isaaclab/managers/manager_term_cfg.py | 2 +- source/isaaclab/isaaclab/managers/recorder_manager.py | 2 +- source/isaaclab/isaaclab/managers/scene_entity_cfg.py | 2 +- source/isaaclab/isaaclab/renderers/renderer_cfg.py | 2 +- source/isaaclab/isaaclab/scene/interactive_scene.py | 2 +- source/isaaclab/isaaclab/scene/interactive_scene_cfg.py | 2 +- source/isaaclab/isaaclab/sensors/camera/camera_cfg.py | 2 +- source/isaaclab/isaaclab/sensors/camera/tiled_camera_cfg.py | 2 +- .../isaaclab/sensors/contact_sensor/contact_sensor_cfg.py | 2 +- .../sensors/frame_transformer/frame_transformer_cfg.py | 2 +- source/isaaclab/isaaclab/sensors/imu/imu_cfg.py | 2 +- .../sensors/joint_wrench/joint_wrench_sensor_cfg.py | 2 +- source/isaaclab/isaaclab/sensors/pva/pva_cfg.py | 2 +- .../sensors/ray_caster/multi_mesh_ray_caster_camera_cfg.py | 2 +- .../sensors/ray_caster/multi_mesh_ray_caster_cfg.py | 2 +- .../isaaclab/sensors/ray_caster/patterns/patterns_cfg.py | 2 +- .../isaaclab/sensors/ray_caster/ray_caster_camera_cfg.py | 2 +- .../isaaclab/isaaclab/sensors/ray_caster/ray_caster_cfg.py | 2 +- source/isaaclab/isaaclab/sensors/sensor_base_cfg.py | 2 +- .../isaaclab/sim/converters/asset_converter_base_cfg.py | 2 +- .../isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py | 2 +- .../isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py | 2 +- .../isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py | 2 +- source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py | 2 +- source/isaaclab/isaaclab/sim/simulation_cfg.py | 2 +- .../isaaclab/sim/spawners/from_files/from_files_cfg.py | 2 +- source/isaaclab/isaaclab/sim/spawners/lights/lights_cfg.py | 2 +- .../sim/spawners/materials/physics_materials_cfg.py | 2 +- .../isaaclab/sim/spawners/materials/visual_materials_cfg.py | 2 +- source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py | 2 +- .../isaaclab/isaaclab/sim/spawners/sensors/sensors_cfg.py | 2 +- source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py | 2 +- source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py | 2 +- .../isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py | 4 ++-- .../isaaclab/terrains/height_field/hf_terrains_cfg.py | 2 +- source/isaaclab/isaaclab/terrains/sub_terrain_cfg.py | 2 +- source/isaaclab/isaaclab/terrains/terrain_generator_cfg.py | 2 +- source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py | 2 +- .../isaaclab/isaaclab/terrains/trimesh/mesh_terrains_cfg.py | 2 +- .../isaaclab/isaaclab/ui/widgets/manager_live_visualizer.py | 2 +- source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py | 2 +- source/isaaclab/isaaclab/utils/noise/noise_cfg.py | 2 +- source/isaaclab/isaaclab/visualizers/visualizer_cfg.py | 2 +- source/isaaclab/test/app/test_non_headless_launch.py | 2 +- source/isaaclab/test/controllers/test_operational_space.py | 2 +- .../test/envs/check_manager_based_env_anymal_locomotion.py | 2 +- .../test/envs/check_manager_based_env_floating_cube.py | 2 +- source/isaaclab/test/envs/test_color_randomization.py | 2 +- source/isaaclab/test/envs/test_direct_marl_env.py | 2 +- source/isaaclab/test/envs/test_env_rendering_logic.py | 2 +- source/isaaclab/test/envs/test_manager_based_env.py | 2 +- source/isaaclab/test/envs/test_manager_based_rl_env_ui.py | 2 +- .../isaaclab/test/envs/test_modify_env_param_curr_term.py | 2 +- source/isaaclab/test/envs/test_scale_randomization.py | 2 +- source/isaaclab/test/envs/test_texture_randomization.py | 2 +- source/isaaclab/test/managers/test_event_manager.py | 2 +- source/isaaclab/test/managers/test_observation_manager.py | 3 ++- source/isaaclab/test/managers/test_recorder_manager.py | 2 +- source/isaaclab/test/managers/test_reward_manager.py | 2 +- source/isaaclab/test/markers/check_markers_visibility.py | 2 +- source/isaaclab/test/scene/check_interactive_scene.py | 2 +- source/isaaclab/test/scene/test_interactive_scene.py | 2 +- source/isaaclab/test/sensors/test_sensor_base.py | 2 +- source/isaaclab/test/utils/test_modifiers.py | 2 +- .../test/visualization/check_scene_xr_visualization.py | 2 +- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 source/isaaclab_contrib/docs/README.md | 2 +- .../isaaclab_contrib/actuators/thruster_cfg.py | 2 +- .../isaaclab_contrib/assets/multirotor/multirotor_cfg.py | 2 +- .../controllers/lee_acceleration_control_cfg.py | 2 +- .../controllers/lee_attitude_control_cfg.py | 2 +- .../isaaclab_contrib/controllers/lee_controller_base_cfg.py | 2 +- .../controllers/lee_position_control_cfg.py | 2 +- .../controllers/lee_velocity_control_cfg.py | 2 +- .../isaaclab_contrib/mdp/actions/thrust_actions_cfg.py | 2 +- .../sensors/tacsl_sensor/visuotactile_sensor_cfg.py | 2 +- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../isaaclab_experimental/envs/mdp/actions/actions_cfg.py | 2 +- .../isaaclab_experimental/managers/manager_base.py | 2 +- .../isaaclab_experimental/managers/manager_term_cfg.py | 2 +- .../isaaclab_experimental/utils/modifiers/modifier_cfg.py | 2 +- .../isaaclab_experimental/utils/noise/noise_cfg.py | 2 +- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../envs/agibot_place_toy2box_mimic_env_cfg.py | 2 +- .../envs/agibot_place_upright_mug_mimic_env_cfg.py | 2 +- .../isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py | 2 +- .../envs/franka_bin_stack_ik_rel_mimic_env_cfg.py | 2 +- .../envs/franka_stack_ik_abs_mimic_env_cfg.py | 2 +- .../envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py | 2 +- .../envs/franka_stack_ik_rel_mimic_env_cfg.py | 2 +- .../envs/franka_stack_ik_rel_skillgen_env_cfg.py | 2 +- .../franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py | 2 +- .../envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py | 2 +- .../envs/galbot_stack_rmp_abs_mimic_env_cfg.py | 2 +- .../envs/galbot_stack_rmp_rel_mimic_env_cfg.py | 2 +- .../envs/locomanipulation_g1_mimic_env_cfg.py | 2 +- .../isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py | 2 +- .../isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py | 2 +- .../envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py | 2 +- .../envs/g1_locomanipulation_sdg_env.py | 2 +- .../envs/locomanipulation_sdg_env_cfg.py | 2 +- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../isaaclab_newton/physics/featherstone_manager_cfg.py | 2 +- .../isaaclab_newton/physics/kamino_manager_cfg.py | 2 +- .../isaaclab_newton/physics/mjwarp_manager_cfg.py | 2 +- .../isaaclab_newton/physics/xpbd_manager_cfg.py | 2 +- .../isaaclab_newton/renderers/newton_warp_renderer_cfg.py | 2 +- .../sensors/contact_sensor/contact_sensor_cfg.py | 2 +- .../isaaclab_newton/sim/schemas/schemas_cfg.py | 2 +- .../video_recording/newton_gl_perspective_video_cfg.py | 2 +- source/isaaclab_newton/test/sensors/test_contact_sensor.py | 2 +- .../isaaclab_newton/test/sensors/test_frame_transformer.py | 2 +- source/isaaclab_newton/test/sensors/test_imu.py | 2 +- .../test/sensors/test_joint_wrench_sensor.py | 2 +- source/isaaclab_newton/test/sensors/test_pva.py | 2 +- .../test/sim/test_views_xform_prim_newton.py | 2 +- .../isaaclab_ov/changelog.d/pbarejko-fix-lazy-import.skip | 0 .../isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py | 2 +- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../isaaclab_ovphysx/physics/ovphysx_manager_cfg.py | 2 +- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../assets/deformable_object/deformable_object_cfg.py | 2 +- .../assets/surface_gripper/surface_gripper_cfg.py | 2 +- .../isaaclab_physx/physics/physx_manager_cfg.py | 2 +- .../isaaclab_physx/renderers/isaac_rtx_renderer_cfg.py | 2 +- .../sensors/contact_sensor/contact_sensor_cfg.py | 2 +- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 2 +- .../sim/spawners/materials/physics_materials_cfg.py | 2 +- .../isaaclab_physx/sim/spawners/spawner_cfg.py | 2 +- .../video_recording/isaacsim_kit_perspective_video_cfg.py | 2 +- source/isaaclab_physx/test/sensors/test_contact_sensor.py | 2 +- .../isaaclab_physx/test/sensors/test_frame_transformer.py | 2 +- source/isaaclab_physx/test/sensors/test_imu.py | 2 +- .../isaaclab_physx/test/sensors/test_joint_wrench_sensor.py | 2 +- source/isaaclab_physx/test/sensors/test_pva.py | 2 +- source/isaaclab_physx/test/sim/test_cloner.py | 2 +- .../isaaclab_rl/changelog.d/pbarejko-fix-lazy-import.skip | 0 source/isaaclab_rl/isaaclab_rl/rl_games/pbt/pbt_cfg.py | 2 +- source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py | 2 +- source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py | 2 +- source/isaaclab_rl/isaaclab_rl/rsl_rl/rnd_cfg.py | 2 +- source/isaaclab_rl/isaaclab_rl/rsl_rl/symmetry_cfg.py | 2 +- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../direct/allegro_hand/agents/rsl_rl_ppo_cfg.py | 2 +- .../direct/allegro_hand/allegro_hand_env_cfg.py | 2 +- .../isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py | 2 +- .../isaaclab_tasks/isaaclab_tasks/direct/ant/ant_env_cfg.py | 2 +- .../isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py | 2 +- .../isaaclab_tasks/direct/anymal_c/anymal_c_env_cfg.py | 2 +- .../isaaclab_tasks/direct/automate/assembly_env_cfg.py | 2 +- .../isaaclab_tasks/direct/automate/assembly_tasks_cfg.py | 2 +- .../isaaclab_tasks/direct/automate/disassembly_env_cfg.py | 2 +- .../isaaclab_tasks/direct/automate/disassembly_tasks_cfg.py | 2 +- .../direct/cart_double_pendulum/cart_double_pendulum_env.py | 2 +- .../cart_double_pendulum/cart_double_pendulum_env_cfg.py | 2 +- .../isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py | 2 +- .../direct/cartpole/cartpole_camera_env_cfg.py | 2 +- .../direct/cartpole/cartpole_camera_presets_env_cfg.py | 2 +- .../isaaclab_tasks/direct/cartpole/cartpole_env_cfg.py | 2 +- .../direct/cartpole_showcase/cartpole/cartpole_env_cfg.py | 2 +- .../cartpole_camera/cartpole_camera_env_cfg.py | 2 +- .../isaaclab_tasks/direct/factory/factory_env_cfg.py | 2 +- .../isaaclab_tasks/direct/factory/factory_tasks_cfg.py | 2 +- .../isaaclab_tasks/direct/forge/forge_env_cfg.py | 2 +- .../isaaclab_tasks/direct/forge/forge_tasks_cfg.py | 2 +- .../direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py | 2 +- .../direct/franka_cabinet/franka_cabinet_env_cfg.py | 2 +- .../isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py | 2 +- .../isaaclab_tasks/direct/humanoid/humanoid_env_cfg.py | 2 +- .../direct/humanoid_amp/humanoid_amp_env_cfg.py | 2 +- .../direct/quadcopter/agents/rsl_rl_ppo_cfg.py | 2 +- .../isaaclab_tasks/direct/quadcopter/quadcopter_env_cfg.py | 2 +- .../direct/shadow_hand/agents/rsl_rl_ppo_cfg.py | 2 +- .../isaaclab_tasks/direct/shadow_hand/feature_extractor.py | 2 +- .../direct/shadow_hand/shadow_hand_env_cfg.py | 2 +- .../direct/shadow_hand/shadow_hand_vision_env_cfg.py | 2 +- .../direct/shadow_hand_over/shadow_hand_over_env_cfg.py | 2 +- .../manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py | 2 +- .../isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py | 2 +- .../manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py | 2 +- .../classic/cartpole/cartpole_camera_env_cfg.py | 2 +- .../manager_based/classic/cartpole/cartpole_env_cfg.py | 2 +- .../manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py | 2 +- .../manager_based/classic/humanoid/humanoid_env_cfg.py | 2 +- .../manager_based/drone_arl/mdp/commands/commands_cfg.py | 2 +- .../navigation/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py | 2 +- .../config/arl_robot_1/floating_obstacles_env_cfg.py | 2 +- .../navigation/config/arl_robot_1/navigation_env_cfg.py | 2 +- .../scenes/obstacle_scenes/obstacle_scene_cfg.py | 2 +- .../config/arl_robot_1/agents/rsl_rl_ppo_cfg.py | 2 +- .../config/arl_robot_1/no_obstacle_env_cfg.py | 2 +- .../arl_robot_1/track_position_state_based_env_cfg.py | 2 +- .../locomanipulation/pick_place/configs/action_cfg.py | 2 +- .../pick_place/configs/agile_locomotion_observation_cfg.py | 2 +- .../pick_place/fixed_base_upper_body_ik_g1_env_cfg.py | 2 +- .../pick_place/locomanipulation_g1_env_cfg.py | 2 +- .../tracking/config/digit/agents/rsl_rl_ppo_cfg.py | 2 +- .../tracking/config/digit/loco_manip_env_cfg.py | 2 +- .../locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py | 2 +- .../locomotion/velocity/config/a1/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/a1/rough_env_cfg.py | 2 +- .../velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py | 2 +- .../locomotion/velocity/config/anymal_b/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/anymal_b/rough_env_cfg.py | 2 +- .../velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py | 2 +- .../locomotion/velocity/config/anymal_c/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/anymal_c/rough_env_cfg.py | 2 +- .../config/anymal_d/agents/rsl_rl_distillation_cfg.py | 2 +- .../velocity/config/anymal_d/agents/rsl_rl_ppo_cfg.py | 2 +- .../locomotion/velocity/config/anymal_d/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/anymal_d/rough_env_cfg.py | 2 +- .../velocity/config/cassie/agents/rsl_rl_ppo_cfg.py | 2 +- .../locomotion/velocity/config/cassie/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/cassie/rough_env_cfg.py | 2 +- .../velocity/config/digit/agents/rsl_rl_ppo_cfg.py | 2 +- .../locomotion/velocity/config/digit/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/digit/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py | 2 +- .../locomotion/velocity/config/g1/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/g1/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py | 2 +- .../locomotion/velocity/config/go1/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/go1/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py | 2 +- .../locomotion/velocity/config/go2/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/go2/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py | 2 +- .../locomotion/velocity/config/h1/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/h1/rough_env_cfg.py | 2 +- .../velocity/config/spot/agents/rsl_rl_ppo_cfg.py | 2 +- .../locomotion/velocity/config/spot/flat_env_cfg.py | 2 +- .../manager_based/locomotion/velocity/velocity_env_cfg.py | 2 +- .../manipulation/assemble_trocar/config/camera_config.py | 2 +- .../manipulation/assemble_trocar/config/robot_config.py | 2 +- .../manipulation/assemble_trocar/g129_dex3_env_cfg.py | 2 +- .../manager_based/manipulation/cabinet/cabinet_env_cfg.py | 2 +- .../cabinet/config/franka/agents/rsl_rl_ppo_cfg.py | 2 +- .../manipulation/cabinet/config/franka/ik_abs_env_cfg.py | 2 +- .../manipulation/cabinet/config/franka/ik_rel_env_cfg.py | 2 +- .../manipulation/cabinet/config/franka/joint_pos_env_cfg.py | 2 +- .../cabinet/config/openarm/agents/rsl_rl_ppo_cfg.py | 2 +- .../cabinet/config/openarm/cabinet_openarm_env_cfg.py | 2 +- .../cabinet/config/openarm/joint_pos_env_cfg.py | 2 +- .../gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py | 2 +- .../gear_assembly/config/rizon_4s/joint_pos_env_cfg.py | 2 +- .../gear_assembly/config/rizon_4s/ros_inference_env_cfg.py | 2 +- .../gear_assembly/config/ur_10e/agents/rsl_rl_ppo_cfg.py | 2 +- .../deploy/gear_assembly/config/ur_10e/joint_pos_env_cfg.py | 2 +- .../gear_assembly/config/ur_10e/ros_inference_env_cfg.py | 2 +- .../deploy/gear_assembly/gear_assembly_env_cfg.py | 2 +- .../manager_based/manipulation/deploy/mdp/noise_models.py | 2 +- .../deploy/reach/config/rizon_4s/agents/rsl_rl_ppo_cfg.py | 2 +- .../deploy/reach/config/rizon_4s/joint_pos_env_cfg.py | 2 +- .../deploy/reach/config/rizon_4s/ros_inference_env_cfg.py | 2 +- .../deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py | 2 +- .../deploy/reach/config/ur_10e/joint_pos_env_cfg.py | 2 +- .../deploy/reach/config/ur_10e/ros_inference_env_cfg.py | 2 +- .../manipulation/deploy/reach/reach_env_cfg.py | 2 +- .../manager_based/manipulation/dexsuite/adr_curriculum.py | 2 +- .../dexsuite/config/kuka_allegro/agents/rsl_rl_ppo_cfg.py | 2 +- .../manipulation/dexsuite/config/kuka_allegro/camera_cfg.py | 2 +- .../config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py | 2 +- .../manager_based/manipulation/dexsuite/dexsuite_env_cfg.py | 2 +- .../manipulation/dexsuite/mdp/commands/pose_commands_cfg.py | 2 +- .../inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py | 2 +- .../inhand/config/allegro_hand/allegro_env_cfg.py | 2 +- .../manager_based/manipulation/inhand/inhand_env_cfg.py | 2 +- .../manipulation/inhand/mdp/commands/commands_cfg.py | 2 +- .../lift/config/franka/agents/rsl_rl_ppo_cfg.py | 2 +- .../manipulation/lift/config/franka/ik_abs_env_cfg.py | 2 +- .../manipulation/lift/config/franka/ik_rel_env_cfg.py | 2 +- .../manipulation/lift/config/franka/joint_pos_env_cfg.py | 2 +- .../lift/config/openarm/agents/rsl_rl_ppo_cfg.py | 2 +- .../manipulation/lift/config/openarm/joint_pos_env_cfg.py | 2 +- .../lift/config/openarm/lift_openarm_env_cfg.py | 2 +- .../manager_based/manipulation/lift/lift_env_cfg.py | 2 +- .../pick_place/exhaustpipe_gr1t2_base_env_cfg.py | 2 +- .../pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py | 2 +- .../manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py | 2 +- .../pick_place/nutpour_gr1t2_pink_ik_env_cfg.py | 2 +- .../manipulation/pick_place/pickplace_gr1t2_env_cfg.py | 2 +- .../pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py | 2 +- .../pick_place/pickplace_unitree_g1_inspire_hand_env_cfg.py | 2 +- .../place/config/agibot/place_toy2box_rmp_rel_env_cfg.py | 2 +- .../config/agibot/place_upright_mug_rmp_rel_env_cfg.py | 2 +- .../reach/config/franka/agents/rsl_rl_ppo_cfg.py | 2 +- .../manipulation/reach/config/franka/ik_abs_env_cfg.py | 2 +- .../manipulation/reach/config/franka/ik_rel_env_cfg.py | 2 +- .../manipulation/reach/config/franka/joint_pos_env_cfg.py | 2 +- .../manipulation/reach/config/franka/osc_env_cfg.py | 2 +- .../reach/config/openarm/bimanual/agents/rsl_rl_ppo_cfg.py | 2 +- .../reach/config/openarm/bimanual/joint_pos_env_cfg.py | 2 +- .../config/openarm/bimanual/reach_openarm_bi_env_cfg.py | 2 +- .../reach/config/openarm/unimanual/agents/rsl_rl_ppo_cfg.py | 2 +- .../reach/config/openarm/unimanual/joint_pos_env_cfg.py | 2 +- .../config/openarm/unimanual/reach_openarm_uni_env_cfg.py | 2 +- .../reach/config/ur_10/agents/rsl_rl_ppo_cfg.py | 2 +- .../manipulation/reach/config/ur_10/joint_pos_env_cfg.py | 2 +- .../manager_based/manipulation/reach/reach_env_cfg.py | 2 +- .../stack/config/franka/bin_stack_ik_rel_env_cfg.py | 2 +- .../stack/config/franka/bin_stack_joint_pos_env_cfg.py | 2 +- .../stack/config/franka/stack_ik_abs_env_cfg.py | 2 +- .../stack/config/franka/stack_ik_rel_blueprint_env_cfg.py | 2 +- .../stack/config/franka/stack_ik_rel_env_cfg.py | 2 +- .../stack/config/franka/stack_ik_rel_env_cfg_skillgen.py | 2 +- .../franka/stack_ik_rel_instance_randomize_env_cfg.py | 2 +- .../config/franka/stack_ik_rel_visuomotor_cosmos_env_cfg.py | 2 +- .../stack/config/franka/stack_ik_rel_visuomotor_env_cfg.py | 2 +- .../stack/config/franka/stack_joint_pos_env_cfg.py | 2 +- .../franka/stack_joint_pos_instance_randomize_env_cfg.py | 2 +- .../stack/config/galbot/stack_joint_pos_env_cfg.py | 2 +- .../stack/config/galbot/stack_rmp_rel_env_cfg.py | 2 +- .../stack/config/ur10_gripper/stack_ik_rel_env_cfg.py | 2 +- .../stack/config/ur10_gripper/stack_joint_pos_env_cfg.py | 2 +- .../manager_based/manipulation/stack/stack_env_cfg.py | 2 +- .../manipulation/stack/stack_instance_randomize_env_cfg.py | 2 +- .../navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py | 2 +- .../navigation/config/anymal_c/navigation_env_cfg.py | 2 +- .../navigation/mdp/pre_trained_policy_action_cfg.py | 2 +- source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py | 3 ++- source/isaaclab_tasks/isaaclab_tasks/utils/presets.py | 2 +- source/isaaclab_tasks/test/test_hydra.py | 2 +- source/isaaclab_tasks/test/test_preset_cli.py | 4 ++-- .../direct/allegro_hand/allegro_hand_warp_env_cfg.py | 2 +- .../isaaclab_tasks_experimental/direct/ant/ant_env_warp.py | 2 +- .../direct/cartpole/cartpole_warp_env.py | 2 +- .../direct/humanoid/humanoid_warp_env.py | 2 +- .../manager_based/classic/ant/ant_env_cfg.py | 2 +- .../manager_based/classic/cartpole/cartpole_env_cfg.py | 2 +- .../manager_based/classic/humanoid/humanoid_env_cfg.py | 2 +- .../locomotion/velocity/config/a1/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/a1/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/anymal_b/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/anymal_b/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/anymal_c/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/anymal_c/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/anymal_d/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/anymal_d/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/cassie/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/cassie/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/g1/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/g1/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/go1/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/go1/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/go2/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/go2/rough_env_cfg.py | 2 +- .../locomotion/velocity/config/h1/flat_env_cfg.py | 2 +- .../locomotion/velocity/config/h1/rough_env_cfg.py | 2 +- .../manager_based/locomotion/velocity/velocity_env_cfg.py | 2 +- .../manipulation/reach/config/franka/joint_pos_env_cfg.py | 2 +- .../manipulation/reach/config/ur_10/joint_pos_env_cfg.py | 2 +- .../manager_based/manipulation/reach/reach_env_cfg.py | 2 +- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py | 2 +- source/isaaclab_teleop/isaaclab_teleop/xr_cfg.py | 2 +- source/isaaclab_teleop/test/test_oxr_device.py | 2 +- .../isaaclab_visualizers/kit/kit_visualizer_cfg.py | 2 +- .../isaaclab_visualizers/newton/newton_visualizer_cfg.py | 2 +- .../isaaclab_visualizers/rerun/rerun_visualizer_cfg.py | 2 +- .../isaaclab_visualizers/viser/viser_visualizer_cfg.py | 2 +- tools/template/templates/agents/rsl_rl_ppo_cfg | 2 +- tools/template/templates/tasks/direct_multi-agent/env_cfg | 2 +- tools/template/templates/tasks/direct_single-agent/env_cfg | 2 +- .../templates/tasks/manager-based_single-agent/env_cfg | 2 +- 439 files changed, 440 insertions(+), 436 deletions(-) create mode 100644 source/isaaclab/changelog.d/pbarejko-fix-lazy-import.skip create mode 100644 source/isaaclab_contrib/changelog.d/pbarejko-fix-lazy-import.skip create mode 100644 source/isaaclab_experimental/changelog.d/pbarejko-fix-lazy-import.skip create mode 100644 source/isaaclab_mimic/changelog.d/pbarejko-fix-lazy-import.skip create mode 100644 source/isaaclab_newton/changelog.d/pbarejko-fix-lazy-import.skip create mode 100644 source/isaaclab_ov/changelog.d/pbarejko-fix-lazy-import.skip create mode 100644 source/isaaclab_ovphysx/changelog.d/pbarejko-fix-lazy-import.skip create mode 100644 source/isaaclab_physx/changelog.d/pbarejko-fix-lazy-import.skip create mode 100644 source/isaaclab_rl/changelog.d/pbarejko-fix-lazy-import.skip create mode 100644 source/isaaclab_tasks/changelog.d/pbarejko-fix-lazy-import.skip create mode 100644 source/isaaclab_teleop/changelog.d/pbarejko-fix-lazy-import.skip diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2fad4dd6eaae..0fe48f5d5891 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -109,9 +109,9 @@ jobs: echo "|---|---|---|" for entry in "${patterns[@]}"; do IFS=$'\t' read -r regex desc <<< "$entry" - count=$(printf '%s\n' "$files" | grep -cE "$regex" || true) + count=$(grep -cE "$regex" <<< "$files" || true) if [ "$count" -gt 0 ]; then - sample=$(printf '%s\n' "$files" | grep -E "$regex" | head -3 | paste -sd ', ' -) + sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" echo "| \`$regex\` | $desc | $sample |" else @@ -124,7 +124,7 @@ jobs: local files="$1" entry regex for entry in "${patterns[@]}"; do IFS=$'\t' read -r regex _ <<< "$entry" - if printf '%s\n' "$files" | grep -qE "$regex"; then + if grep -qE "$regex" <<< "$files"; then return 0 fi done diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index c66e1bf4fce5..9a720575118c 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -68,9 +68,9 @@ jobs: echo "|---|---|---|" for entry in "${patterns[@]}"; do IFS=$'\t' read -r regex desc <<< "$entry" - count=$(printf '%s\n' "$files" | grep -cE "$regex" || true) + count=$(grep -cE "$regex" <<< "$files" || true) if [ "$count" -gt 0 ]; then - sample=$(printf '%s\n' "$files" | grep -E "$regex" | head -3 | paste -sd ', ' -) + sample=$(grep -m 3 -E "$regex" <<< "$files" | paste -sd ', ' -) [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" echo "| \`$regex\` | $desc | $sample |" else @@ -83,7 +83,7 @@ jobs: local files="$1" entry regex for entry in "${patterns[@]}"; do IFS=$'\t' read -r regex _ <<< "$entry" - if printf '%s\n' "$files" | grep -qE "$regex"; then + if grep -qE "$regex" <<< "$files"; then return 0 fi done diff --git a/docs/source/features/hydra.rst b/docs/source/features/hydra.rst index 25b5d4b8bdf1..e3afa45c0065 100644 --- a/docs/source/features/hydra.rst +++ b/docs/source/features/hydra.rst @@ -141,7 +141,7 @@ combinations early with clear error messages. .. code-block:: python - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass @configclass class MyEnvCfg: @@ -252,7 +252,7 @@ Physics backend selection uses the same preset system. A task can define a .. code-block:: python - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab_tasks.utils import PresetCfg diff --git a/docs/source/how-to/cloning.rst b/docs/source/how-to/cloning.rst index ce513cd29497..4377923bfa45 100644 --- a/docs/source/how-to/cloning.rst +++ b/docs/source/how-to/cloning.rst @@ -246,7 +246,7 @@ paths: import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass from isaaclab_assets.robots.cartpole import CARTPOLE_CFG diff --git a/docs/source/how-to/record_video.rst b/docs/source/how-to/record_video.rst index 335ba7192557..f8b7ddef1772 100644 --- a/docs/source/how-to/record_video.rst +++ b/docs/source/how-to/record_video.rst @@ -126,7 +126,7 @@ uses the same configured viewpoint as the interactive viewport: from isaaclab.envs import ManagerBasedRLEnvCfg from isaaclab.envs.common import ViewerCfg - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass @configclass class MyTaskCfg(ManagerBasedRLEnvCfg): diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index d660aa6e62ae..efa7d8678023 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -589,7 +589,7 @@ when no CLI override is given. Other fields are named presets selectable with .. code-block:: python from isaaclab_tasks.utils import PresetCfg - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass @configclass class MyPhysicsCfg(PresetCfg): diff --git a/docs/source/overview/core-concepts/multi_backend_architecture.rst b/docs/source/overview/core-concepts/multi_backend_architecture.rst index e56be6fbd133..352245c76ac7 100644 --- a/docs/source/overview/core-concepts/multi_backend_architecture.rst +++ b/docs/source/overview/core-concepts/multi_backend_architecture.rst @@ -139,7 +139,7 @@ Environments can support multiple backends simultaneously using the :doc:`preset .. code-block:: python - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg from isaaclab_physx.physics import PhysxCfg from isaaclab_newton.physics import NewtonCfg, MJWarpSolverCfg @@ -334,7 +334,7 @@ transforms in a Warp-native format that renderers and visualizers consume direct # isaaclab_mybackend/physics/mybackend_manager_cfg.py from isaaclab.physics import PhysicsCfg - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass @configclass class MyBackendCfg(PhysicsCfg): diff --git a/docs/source/refs/contributing.rst b/docs/source/refs/contributing.rst index 9a94688ca5fe..a49f74214050 100644 --- a/docs/source/refs/contributing.rst +++ b/docs/source/refs/contributing.rst @@ -506,7 +506,7 @@ to avoid importing it: from __future__ import annotations import typing - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass if typing.TYPE_CHECKING: from .sensor import Sensor diff --git a/docs/source/setup/walkthrough/api_env_design.rst b/docs/source/setup/walkthrough/api_env_design.rst index 07471ec2ea5a..a669bc47fa1c 100644 --- a/docs/source/setup/walkthrough/api_env_design.rst +++ b/docs/source/setup/walkthrough/api_env_design.rst @@ -14,7 +14,7 @@ and the contents of ``isaac_lab_tutorial_env_cfg.py``. You should see something from isaaclab.envs import DirectRLEnvCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass @configclass diff --git a/docs/source/setup/walkthrough/technical_env_design.rst b/docs/source/setup/walkthrough/technical_env_design.rst index 982a579f6831..41b99445c135 100644 --- a/docs/source/setup/walkthrough/technical_env_design.rst +++ b/docs/source/setup/walkthrough/technical_env_design.rst @@ -58,7 +58,7 @@ replace its contents with the following from isaaclab.envs import DirectRLEnvCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass @configclass class IsaacLabTutorialEnvCfg(DirectRLEnvCfg): diff --git a/pyproject.toml b/pyproject.toml index dd324e22ef97..572395018482 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "isaaclab-dev" version = "0.1.0" description = "Isaac Lab source checkout development environment." -requires-python = ">=3.12" +requires-python = ">=3.12,<3.13" dependencies = [ "isaaclab", "isaaclab-assets", diff --git a/scripts/benchmarks/benchmark_load_robot.py b/scripts/benchmarks/benchmark_load_robot.py index 490a8bd0a064..d10ac6365323 100644 --- a/scripts/benchmarks/benchmark_load_robot.py +++ b/scripts/benchmarks/benchmark_load_robot.py @@ -63,7 +63,7 @@ from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sim import SimulationContext from isaaclab.test.benchmark import BaseIsaacLabBenchmark, SingleMeasurement -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/scripts/benchmarks/benchmark_view_comparison.py b/scripts/benchmarks/benchmark_view_comparison.py index a637f687803e..aa5927e10b6a 100644 --- a/scripts/benchmarks/benchmark_view_comparison.py +++ b/scripts/benchmarks/benchmark_view_comparison.py @@ -141,7 +141,7 @@ def benchmark_newton(num_iterations: int) -> dict[str, float]: from isaaclab.assets import RigidObjectCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sim import SimulationCfg, build_simulation_context - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass timing_results = {} diff --git a/scripts/benchmarks/benchmark_xform_prim_view.py b/scripts/benchmarks/benchmark_xform_prim_view.py index b682c03f71fc..fee3b9642c79 100644 --- a/scripts/benchmarks/benchmark_xform_prim_view.py +++ b/scripts/benchmarks/benchmark_xform_prim_view.py @@ -54,7 +54,7 @@ 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 +from isaaclab.utils.configclass import configclass @configclass diff --git a/scripts/demos/bin_packing.py b/scripts/demos/bin_packing.py index 20d2d656cd86..fd0f02a4a4af 100644 --- a/scripts/demos/bin_packing.py +++ b/scripts/demos/bin_packing.py @@ -52,8 +52,9 @@ from isaaclab.assets import AssetBaseCfg, RigidObjectCfg, RigidObjectCollection, RigidObjectCollectionCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sim import SimulationContext -from isaaclab.utils import Timer, configclass +from isaaclab.utils import Timer from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass ## # Scene Configuration diff --git a/scripts/demos/haply_teleoperation.py b/scripts/demos/haply_teleoperation.py index 0f24ebd4d759..9880cae46f2e 100644 --- a/scripts/demos/haply_teleoperation.py +++ b/scripts/demos/haply_teleoperation.py @@ -66,8 +66,8 @@ from isaaclab.devices import HaplyDevice, HaplyDeviceCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import ContactSensor, ContactSensorCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_assets import FRANKA_PANDA_HIGH_PD_CFG # isort: skip diff --git a/scripts/demos/multi_asset.py b/scripts/demos/multi_asset.py index 8091c71afe0e..907c993e9549 100644 --- a/scripts/demos/multi_asset.py +++ b/scripts/demos/multi_asset.py @@ -54,8 +54,9 @@ from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sim import SimulationContext from isaaclab.sim.utils.stage import get_current_stage -from isaaclab.utils import Timer, configclass +from isaaclab.utils import Timer from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass ## # Pre-defined Configuration diff --git a/scripts/demos/pick_and_place.py b/scripts/demos/pick_and_place.py index 0308706ce38d..d5e77aa4a12a 100644 --- a/scripts/demos/pick_and_place.py +++ b/scripts/demos/pick_and_place.py @@ -46,7 +46,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.sim.spawners.from_files import GroundPlaneCfg, spawn_ground_plane -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.math import sample_uniform from isaaclab_assets.robots.pick_and_place import PICK_AND_PLACE_CFG diff --git a/scripts/demos/sensors/cameras.py b/scripts/demos/sensors/cameras.py index 400881ac5d62..5e1fc37f5154 100644 --- a/scripts/demos/sensors/cameras.py +++ b/scripts/demos/sensors/cameras.py @@ -51,7 +51,7 @@ from isaaclab.sensors import CameraCfg, RayCasterCameraCfg from isaaclab.sensors.ray_caster import patterns from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/scripts/demos/sensors/contact_sensor.py b/scripts/demos/sensors/contact_sensor.py index 37ddd14e28a6..a8cc37aeff93 100644 --- a/scripts/demos/sensors/contact_sensor.py +++ b/scripts/demos/sensors/contact_sensor.py @@ -31,7 +31,7 @@ from isaaclab.assets import AssetBaseCfg, RigidObjectCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import ContactSensorCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/scripts/demos/sensors/frame_transformer_sensor.py b/scripts/demos/sensors/frame_transformer_sensor.py index 039e38935547..99e5cab6636d 100644 --- a/scripts/demos/sensors/frame_transformer_sensor.py +++ b/scripts/demos/sensors/frame_transformer_sensor.py @@ -29,7 +29,7 @@ from isaaclab.assets import AssetBaseCfg, RigidObjectCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import FrameTransformerCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/scripts/demos/sensors/imu_sensor.py b/scripts/demos/sensors/imu_sensor.py index beb701d62074..22eb1729e75b 100644 --- a/scripts/demos/sensors/imu_sensor.py +++ b/scripts/demos/sensors/imu_sensor.py @@ -31,7 +31,7 @@ from isaaclab.assets import AssetBaseCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import ImuCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/scripts/demos/sensors/multi_mesh_raycaster.py b/scripts/demos/sensors/multi_mesh_raycaster.py index c154a6181763..6ff142f680af 100644 --- a/scripts/demos/sensors/multi_mesh_raycaster.py +++ b/scripts/demos/sensors/multi_mesh_raycaster.py @@ -57,8 +57,8 @@ from isaaclab.markers.config import VisualizationMarkersCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors.ray_caster import MultiMeshRayCasterCfg, patterns -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/scripts/demos/sensors/multi_mesh_raycaster_camera.py b/scripts/demos/sensors/multi_mesh_raycaster_camera.py index 5d1dfd39a07d..4bbc0769e6ba 100644 --- a/scripts/demos/sensors/multi_mesh_raycaster_camera.py +++ b/scripts/demos/sensors/multi_mesh_raycaster_camera.py @@ -57,8 +57,8 @@ from isaaclab.markers.config import VisualizationMarkersCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors.ray_caster import MultiMeshRayCasterCameraCfg, patterns -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/scripts/demos/sensors/pva_sensor.py b/scripts/demos/sensors/pva_sensor.py index 31066fb59164..746bd1bc8452 100644 --- a/scripts/demos/sensors/pva_sensor.py +++ b/scripts/demos/sensors/pva_sensor.py @@ -31,7 +31,7 @@ from isaaclab.assets import AssetBaseCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import PvaCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/scripts/demos/sensors/raycaster_sensor.py b/scripts/demos/sensors/raycaster_sensor.py index 25073af40487..2ae95f85e64d 100644 --- a/scripts/demos/sensors/raycaster_sensor.py +++ b/scripts/demos/sensors/raycaster_sensor.py @@ -30,8 +30,8 @@ from isaaclab.assets import AssetBaseCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors.ray_caster import RayCasterCfg, patterns -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/scripts/demos/sensors/tacsl_sensor.py b/scripts/demos/sensors/tacsl_sensor.py index 22f40e8c9ff2..b23bac972bba 100644 --- a/scripts/demos/sensors/tacsl_sensor.py +++ b/scripts/demos/sensors/tacsl_sensor.py @@ -83,8 +83,8 @@ from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import CameraCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.timer import Timer from isaaclab_contrib.sensors.tacsl_sensor import VisuoTactileSensorCfg diff --git a/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py b/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py index 0b43f5947a7b..1767d861b338 100644 --- a/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py +++ b/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py @@ -61,7 +61,7 @@ from isaaclab.envs import ManagerBasedRLMimicEnv from isaaclab.envs.mdp.recorders.recorders_cfg import ActionStateRecorderManagerCfg from isaaclab.managers import RecorderTerm, RecorderTermCfg, TerminationTermCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler import isaaclab_tasks # noqa: F401 diff --git a/scripts/imitation_learning/isaaclab_mimic/consolidated_demo.py b/scripts/imitation_learning/isaaclab_mimic/consolidated_demo.py index d180dffd7ccf..12bbf86b4cda 100644 --- a/scripts/imitation_learning/isaaclab_mimic/consolidated_demo.py +++ b/scripts/imitation_learning/isaaclab_mimic/consolidated_demo.py @@ -85,7 +85,7 @@ from isaaclab.envs import ManagerBasedRLMimicEnv from isaaclab.envs.mdp.recorders.recorders_cfg import ActionStateRecorderManagerCfg from isaaclab.managers import DatasetExportMode, RecorderTerm, RecorderTermCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.datasets import HDF5DatasetFileHandler import isaaclab_mimic.envs # noqa: F401 diff --git a/scripts/imitation_learning/locomanipulation_sdg/generate_data.py b/scripts/imitation_learning/locomanipulation_sdg/generate_data.py index 99014e75ff1d..63b3e30f5ccd 100644 --- a/scripts/imitation_learning/locomanipulation_sdg/generate_data.py +++ b/scripts/imitation_learning/locomanipulation_sdg/generate_data.py @@ -142,7 +142,7 @@ import omni.usd from isaaclab.managers import DatasetExportMode -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler from isaaclab.utils.math import convert_quat from isaaclab.utils.seed import configure_seed diff --git a/scripts/tutorials/02_scene/create_scene.py b/scripts/tutorials/02_scene/create_scene.py index 1d4578ca256c..b60234fbb32d 100644 --- a/scripts/tutorials/02_scene/create_scene.py +++ b/scripts/tutorials/02_scene/create_scene.py @@ -39,7 +39,7 @@ from isaaclab.assets import ArticulationCfg, AssetBaseCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sim import SimulationContext -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/scripts/tutorials/03_envs/create_cartpole_base_env.py b/scripts/tutorials/03_envs/create_cartpole_base_env.py index 35c3650e6811..ef0105ae6364 100644 --- a/scripts/tutorials/03_envs/create_cartpole_base_env.py +++ b/scripts/tutorials/03_envs/create_cartpole_base_env.py @@ -45,7 +45,7 @@ from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.classic.cartpole.cartpole_env_cfg import CartpoleSceneCfg diff --git a/scripts/tutorials/03_envs/create_cube_base_env.py b/scripts/tutorials/03_envs/create_cube_base_env.py index f6629c6bbb82..f1deeabc927e 100644 --- a/scripts/tutorials/03_envs/create_cube_base_env.py +++ b/scripts/tutorials/03_envs/create_cube_base_env.py @@ -62,7 +62,7 @@ from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.scene import InteractiveSceneCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Custom action term diff --git a/scripts/tutorials/03_envs/create_quadruped_base_env.py b/scripts/tutorials/03_envs/create_quadruped_base_env.py index 78f5b75ec5f8..8f287b5005ff 100644 --- a/scripts/tutorials/03_envs/create_quadruped_base_env.py +++ b/scripts/tutorials/03_envs/create_quadruped_base_env.py @@ -52,8 +52,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors import RayCasterCfg, patterns from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, check_file_path, read_file +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import AdditiveUniformNoiseCfg as Unoise ## diff --git a/scripts/tutorials/04_sensors/add_sensors_on_robot.py b/scripts/tutorials/04_sensors/add_sensors_on_robot.py index 4c143318f27a..a89c1f910bc7 100644 --- a/scripts/tutorials/04_sensors/add_sensors_on_robot.py +++ b/scripts/tutorials/04_sensors/add_sensors_on_robot.py @@ -45,7 +45,7 @@ from isaaclab.assets import ArticulationCfg, AssetBaseCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import CameraCfg, ContactSensorCfg, RayCasterCfg, patterns -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/scripts/tutorials/05_controllers/run_diff_ik.py b/scripts/tutorials/05_controllers/run_diff_ik.py index a4e88b9d215e..b3825dcd2945 100644 --- a/scripts/tutorials/05_controllers/run_diff_ik.py +++ b/scripts/tutorials/05_controllers/run_diff_ik.py @@ -46,8 +46,8 @@ from isaaclab.markers import VisualizationMarkers from isaaclab.markers.config import FRAME_MARKER_CFG from isaaclab.scene import InteractiveScene, InteractiveSceneCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.math import subtract_frame_transforms ## diff --git a/scripts/tutorials/05_controllers/run_osc.py b/scripts/tutorials/05_controllers/run_osc.py index 362efd5f5ee4..c37ace736ef9 100644 --- a/scripts/tutorials/05_controllers/run_osc.py +++ b/scripts/tutorials/05_controllers/run_osc.py @@ -45,7 +45,7 @@ from isaaclab.markers.config import FRAME_MARKER_CFG from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import ContactSensorCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.math import ( combine_frame_transforms, matrix_from_quat, diff --git a/source/isaaclab/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab/changelog.d/pbarejko-fix-lazy-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/isaaclab/actuators/actuator_base_cfg.py b/source/isaaclab/isaaclab/actuators/actuator_base_cfg.py index 99277f9d6139..8afa1807754d 100644 --- a/source/isaaclab/isaaclab/actuators/actuator_base_cfg.py +++ b/source/isaaclab/isaaclab/actuators/actuator_base_cfg.py @@ -7,7 +7,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/actuators/actuator_net_cfg.py b/source/isaaclab/isaaclab/actuators/actuator_net_cfg.py index ab8f53151b70..4a2d9ff30465 100644 --- a/source/isaaclab/isaaclab/actuators/actuator_net_cfg.py +++ b/source/isaaclab/isaaclab/actuators/actuator_net_cfg.py @@ -7,7 +7,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING, Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .actuator_pd_cfg import DCMotorCfg diff --git a/source/isaaclab/isaaclab/actuators/actuator_pd_cfg.py b/source/isaaclab/isaaclab/actuators/actuator_pd_cfg.py index a57a7b3b7f6a..c4cad3e69f41 100644 --- a/source/isaaclab/isaaclab/actuators/actuator_pd_cfg.py +++ b/source/isaaclab/isaaclab/actuators/actuator_pd_cfg.py @@ -6,7 +6,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .actuator_base_cfg import ActuatorBaseCfg diff --git a/source/isaaclab/isaaclab/assets/articulation/articulation_cfg.py b/source/isaaclab/isaaclab/assets/articulation/articulation_cfg.py index d3d94a9b23e0..b7514fb8a91c 100644 --- a/source/isaaclab/isaaclab/assets/articulation/articulation_cfg.py +++ b/source/isaaclab/isaaclab/assets/articulation/articulation_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from isaaclab.actuators import ActuatorBaseCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..asset_base_cfg import AssetBaseCfg diff --git a/source/isaaclab/isaaclab/assets/asset_base_cfg.py b/source/isaaclab/isaaclab/assets/asset_base_cfg.py index 4575acc08452..095bb6afd9cd 100644 --- a/source/isaaclab/isaaclab/assets/asset_base_cfg.py +++ b/source/isaaclab/isaaclab/assets/asset_base_cfg.py @@ -9,7 +9,7 @@ from typing import Literal from isaaclab.sim import SpawnerCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/assets/rigid_object/rigid_object_cfg.py b/source/isaaclab/isaaclab/assets/rigid_object/rigid_object_cfg.py index 099bbdeea784..ae1f881fae89 100644 --- a/source/isaaclab/isaaclab/assets/rigid_object/rigid_object_cfg.py +++ b/source/isaaclab/isaaclab/assets/rigid_object/rigid_object_cfg.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..asset_base_cfg import AssetBaseCfg diff --git a/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection_cfg.py b/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection_cfg.py index a094a8968dd5..28c8f8998709 100644 --- a/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection_cfg.py +++ b/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection_cfg.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from isaaclab.assets.rigid_object import RigidObjectCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .rigid_object_collection import RigidObjectCollection diff --git a/source/isaaclab/isaaclab/cloner/cloner_cfg.py b/source/isaaclab/isaaclab/cloner/cloner_cfg.py index 369f70e33520..477b4bbfc2c2 100644 --- a/source/isaaclab/isaaclab/cloner/cloner_cfg.py +++ b/source/isaaclab/isaaclab/cloner/cloner_cfg.py @@ -5,7 +5,7 @@ from __future__ import annotations -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .cloner_strategies import random diff --git a/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py b/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py index f596ce75f638..409ecc9429f5 100644 --- a/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py +++ b/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py @@ -8,7 +8,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING, Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .differential_ik import DifferentialIKController diff --git a/source/isaaclab/isaaclab/controllers/joint_impedance_cfg.py b/source/isaaclab/isaaclab/controllers/joint_impedance_cfg.py index 8245b0e50ab0..05a518043dd2 100644 --- a/source/isaaclab/isaaclab/controllers/joint_impedance_cfg.py +++ b/source/isaaclab/isaaclab/controllers/joint_impedance_cfg.py @@ -8,7 +8,7 @@ from collections.abc import Sequence from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/controllers/operational_space_cfg.py b/source/isaaclab/isaaclab/controllers/operational_space_cfg.py index 280caf7d32df..22f9fa193781 100644 --- a/source/isaaclab/isaaclab/controllers/operational_space_cfg.py +++ b/source/isaaclab/isaaclab/controllers/operational_space_cfg.py @@ -9,7 +9,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .operational_space import OperationalSpaceController diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py index bb0839fcf1bf..d62c45ee30e2 100644 --- a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py @@ -10,7 +10,7 @@ from dataclasses import field from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .pink_task_cfg import PinkIKTaskCfg diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/pink_task_cfg.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_task_cfg.py index c6472131babc..bdab08b68862 100644 --- a/source/isaaclab/isaaclab/controllers/pink_ik/pink_task_cfg.py +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_task_cfg.py @@ -10,7 +10,7 @@ from dataclasses import MISSING, field from typing import Any -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/controllers/rmp_flow_cfg.py b/source/isaaclab/isaaclab/controllers/rmp_flow_cfg.py index e94e94f2377b..a55954e6aafb 100644 --- a/source/isaaclab/isaaclab/controllers/rmp_flow_cfg.py +++ b/source/isaaclab/isaaclab/controllers/rmp_flow_cfg.py @@ -9,7 +9,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/devices/device_base.py b/source/isaaclab/isaaclab/devices/device_base.py index 00c7100ab523..e175dc1ef500 100644 --- a/source/isaaclab/isaaclab/devices/device_base.py +++ b/source/isaaclab/isaaclab/devices/device_base.py @@ -16,7 +16,7 @@ import torch from isaaclab.devices.retargeter_base import RetargeterBase, RetargeterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/devices/gamepad/se2_gamepad_cfg.py b/source/isaaclab/isaaclab/devices/gamepad/se2_gamepad_cfg.py index fc4239538070..f7156ae1f49c 100644 --- a/source/isaaclab/isaaclab/devices/gamepad/se2_gamepad_cfg.py +++ b/source/isaaclab/isaaclab/devices/gamepad/se2_gamepad_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..device_base import DeviceCfg diff --git a/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad_cfg.py b/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad_cfg.py index 123105fa10dc..2d21e3980713 100644 --- a/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad_cfg.py +++ b/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..device_base import DeviceCfg diff --git a/source/isaaclab/isaaclab/devices/haply/se3_haply.py b/source/isaaclab/isaaclab/devices/haply/se3_haply.py index f9e190de8a4a..871aee4689f3 100644 --- a/source/isaaclab/isaaclab/devices/haply/se3_haply.py +++ b/source/isaaclab/isaaclab/devices/haply/se3_haply.py @@ -16,7 +16,7 @@ import numpy as np import torch -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass try: import websockets diff --git a/source/isaaclab/isaaclab/devices/keyboard/se2_keyboard_cfg.py b/source/isaaclab/isaaclab/devices/keyboard/se2_keyboard_cfg.py index acecde3ae7d6..0e3679f82b31 100644 --- a/source/isaaclab/isaaclab/devices/keyboard/se2_keyboard_cfg.py +++ b/source/isaaclab/isaaclab/devices/keyboard/se2_keyboard_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..device_base import DeviceCfg diff --git a/source/isaaclab/isaaclab/devices/keyboard/se3_keyboard_cfg.py b/source/isaaclab/isaaclab/devices/keyboard/se3_keyboard_cfg.py index 3ac55205360a..5279dc82a124 100644 --- a/source/isaaclab/isaaclab/devices/keyboard/se3_keyboard_cfg.py +++ b/source/isaaclab/isaaclab/devices/keyboard/se3_keyboard_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..device_base import DeviceCfg diff --git a/source/isaaclab/isaaclab/devices/retargeter_base.py b/source/isaaclab/isaaclab/devices/retargeter_base.py index 0859dd5342cf..e474cb620870 100644 --- a/source/isaaclab/isaaclab/devices/retargeter_base.py +++ b/source/isaaclab/isaaclab/devices/retargeter_base.py @@ -19,7 +19,7 @@ from enum import Enum from typing import Any -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/devices/spacemouse/se2_spacemouse_cfg.py b/source/isaaclab/isaaclab/devices/spacemouse/se2_spacemouse_cfg.py index 91e6a823833a..1a287317a3ba 100644 --- a/source/isaaclab/isaaclab/devices/spacemouse/se2_spacemouse_cfg.py +++ b/source/isaaclab/isaaclab/devices/spacemouse/se2_spacemouse_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..device_base import DeviceCfg diff --git a/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse_cfg.py b/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse_cfg.py index ba63dae831b6..d6b8f1d4a206 100644 --- a/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse_cfg.py +++ b/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..device_base import DeviceCfg diff --git a/source/isaaclab/isaaclab/envs/common.py b/source/isaaclab/isaaclab/envs/common.py index 5da6f871361e..2bd55ad15a8a 100644 --- a/source/isaaclab/isaaclab/envs/common.py +++ b/source/isaaclab/isaaclab/envs/common.py @@ -12,7 +12,7 @@ import gymnasium as gym import torch -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Configuration. diff --git a/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py b/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py index 9f3e6a627b07..de8381e1050d 100644 --- a/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py @@ -13,7 +13,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import NoiseModelCfg from .common import AgentID, SpaceType, ViewerCfg diff --git a/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py b/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py index 69b917e1d01f..484338337e70 100644 --- a/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py @@ -12,7 +12,7 @@ from isaaclab.devices.openxr import XrCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import NoiseModelCfg from .common import SpaceType, ViewerCfg diff --git a/source/isaaclab/isaaclab/envs/manager_based_env_cfg.py b/source/isaaclab/isaaclab/envs/manager_based_env_cfg.py index 843701b03fd1..c76e472975d0 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env_cfg.py @@ -23,7 +23,7 @@ from isaaclab.managers import RecorderManagerBaseCfg as DefaultEmptyRecorderManagerCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .common import ViewerCfg from .utils.video_recorder_cfg import VideoRecorderCfg diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py index 102d4bc26cd7..8e85a3bd3ab4 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py @@ -7,7 +7,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .manager_based_env_cfg import ManagerBasedEnvCfg diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py b/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py index d86fe55ee436..35c1266fb578 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py @@ -10,7 +10,7 @@ from isaaclab.controllers import DifferentialIKControllerCfg, OperationalSpaceControllerCfg from isaaclab.managers.action_manager import ActionTermCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .binary_joint_actions import AbsBinaryJointPositionAction, BinaryJointPositionAction, BinaryJointVelocityAction diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py b/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py index b5910b64d0f6..05edc8a01ff2 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py @@ -8,7 +8,7 @@ from isaaclab.controllers.pink_ik import PinkIKControllerCfg from isaaclab.managers.action_manager import ActionTermCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .pink_task_space_actions import PinkInverseKinematicsAction diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_actions_cfg.py b/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_actions_cfg.py index a988785bddb3..2f9058cf1fb9 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_actions_cfg.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_actions_cfg.py @@ -10,7 +10,7 @@ from isaaclab.controllers.rmp_flow_cfg import RmpFlowControllerCfg from isaaclab.managers.action_manager import ActionTermCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .rmpflow_task_space_actions import RMPFlowAction diff --git a/source/isaaclab/isaaclab/envs/mdp/commands/commands_cfg.py b/source/isaaclab/isaaclab/envs/mdp/commands/commands_cfg.py index 34a7a6f994f2..9b6f7b0ff9ca 100644 --- a/source/isaaclab/isaaclab/envs/mdp/commands/commands_cfg.py +++ b/source/isaaclab/isaaclab/envs/mdp/commands/commands_cfg.py @@ -10,7 +10,7 @@ from isaaclab.managers import CommandTermCfg from isaaclab.markers import VisualizationMarkersCfg from isaaclab.markers.config import BLUE_ARROW_X_MARKER_CFG, FRAME_MARKER_CFG, GREEN_ARROW_X_MARKER_CFG -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .null_command import NullCommand diff --git a/source/isaaclab/isaaclab/envs/mdp/recorders/recorders_cfg.py b/source/isaaclab/isaaclab/envs/mdp/recorders/recorders_cfg.py index 473501591053..7c06d78d92d8 100644 --- a/source/isaaclab/isaaclab/envs/mdp/recorders/recorders_cfg.py +++ b/source/isaaclab/isaaclab/envs/mdp/recorders/recorders_cfg.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from isaaclab.managers.recorder_manager import RecorderManagerBaseCfg, RecorderTermCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .recorders import ( diff --git a/source/isaaclab/isaaclab/envs/mimic_env_cfg.py b/source/isaaclab/isaaclab/envs/mimic_env_cfg.py index a98c34bf5f90..e9760fdd1277 100644 --- a/source/isaaclab/isaaclab/envs/mimic_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/mimic_env_cfg.py @@ -15,7 +15,7 @@ import enum from isaaclab.managers.recorder_manager import RecorderManagerBaseCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/envs/utils/io_descriptors.py b/source/isaaclab/isaaclab/envs/utils/io_descriptors.py index 3a9d54f12e5a..f14ae14e5f78 100644 --- a/source/isaaclab/isaaclab/envs/utils/io_descriptors.py +++ b/source/isaaclab/isaaclab/envs/utils/io_descriptors.py @@ -8,7 +8,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: import torch diff --git a/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py b/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py index c0cbb1d9a11d..f795ad114aaf 100644 --- a/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py +++ b/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py @@ -14,7 +14,7 @@ from typing import Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .video_recorder import VideoRecorder diff --git a/source/isaaclab/isaaclab/managers/manager_base.py b/source/isaaclab/isaaclab/managers/manager_base.py index 405157a1548b..f898e904b0bf 100644 --- a/source/isaaclab/isaaclab/managers/manager_base.py +++ b/source/isaaclab/isaaclab/managers/manager_base.py @@ -37,7 +37,7 @@ class should also have a corresponding configuration class that defines the conf .. code-block:: python - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass from isaaclab.utils.mdp import ManagerBase, ManagerTermBaseCfg diff --git a/source/isaaclab/isaaclab/managers/manager_term_cfg.py b/source/isaaclab/isaaclab/managers/manager_term_cfg.py index 06f2516324b5..aa118f8e9784 100644 --- a/source/isaaclab/isaaclab/managers/manager_term_cfg.py +++ b/source/isaaclab/isaaclab/managers/manager_term_cfg.py @@ -13,7 +13,7 @@ import torch -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.modifiers import ModifierCfg from isaaclab.utils.noise import NoiseCfg, NoiseModelCfg diff --git a/source/isaaclab/isaaclab/managers/recorder_manager.py b/source/isaaclab/isaaclab/managers/recorder_manager.py index 2e6ebf0b2446..67a189736da0 100644 --- a/source/isaaclab/isaaclab/managers/recorder_manager.py +++ b/source/isaaclab/isaaclab/managers/recorder_manager.py @@ -15,7 +15,7 @@ import warp as wp from prettytable import PrettyTable -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler from .manager_base import ManagerBase, ManagerTermBase diff --git a/source/isaaclab/isaaclab/managers/scene_entity_cfg.py b/source/isaaclab/isaaclab/managers/scene_entity_cfg.py index 2fe118c2da26..40250ceeb4d0 100644 --- a/source/isaaclab/isaaclab/managers/scene_entity_cfg.py +++ b/source/isaaclab/isaaclab/managers/scene_entity_cfg.py @@ -10,7 +10,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from isaaclab.assets import Articulation, RigidObject, RigidObjectCollection diff --git a/source/isaaclab/isaaclab/renderers/renderer_cfg.py b/source/isaaclab/isaaclab/renderers/renderer_cfg.py index 334fa3f070ae..276add3a0727 100644 --- a/source/isaaclab/isaaclab/renderers/renderer_cfg.py +++ b/source/isaaclab/isaaclab/renderers/renderer_cfg.py @@ -5,7 +5,7 @@ """Base configuration for renderers.""" -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 6b13afb4565f..543dfdfc9d57 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -76,7 +76,7 @@ class InteractiveScene: .. code-block:: python from isaaclab.scene import InteractiveSceneCfg - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass from isaaclab_assets.robots.anymal import ANYMAL_C_CFG diff --git a/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py b/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py index f4328324152c..034307017219 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py @@ -29,7 +29,7 @@ class InteractiveSceneCfg: from isaaclab.assets import AssetBaseCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors.ray_caster import GridPatternCfg, RayCasterCfg - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass from isaaclab_assets.robots.anymal import ANYMAL_C_CFG diff --git a/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py b/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py index 3ecec15d11d3..02e834333d2b 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py @@ -11,7 +11,7 @@ from isaaclab.renderers import RendererCfg from isaaclab.sim import FisheyeCameraCfg, PinholeCameraCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..sensor_base_cfg import SensorBaseCfg diff --git a/source/isaaclab/isaaclab/sensors/camera/tiled_camera_cfg.py b/source/isaaclab/isaaclab/sensors/camera/tiled_camera_cfg.py index e200468daa78..05cc38777511 100644 --- a/source/isaaclab/isaaclab/sensors/camera/tiled_camera_cfg.py +++ b/source/isaaclab/isaaclab/sensors/camera/tiled_camera_cfg.py @@ -6,7 +6,7 @@ import warnings from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .camera_cfg import CameraCfg diff --git a/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_cfg.py b/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_cfg.py index e68117745237..4435b5fac64f 100644 --- a/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_cfg.py +++ b/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_cfg.py @@ -7,7 +7,7 @@ from isaaclab.markers import VisualizationMarkersCfg from isaaclab.markers.config import CONTACT_SENSOR_MARKER_CFG -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..sensor_base_cfg import SensorBaseCfg diff --git a/source/isaaclab/isaaclab/sensors/frame_transformer/frame_transformer_cfg.py b/source/isaaclab/isaaclab/sensors/frame_transformer/frame_transformer_cfg.py index b41c7d6d5f0d..03aec5ca9155 100644 --- a/source/isaaclab/isaaclab/sensors/frame_transformer/frame_transformer_cfg.py +++ b/source/isaaclab/isaaclab/sensors/frame_transformer/frame_transformer_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from isaaclab.markers.config import FRAME_MARKER_CFG, VisualizationMarkersCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..sensor_base_cfg import SensorBaseCfg diff --git a/source/isaaclab/isaaclab/sensors/imu/imu_cfg.py b/source/isaaclab/isaaclab/sensors/imu/imu_cfg.py index a095c7f27642..4797e0dc0339 100644 --- a/source/isaaclab/isaaclab/sensors/imu/imu_cfg.py +++ b/source/isaaclab/isaaclab/sensors/imu/imu_cfg.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..sensor_base_cfg import SensorBaseCfg diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py index 4bdb0ab74f06..d482603dd48f 100644 --- a/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..sensor_base_cfg import SensorBaseCfg diff --git a/source/isaaclab/isaaclab/sensors/pva/pva_cfg.py b/source/isaaclab/isaaclab/sensors/pva/pva_cfg.py index 38f3d627608a..5e7166ca1911 100644 --- a/source/isaaclab/isaaclab/sensors/pva/pva_cfg.py +++ b/source/isaaclab/isaaclab/sensors/pva/pva_cfg.py @@ -9,7 +9,7 @@ from isaaclab.markers import VisualizationMarkersCfg from isaaclab.markers.config import RED_ARROW_X_MARKER_CFG -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..sensor_base_cfg import SensorBaseCfg diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera_cfg.py b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera_cfg.py index c625ccffedf4..fa60dcf14722 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera_cfg.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera_cfg.py @@ -8,7 +8,7 @@ import logging from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .multi_mesh_ray_caster_cfg import MultiMeshRayCasterCfg from .ray_caster_camera_cfg import RayCasterCameraCfg diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_cfg.py b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_cfg.py index 3e7de9429c60..c88f3199bb48 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_cfg.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_cfg.py @@ -9,7 +9,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .ray_caster_cfg import RayCasterCfg diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/patterns/patterns_cfg.py b/source/isaaclab/isaaclab/sensors/ray_caster/patterns/patterns_cfg.py index aa946ffac7bc..71582913c6f3 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/patterns/patterns_cfg.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/patterns/patterns_cfg.py @@ -13,7 +13,7 @@ import torch -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import patterns 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 574c95020437..84238380aef8 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 @@ -8,7 +8,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING, Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .patterns import PinholeCameraPatternCfg from .ray_caster_cfg import RayCasterCfg 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 3e862e389c1e..0b864c8ee5cf 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_cfg.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_cfg.py @@ -13,7 +13,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 isaaclab.utils.configclass import configclass from ..sensor_base_cfg import SensorBaseCfg from .patterns.patterns_cfg import PatternBaseCfg diff --git a/source/isaaclab/isaaclab/sensors/sensor_base_cfg.py b/source/isaaclab/isaaclab/sensors/sensor_base_cfg.py index e2397233be3c..fcf1ecf476ab 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base_cfg.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base_cfg.py @@ -6,7 +6,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .sensor_base import SensorBase diff --git a/source/isaaclab/isaaclab/sim/converters/asset_converter_base_cfg.py b/source/isaaclab/isaaclab/sim/converters/asset_converter_base_cfg.py index 3ba2a27c18cb..706927fd350e 100644 --- a/source/isaaclab/isaaclab/sim/converters/asset_converter_base_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/asset_converter_base_cfg.py @@ -7,7 +7,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py index 549aecab2eff..73ec37e777b6 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py @@ -5,7 +5,7 @@ from isaaclab.sim.converters.asset_converter_base_cfg import AssetConverterBaseCfg from isaaclab.sim.schemas import schemas_cfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py index d08cdf493906..ca111cb494db 100644 --- a/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py @@ -8,7 +8,7 @@ from typing import Literal from isaaclab.sim.converters.asset_converter_base_cfg import AssetConverterBaseCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py index d1ed21b75215..49a2ef83b38f 100644 --- a/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py @@ -9,7 +9,7 @@ from typing import Literal from isaaclab.sim.converters.asset_converter_base_cfg import AssetConverterBaseCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index e54d71ca9d76..b154d91f4cc5 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -8,7 +8,7 @@ import warnings from typing import ClassVar, Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass # Names that moved out of this submodule into ``isaaclab_physx.sim.schemas.schemas_cfg``. # Resolved lazily so callers using ``from isaaclab.sim.schemas.schemas_cfg import diff --git a/source/isaaclab/isaaclab/sim/simulation_cfg.py b/source/isaaclab/isaaclab/sim/simulation_cfg.py index eb0f9821592c..8abed4cbfc17 100644 --- a/source/isaaclab/isaaclab/sim/simulation_cfg.py +++ b/source/isaaclab/isaaclab/sim/simulation_cfg.py @@ -15,7 +15,7 @@ from isaaclab.physics import PhysicsCfg from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.visualizers import VisualizerCfg diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py index 87fc48de4d30..1d61cde5725d 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py @@ -14,8 +14,8 @@ from isaaclab.sim import converters, schemas from isaaclab.sim.spawners import materials from isaaclab.sim.spawners.spawner_cfg import RigidObjectSpawnerCfg, SpawnerCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/sim/spawners/lights/lights_cfg.py b/source/isaaclab/isaaclab/sim/spawners/lights/lights_cfg.py index 653e83a108cc..5016f092135b 100644 --- a/source/isaaclab/isaaclab/sim/spawners/lights/lights_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/lights/lights_cfg.py @@ -10,7 +10,7 @@ from typing import Literal from isaaclab.sim.spawners.spawner_cfg import SpawnerCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py index 0c9a7be478e8..886b049b77b0 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py @@ -9,7 +9,7 @@ from dataclasses import MISSING from typing import ClassVar -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass # Names that moved out of this submodule into ``isaaclab_physx.sim.spawners.materials.physics_materials_cfg``. # Resolved lazily so callers using ``from isaaclab.sim.spawners.materials.physics_materials_cfg diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/visual_materials_cfg.py b/source/isaaclab/isaaclab/sim/spawners/materials/visual_materials_cfg.py index 961b351b6ce4..0f2a88c2cd9c 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/visual_materials_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/visual_materials_cfg.py @@ -8,7 +8,7 @@ from collections.abc import Callable from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py index a9e7e44586d0..6bdf9ebf9baa 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py @@ -14,7 +14,7 @@ from isaaclab.sim.spawners import materials from isaaclab.sim.spawners.spawner_cfg import RigidObjectSpawnerCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/sim/spawners/sensors/sensors_cfg.py b/source/isaaclab/isaaclab/sim/spawners/sensors/sensors_cfg.py index 1ad9dcd73bf2..6a1741675074 100644 --- a/source/isaaclab/isaaclab/sim/spawners/sensors/sensors_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/sensors/sensors_cfg.py @@ -10,7 +10,7 @@ import isaaclab.utils.sensors as sensor_utils from isaaclab.sim.spawners.spawner_cfg import SpawnerCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py index 2baa75d19e66..b111bdbb2bf3 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py @@ -11,7 +11,7 @@ from isaaclab.sim.spawners import materials from isaaclab.sim.spawners.spawner_cfg import RigidObjectSpawnerCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py index 0adf9215f81e..3cc632e2c0d5 100644 --- a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py @@ -9,7 +9,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from pxr import Usd diff --git a/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py b/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py index e335393f7d94..dafe5beb812f 100644 --- a/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py @@ -15,7 +15,7 @@ Safe to ignore if using newton only. Complete exception: {e}""" ) # import dummy class to avoid errors in type hints - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass @configclass class DeformableObjectSpawnerCfg: @@ -24,7 +24,7 @@ class DeformableObjectSpawnerCfg: from isaaclab.sim.spawners.from_files import UsdFileCfg from isaaclab.sim.spawners.spawner_cfg import RigidObjectSpawnerCfg, SpawnerCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/isaaclab/terrains/height_field/hf_terrains_cfg.py b/source/isaaclab/isaaclab/terrains/height_field/hf_terrains_cfg.py index 069f87503e65..b237f934c14d 100644 --- a/source/isaaclab/isaaclab/terrains/height_field/hf_terrains_cfg.py +++ b/source/isaaclab/isaaclab/terrains/height_field/hf_terrains_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..sub_terrain_cfg import SubTerrainBaseCfg diff --git a/source/isaaclab/isaaclab/terrains/sub_terrain_cfg.py b/source/isaaclab/isaaclab/terrains/sub_terrain_cfg.py index 5167afd5eb68..6532b6e411bf 100644 --- a/source/isaaclab/isaaclab/terrains/sub_terrain_cfg.py +++ b/source/isaaclab/isaaclab/terrains/sub_terrain_cfg.py @@ -10,7 +10,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: import numpy as np diff --git a/source/isaaclab/isaaclab/terrains/terrain_generator_cfg.py b/source/isaaclab/isaaclab/terrains/terrain_generator_cfg.py index 605b8116e7dd..9d944c83fdf3 100644 --- a/source/isaaclab/isaaclab/terrains/terrain_generator_cfg.py +++ b/source/isaaclab/isaaclab/terrains/terrain_generator_cfg.py @@ -17,7 +17,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING, Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .sub_terrain_cfg import SubTerrainBaseCfg diff --git a/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py b/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py index 279c4c981ebb..db34c9a17195 100644 --- a/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py +++ b/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Literal import isaaclab.sim as sim_utils -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .terrain_generator_cfg import TerrainGeneratorCfg diff --git a/source/isaaclab/isaaclab/terrains/trimesh/mesh_terrains_cfg.py b/source/isaaclab/isaaclab/terrains/trimesh/mesh_terrains_cfg.py index 1e82a88aee77..da2cb580f8c3 100644 --- a/source/isaaclab/isaaclab/terrains/trimesh/mesh_terrains_cfg.py +++ b/source/isaaclab/isaaclab/terrains/trimesh/mesh_terrains_cfg.py @@ -7,7 +7,7 @@ from dataclasses import MISSING from typing import Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..sub_terrain_cfg import SubTerrainBaseCfg diff --git a/source/isaaclab/isaaclab/ui/widgets/manager_live_visualizer.py b/source/isaaclab/isaaclab/ui/widgets/manager_live_visualizer.py index cc0a2186c89e..fe6ae804486e 100644 --- a/source/isaaclab/isaaclab/ui/widgets/manager_live_visualizer.py +++ b/source/isaaclab/isaaclab/ui/widgets/manager_live_visualizer.py @@ -14,7 +14,7 @@ from isaaclab.managers import ManagerBase from isaaclab.sim import SimulationContext -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .image_plot import ImagePlot from .line_plot import LiveLinePlot diff --git a/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py b/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py index cf018fc07165..b0e6a84defb6 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py +++ b/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py @@ -9,7 +9,7 @@ import torch -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import modifier diff --git a/source/isaaclab/isaaclab/utils/noise/noise_cfg.py b/source/isaaclab/isaaclab/utils/noise/noise_cfg.py index 4265a12cda84..a1c6235ece6a 100644 --- a/source/isaaclab/isaaclab/utils/noise/noise_cfg.py +++ b/source/isaaclab/isaaclab/utils/noise/noise_cfg.py @@ -11,7 +11,7 @@ import torch -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .noise_model import NoiseModel, NoiseModelWithAdditiveBias diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 1ee4cde038b5..8a8c7ba7a21b 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .base_visualizer import BaseVisualizer diff --git a/source/isaaclab/test/app/test_non_headless_launch.py b/source/isaaclab/test/app/test_non_headless_launch.py index 8fc8a051ae38..5c7b5bad24cd 100644 --- a/source/isaaclab/test/app/test_non_headless_launch.py +++ b/source/isaaclab/test/app/test_non_headless_launch.py @@ -24,7 +24,7 @@ import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/test/controllers/test_operational_space.py b/source/isaaclab/test/controllers/test_operational_space.py index 80f1f8952623..bed0760271e7 100644 --- a/source/isaaclab/test/controllers/test_operational_space.py +++ b/source/isaaclab/test/controllers/test_operational_space.py @@ -36,7 +36,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors import ContactSensor, ContactSensorCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass as lab_configclass +from isaaclab.utils.configclass import configclass as lab_configclass from isaaclab.utils.math import ( apply_delta_pose, combine_frame_transforms, diff --git a/source/isaaclab/test/envs/check_manager_based_env_anymal_locomotion.py b/source/isaaclab/test/envs/check_manager_based_env_anymal_locomotion.py index 07dc3d73bd14..a505f3f7e504 100644 --- a/source/isaaclab/test/envs/check_manager_based_env_anymal_locomotion.py +++ b/source/isaaclab/test/envs/check_manager_based_env_anymal_locomotion.py @@ -46,8 +46,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors import RayCasterCfg, patterns from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, NVIDIA_NUCLEUS_DIR, check_file_path, read_file +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise ## diff --git a/source/isaaclab/test/envs/check_manager_based_env_floating_cube.py b/source/isaaclab/test/envs/check_manager_based_env_floating_cube.py index 4762cc855cc5..ef0a151434b5 100644 --- a/source/isaaclab/test/envs/check_manager_based_env_floating_cube.py +++ b/source/isaaclab/test/envs/check_manager_based_env_floating_cube.py @@ -43,7 +43,7 @@ from isaaclab.managers.action_manager import ActionTerm, ActionTermCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Scene definition diff --git a/source/isaaclab/test/envs/test_color_randomization.py b/source/isaaclab/test/envs/test_color_randomization.py index a7edda7036c1..5f03ff970855 100644 --- a/source/isaaclab/test/envs/test_color_randomization.py +++ b/source/isaaclab/test/envs/test_color_randomization.py @@ -29,7 +29,7 @@ from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.version import get_isaac_sim_version from isaaclab_tasks.manager_based.classic.cartpole.cartpole_env_cfg import CartpoleSceneCfg diff --git a/source/isaaclab/test/envs/test_direct_marl_env.py b/source/isaaclab/test/envs/test_direct_marl_env.py index 981ebd72203a..7454f21c876b 100644 --- a/source/isaaclab/test/envs/test_direct_marl_env.py +++ b/source/isaaclab/test/envs/test_direct_marl_env.py @@ -22,7 +22,7 @@ import isaaclab.sim as sim_utils from isaaclab.envs import DirectMARLEnv, DirectMARLEnvCfg from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/test/envs/test_env_rendering_logic.py b/source/isaaclab/test/envs/test_env_rendering_logic.py index 59299e2e93ee..ca05ef2f2e2d 100644 --- a/source/isaaclab/test/envs/test_env_rendering_logic.py +++ b/source/isaaclab/test/envs/test_env_rendering_logic.py @@ -29,7 +29,7 @@ ) from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg, SimulationContext -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass pytestmark = pytest.mark.isaacsim_ci diff --git a/source/isaaclab/test/envs/test_manager_based_env.py b/source/isaaclab/test/envs/test_manager_based_env.py index 153129a39263..aede8915b58c 100644 --- a/source/isaaclab/test/envs/test_manager_based_env.py +++ b/source/isaaclab/test/envs/test_manager_based_env.py @@ -25,7 +25,7 @@ from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/test/envs/test_manager_based_rl_env_ui.py b/source/isaaclab/test/envs/test_manager_based_rl_env_ui.py index 2a6157120848..678e28ffded4 100644 --- a/source/isaaclab/test/envs/test_manager_based_rl_env_ui.py +++ b/source/isaaclab/test/envs/test_manager_based_rl_env_ui.py @@ -22,7 +22,7 @@ from isaaclab.envs import ManagerBasedRLEnv, ManagerBasedRLEnvCfg from isaaclab.envs.ui import ManagerBasedRLEnvWindow from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass enable_extension("isaacsim.gui.components") diff --git a/source/isaaclab/test/envs/test_modify_env_param_curr_term.py b/source/isaaclab/test/envs/test_modify_env_param_curr_term.py index 683b6eae02c7..b35487f524c1 100644 --- a/source/isaaclab/test/envs/test_modify_env_param_curr_term.py +++ b/source/isaaclab/test/envs/test_modify_env_param_curr_term.py @@ -18,7 +18,7 @@ from isaaclab.assets import Articulation from isaaclab.envs import ManagerBasedRLEnv from isaaclab.managers import CurriculumTermCfg as CurrTerm -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.classic.cartpole.cartpole_env_cfg import CartpoleEnvCfg diff --git a/source/isaaclab/test/envs/test_scale_randomization.py b/source/isaaclab/test/envs/test_scale_randomization.py index ec4c6cd42a96..1104425b4b1c 100644 --- a/source/isaaclab/test/envs/test_scale_randomization.py +++ b/source/isaaclab/test/envs/test_scale_randomization.py @@ -35,7 +35,7 @@ from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.scene import InteractiveSceneCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Custom action term diff --git a/source/isaaclab/test/envs/test_texture_randomization.py b/source/isaaclab/test/envs/test_texture_randomization.py index b5fe287c48ce..cbc3a7f4faf0 100644 --- a/source/isaaclab/test/envs/test_texture_randomization.py +++ b/source/isaaclab/test/envs/test_texture_randomization.py @@ -29,8 +29,8 @@ from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import NVIDIA_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.classic.cartpole.cartpole_env_cfg import CartpoleSceneCfg diff --git a/source/isaaclab/test/managers/test_event_manager.py b/source/isaaclab/test/managers/test_event_manager.py index 362e5c85c3e6..e339d79ee19f 100644 --- a/source/isaaclab/test/managers/test_event_manager.py +++ b/source/isaaclab/test/managers/test_event_manager.py @@ -26,7 +26,7 @@ from isaaclab.envs import ManagerBasedEnv from isaaclab.managers import EventManager, EventTermCfg, ManagerTermBase, ManagerTermBaseCfg from isaaclab.sim import SimulationContext -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass DummyEnv = namedtuple("ManagerBasedRLEnv", ["num_envs", "dt", "device", "sim", "dummy1", "dummy2"]) """Dummy environment for testing.""" diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index d738f179da71..41ea6bad1ee6 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -29,7 +29,8 @@ ObservationTermCfg, RewardTermCfg, ) -from isaaclab.utils import configclass, modifiers +from isaaclab.utils import modifiers +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from isaaclab.envs import ManagerBasedEnv diff --git a/source/isaaclab/test/managers/test_recorder_manager.py b/source/isaaclab/test/managers/test_recorder_manager.py index f85a1a191da6..443fe50fb645 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -31,7 +31,7 @@ from isaaclab.managers import DatasetExportMode, RecorderManager, RecorderManagerBaseCfg, RecorderTerm, RecorderTermCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationContext -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: import numpy as np diff --git a/source/isaaclab/test/managers/test_reward_manager.py b/source/isaaclab/test/managers/test_reward_manager.py index 8301fac5b504..92554292e6f8 100644 --- a/source/isaaclab/test/managers/test_reward_manager.py +++ b/source/isaaclab/test/managers/test_reward_manager.py @@ -19,7 +19,7 @@ from isaaclab.managers import RewardManager, RewardTermCfg from isaaclab.sim import SimulationContext -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass def grilled_chicken(env): diff --git a/source/isaaclab/test/markers/check_markers_visibility.py b/source/isaaclab/test/markers/check_markers_visibility.py index eadc8af82a53..544178bd0409 100644 --- a/source/isaaclab/test/markers/check_markers_visibility.py +++ b/source/isaaclab/test/markers/check_markers_visibility.py @@ -42,7 +42,7 @@ from isaaclab.assets import ArticulationCfg, AssetBaseCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import RayCasterCfg, patterns -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/source/isaaclab/test/scene/check_interactive_scene.py b/source/isaaclab/test/scene/check_interactive_scene.py index df0c4a12a945..dd0b03c5e35d 100644 --- a/source/isaaclab/test/scene/check_interactive_scene.py +++ b/source/isaaclab/test/scene/check_interactive_scene.py @@ -33,7 +33,7 @@ from isaaclab.sensors.ray_caster import RayCasterCfg, patterns from isaaclab.sim import SimulationContext from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.timer import Timer ## diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 626e4d8e44df..f56803ef5cf6 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -24,8 +24,8 @@ from isaaclab.physics.scene_data_requirements import SceneDataRequirement from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sim import build_simulation_context -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/test/sensors/test_sensor_base.py b/source/isaaclab/test/sensors/test_sensor_base.py index 34a865ff96f1..3d8d6af9090c 100644 --- a/source/isaaclab/test/sensors/test_sensor_base.py +++ b/source/isaaclab/test/sensors/test_sensor_base.py @@ -23,7 +23,7 @@ import isaaclab.sim as sim_utils from isaaclab.sensors import SensorBase, SensorBaseCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @dataclass diff --git a/source/isaaclab/test/utils/test_modifiers.py b/source/isaaclab/test/utils/test_modifiers.py index 9cdd9a5d6631..d01771256059 100644 --- a/source/isaaclab/test/utils/test_modifiers.py +++ b/source/isaaclab/test/utils/test_modifiers.py @@ -18,7 +18,7 @@ import torch import isaaclab.utils.modifiers as modifiers -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab/test/visualization/check_scene_xr_visualization.py b/source/isaaclab/test/visualization/check_scene_xr_visualization.py index b03fa9e88bd2..0ecded8048a8 100644 --- a/source/isaaclab/test/visualization/check_scene_xr_visualization.py +++ b/source/isaaclab/test/visualization/check_scene_xr_visualization.py @@ -43,7 +43,7 @@ from isaaclab.assets import AssetBaseCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.ui.xr_widgets import DataCollector, TriggerType, VisualizationManager, XRVisualization, update_instruction -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/source/isaaclab_contrib/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_contrib/changelog.d/pbarejko-fix-lazy-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_contrib/docs/README.md b/source/isaaclab_contrib/docs/README.md index ea09129849f5..5d6caed5f88a 100644 --- a/source/isaaclab_contrib/docs/README.md +++ b/source/isaaclab_contrib/docs/README.md @@ -142,7 +142,7 @@ multirotor_cfg = MultirotorCfg( ```python from isaaclab.envs import ManagerBasedRLEnvCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_contrib.mdp.actions import ThrustActionCfg @configclass diff --git a/source/isaaclab_contrib/isaaclab_contrib/actuators/thruster_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/actuators/thruster_cfg.py index 1c20492fc2c1..5412acbe1de1 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/actuators/thruster_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/actuators/thruster_cfg.py @@ -6,7 +6,7 @@ from dataclasses import MISSING from typing import TYPE_CHECKING, Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .thruster import Thruster diff --git a/source/isaaclab_contrib/isaaclab_contrib/assets/multirotor/multirotor_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/assets/multirotor/multirotor_cfg.py index 0a8538cc14f0..3d3037ddb9bc 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/assets/multirotor/multirotor_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/assets/multirotor/multirotor_cfg.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from isaaclab.assets.articulation import ArticulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_contrib.actuators import ThrusterCfg diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control_cfg.py index 6a1f6c7db7e2..720d52d4ec58 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_acceleration_control_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .lee_acceleration_control import LeeAccController from .lee_controller_base_cfg import LeeControllerBaseCfg diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control_cfg.py index bcf0f9f3ca13..b6e229575fc0 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_attitude_control_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .lee_attitude_control import LeeAttController from .lee_controller_base_cfg import LeeControllerBaseCfg diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base_cfg.py index 3a279f7ddb81..27168656adbb 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_controller_base_cfg.py @@ -6,7 +6,7 @@ import math from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control_cfg.py index e2df30f8aa70..461ec0a4516f 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_position_control_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .lee_controller_base_cfg import LeeControllerBaseCfg from .lee_position_control import LeePosController diff --git a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control_cfg.py index 13ef9814d268..5347cb0dffc7 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/controllers/lee_velocity_control_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .lee_controller_base_cfg import LeeControllerBaseCfg from .lee_velocity_control import LeeVelController diff --git a/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions_cfg.py index d06242f80ab0..dfe619544201 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/mdp/actions/thrust_actions_cfg.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from isaaclab.managers.action_manager import ActionTermCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from isaaclab_contrib.controllers import LeeAccControllerCfg, LeePosControllerCfg, LeeVelControllerCfg diff --git a/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor_cfg.py index 3eccac3db2a5..8f5b0d187e1d 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor_cfg.py @@ -13,8 +13,8 @@ from isaaclab.markers import VisualizationMarkersCfg from isaaclab.markers.config import VISUO_TACTILE_SENSOR_MARKER_CFG from isaaclab.sensors import CameraCfg, SensorBaseCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .visuotactile_sensor import VisuoTactileSensor diff --git a/source/isaaclab_experimental/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_experimental/changelog.d/pbarejko-fix-lazy-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_experimental/isaaclab_experimental/envs/mdp/actions/actions_cfg.py b/source/isaaclab_experimental/isaaclab_experimental/envs/mdp/actions/actions_cfg.py index 39d5b29c6fdb..f100b5235f00 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/envs/mdp/actions/actions_cfg.py +++ b/source/isaaclab_experimental/isaaclab_experimental/envs/mdp/actions/actions_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_experimental.managers.action_manager import ActionTerm, ActionTermCfg diff --git a/source/isaaclab_experimental/isaaclab_experimental/managers/manager_base.py b/source/isaaclab_experimental/isaaclab_experimental/managers/manager_base.py index d40920b23fe9..6caea3eebcbb 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/managers/manager_base.py +++ b/source/isaaclab_experimental/isaaclab_experimental/managers/manager_base.py @@ -57,7 +57,7 @@ class should also have a corresponding configuration class that defines the conf .. code-block:: python - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass from isaaclab.utils.mdp import ManagerBase, ManagerTermBaseCfg diff --git a/source/isaaclab_experimental/isaaclab_experimental/managers/manager_term_cfg.py b/source/isaaclab_experimental/isaaclab_experimental/managers/manager_term_cfg.py index 3fab3bfc5ffc..ce354be66a2a 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/managers/manager_term_cfg.py +++ b/source/isaaclab_experimental/isaaclab_experimental/managers/manager_term_cfg.py @@ -20,7 +20,7 @@ from isaaclab.managers.manager_term_cfg import * # noqa: F401,F403 from isaaclab.managers.manager_term_cfg import ManagerTermBaseCfg as _ManagerTermBaseCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py index 7cc22eaee6a9..82625feb0433 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py @@ -9,7 +9,7 @@ from dataclasses import MISSING from typing import Any -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/noise/noise_cfg.py b/source/isaaclab_experimental/isaaclab_experimental/utils/noise/noise_cfg.py index 5c7045e65a69..767fbb0451f9 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/noise/noise_cfg.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/noise/noise_cfg.py @@ -13,7 +13,7 @@ import warp as wp -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import noise_model diff --git a/source/isaaclab_mimic/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_mimic/changelog.d/pbarejko-fix-lazy-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py index 76557802f463..49de09cca495 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.place.config.agibot.place_toy2box_rmp_rel_env_cfg import ( RmpFlowAgibotPlaceToy2BoxEnvCfg, diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py index 2bfadef874c3..f1398d2f98d4 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.place.config.agibot.place_upright_mug_rmp_rel_env_cfg import ( RmpFlowAgibotPlaceUprightMugEnvCfg, diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py index ed37975c6afe..289ba14eba7b 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.pick_place.exhaustpipe_gr1t2_pink_ik_env_cfg import ( ExhaustPipeGR1T2PinkIKEnvCfg, diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py index ca28719d7306..59401823be79 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack.config.franka.bin_stack_ik_rel_env_cfg import FrankaBinStackEnvCfg diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py index 93e51d8f673e..d8d9564a98bc 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack.config.franka.stack_ik_abs_env_cfg import FrankaCubeStackEnvCfg diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py index cd75bea018a1..b2057eb30b66 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack.config.franka.stack_ik_rel_blueprint_env_cfg import ( FrankaCubeStackBlueprintEnvCfg, diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py index 8f7d6af8df7f..c874c504d86b 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack.config.franka.stack_ik_rel_env_cfg import FrankaCubeStackEnvCfg diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py index 9d26039126be..377aee477e71 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack.config.franka.stack_ik_rel_env_cfg_skillgen import ( FrankaCubeStackSkillgenEnvCfg, diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py index 2f2ebe5ab339..76e34c17db61 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack.config.franka.stack_ik_rel_visuomotor_cosmos_env_cfg import ( FrankaCubeStackVisuomotorCosmosEnvCfg, diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py index 816d85f611c4..a79373833167 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack.config.franka.stack_ik_rel_visuomotor_env_cfg import ( FrankaCubeStackVisuomotorEnvCfg, diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py index d3de8a9aa3d2..8674e28d8f06 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack.config.galbot.stack_rmp_rel_env_cfg import ( RmpFlowGalbotLeftArmCubeStackEnvCfg, diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py index ce4d00015a3e..812df7337c01 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack.config.galbot.stack_rmp_rel_env_cfg import ( RmpFlowGalbotLeftArmCubeStackEnvCfg, diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py index 2aa766dec33c..92b89babad66 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.locomanipulation.pick_place.locomanipulation_g1_env_cfg import ( LocomanipulationG1EnvCfg, diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py index 683d4be09e44..ee72870c61af 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.pick_place.nutpour_gr1t2_pink_ik_env_cfg import NutPourGR1T2PinkIKEnvCfg diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py index 0297fb72a1bc..724e1a05d8d9 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg import PickPlaceGR1T2EnvCfg diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py index f9528b277dba..26299d03d520 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_waist_enabled_env_cfg import ( PickPlaceGR1T2WaistEnabledEnvCfg, diff --git a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py index 431a890d510d..2b86e6b3913b 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py +++ b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py @@ -14,8 +14,8 @@ from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.sensors import CameraCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR, retrieve_file_path +from isaaclab.utils.configclass import configclass from isaaclab.utils.datasets import EpisodeData from isaaclab_mimic.locomanipulation_sdg.data_classes import LocomanipulationSDGInputData diff --git a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/locomanipulation_sdg_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/locomanipulation_sdg_env_cfg.py index 2ccefe4d0c7b..9c1376c6dce8 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/locomanipulation_sdg_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/locomanipulation_sdg_env_cfg.py @@ -9,7 +9,7 @@ from isaaclab.managers import EventTermCfg as EventTerm from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.managers.recorder_manager import RecorderTermCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_newton/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_newton/changelog.d/pbarejko-fix-lazy-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager_cfg.py index fc4278b49788..4202b46914a7 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .newton_manager_cfg import NewtonSolverCfg diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py index 71c871f9d54d..e88e54e59ab3 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .newton_manager_cfg import NewtonSolverCfg diff --git a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager_cfg.py index 673867902c2c..0080e452bef4 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .newton_manager_cfg import NewtonSolverCfg diff --git a/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager_cfg.py index 0555bd95a412..bc8fd3c9813d 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .newton_manager_cfg import NewtonSolverCfg diff --git a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer_cfg.py b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer_cfg.py index 96db9ca41fd1..9249fbf4ee71 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer_cfg.py @@ -6,7 +6,7 @@ """Configuration for Newton Warp Renderer.""" from isaaclab.renderers.renderer_cfg import RendererCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_cfg.py b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_cfg.py index 196c5d0349e7..924c7292ede4 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_cfg.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from isaaclab.sensors.contact_sensor.contact_sensor_cfg import ContactSensorCfg as BaseContactSensorCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .contact_sensor import ContactSensor diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py index f0065daa867f..d98fbc3a2368 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -15,7 +15,7 @@ RigidBodyBaseCfg, ) from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video_cfg.py b/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video_cfg.py index 6408b2ac0e53..71314ac7401c 100644 --- a/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: pass diff --git a/source/isaaclab_newton/test/sensors/test_contact_sensor.py b/source/isaaclab_newton/test/sensors/test_contact_sensor.py index 3aaa6e14b39c..230214e9588b 100644 --- a/source/isaaclab_newton/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_newton/test/sensors/test_contact_sensor.py @@ -45,7 +45,7 @@ from isaaclab.sensors import ContactSensor, ContactSensorCfg from isaaclab.sim import build_simulation_context from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_assets.robots.allegro import ALLEGRO_HAND_CFG diff --git a/source/isaaclab_newton/test/sensors/test_frame_transformer.py b/source/isaaclab_newton/test/sensors/test_frame_transformer.py index 236fb53f25d9..484f175f67e6 100644 --- a/source/isaaclab_newton/test/sensors/test_frame_transformer.py +++ b/source/isaaclab_newton/test/sensors/test_frame_transformer.py @@ -24,7 +24,7 @@ from isaaclab.sensors import FrameTransformerCfg, OffsetCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/source/isaaclab_newton/test/sensors/test_imu.py b/source/isaaclab_newton/test/sensors/test_imu.py index 3edb4c908398..324219084db5 100644 --- a/source/isaaclab_newton/test/sensors/test_imu.py +++ b/source/isaaclab_newton/test/sensors/test_imu.py @@ -21,7 +21,7 @@ from isaaclab.sensors.imu import Imu, ImuCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py b/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py index fedf7c359081..fb99cbe56f72 100644 --- a/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py +++ b/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py @@ -22,9 +22,9 @@ from isaaclab.sensors.joint_wrench import JointWrenchSensor, JointWrenchSensorCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass from isaaclab.utils import math as math_utils from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_assets.robots.ant import ANT_CFG diff --git a/source/isaaclab_newton/test/sensors/test_pva.py b/source/isaaclab_newton/test/sensors/test_pva.py index 8d14b9cb77ef..85b5e4f66257 100644 --- a/source/isaaclab_newton/test/sensors/test_pva.py +++ b/source/isaaclab_newton/test/sensors/test_pva.py @@ -21,7 +21,7 @@ from isaaclab.sensors.pva import Pva, PvaCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass 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 index e8b28aad2d95..594a55e13584 100644 --- a/source/isaaclab_newton/test/sim/test_views_xform_prim_newton.py +++ b/source/isaaclab_newton/test/sim/test_views_xform_prim_newton.py @@ -31,7 +31,7 @@ from isaaclab.assets import RigidObjectCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sim import SimulationCfg, build_simulation_context -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass NEWTON_SIM_CFG = SimulationCfg(physics=NewtonCfg(solver_cfg=MJWarpSolverCfg())) WORLD_MARKER_POS = (5.0, 3.0, 1.0) diff --git a/source/isaaclab_ov/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_ov/changelog.d/pbarejko-fix-lazy-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py index f8cf694040b1..6c5a7fc29a0f 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py @@ -9,7 +9,7 @@ from pathlib import Path from isaaclab.renderers.renderer_cfg import RendererCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_ovphysx/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_ovphysx/changelog.d/pbarejko-fix-lazy-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py index c74dc56209ea..96047e46363d 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py @@ -8,7 +8,7 @@ from __future__ import annotations from isaaclab.physics import PhysicsCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_physx/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_physx/changelog.d/pbarejko-fix-lazy-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_cfg.py b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_cfg.py index 7dbf060c27b6..9c54aa7b6b0b 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_cfg.py @@ -10,7 +10,7 @@ from isaaclab.assets.asset_base_cfg import AssetBaseCfg from isaaclab.markers import VisualizationMarkersCfg from isaaclab.markers.config import DEFORMABLE_TARGET_MARKER_CFG -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .deformable_object import DeformableObject diff --git a/source/isaaclab_physx/isaaclab_physx/assets/surface_gripper/surface_gripper_cfg.py b/source/isaaclab_physx/isaaclab_physx/assets/surface_gripper/surface_gripper_cfg.py index 7b2c3c5493fe..3aa74800e026 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/surface_gripper/surface_gripper_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/surface_gripper/surface_gripper_cfg.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from isaaclab.assets.asset_base_cfg import AssetBaseCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .surface_gripper import SurfaceGripper diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager_cfg.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager_cfg.py index 301ffa3c72ed..18ba5303134f 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager_cfg.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Literal from isaaclab.physics import PhysicsCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .physx_manager import PhysxManager diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_cfg.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_cfg.py index de5d433e867f..2f765546bafe 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_cfg.py @@ -8,7 +8,7 @@ from typing import Literal from isaaclab.renderers.renderer_cfg import RendererCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor_cfg.py b/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor_cfg.py index 8950342a0f37..095ab64ac696 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor_cfg.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from isaaclab.sensors.contact_sensor.contact_sensor_cfg import ContactSensorCfg as _BaseContactSensorCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .contact_sensor import ContactSensor diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py index e6bdea28d24e..88729c2b0f0f 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -16,7 +16,7 @@ MeshCollisionBaseCfg, RigidBodyBaseCfg, ) -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py index 2c8121a5cbee..7128a080b502 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py @@ -12,7 +12,7 @@ from isaaclab.sim.spawners.materials import PhysicsMaterialCfg from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/spawner_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/spawners/spawner_cfg.py index dda028508762..d4843ec83d3e 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/spawner_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/spawner_cfg.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from isaaclab.sim.spawners.spawner_cfg import SpawnerCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from isaaclab.sim import schemas diff --git a/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video_cfg.py b/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video_cfg.py index c3f9280976aa..7e05155fbd82 100644 --- a/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video_cfg.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: pass diff --git a/source/isaaclab_physx/test/sensors/test_contact_sensor.py b/source/isaaclab_physx/test/sensors/test_contact_sensor.py index 685d9204b3ef..b3ce3b40edb5 100644 --- a/source/isaaclab_physx/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_physx/test/sensors/test_contact_sensor.py @@ -30,7 +30,7 @@ from isaaclab.sim import SimulationCfg, SimulationContext, build_simulation_context from isaaclab.sim.utils.stage import get_current_stage from isaaclab.terrains import HfRandomUniformTerrainCfg, TerrainGeneratorCfg, TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Custom helper classes. diff --git a/source/isaaclab_physx/test/sensors/test_frame_transformer.py b/source/isaaclab_physx/test/sensors/test_frame_transformer.py index 28559f9b6da5..f7d9c5db5817 100644 --- a/source/isaaclab_physx/test/sensors/test_frame_transformer.py +++ b/source/isaaclab_physx/test/sensors/test_frame_transformer.py @@ -24,7 +24,7 @@ from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import FrameTransformerCfg, OffsetCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/source/isaaclab_physx/test/sensors/test_imu.py b/source/isaaclab_physx/test/sensors/test_imu.py index 641c72a8d4ba..08aaa1bd1818 100644 --- a/source/isaaclab_physx/test/sensors/test_imu.py +++ b/source/isaaclab_physx/test/sensors/test_imu.py @@ -27,7 +27,7 @@ from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors.imu import Imu, ImuCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py b/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py index 4f10f2506ebb..8aef39cace4b 100644 --- a/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py +++ b/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py @@ -28,9 +28,9 @@ from isaaclab.sensors import JointWrenchSensor, JointWrenchSensorCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass from isaaclab.utils import math as math_utils from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_assets.robots.ant import ANT_CFG diff --git a/source/isaaclab_physx/test/sensors/test_pva.py b/source/isaaclab_physx/test/sensors/test_pva.py index feb527f88c62..27018230f960 100644 --- a/source/isaaclab_physx/test/sensors/test_pva.py +++ b/source/isaaclab_physx/test/sensors/test_pva.py @@ -28,7 +28,7 @@ from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors.pva import Pva, PvaCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/source/isaaclab_physx/test/sim/test_cloner.py b/source/isaaclab_physx/test/sim/test_cloner.py index f90c740f17ed..a4f1c730fc2d 100644 --- a/source/isaaclab_physx/test/sim/test_cloner.py +++ b/source/isaaclab_physx/test/sim/test_cloner.py @@ -587,7 +587,7 @@ def test_disabled_fabric_change_notifies_speedup_regression(): import isaaclab.sim as sim_utils from isaaclab.assets import RigidObjectCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass if fabric_notices_mod.get_bindings() is None: pytest.skip("omni::fabric::IFabricUsd unavailable") diff --git a/source/isaaclab_rl/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_rl/changelog.d/pbarejko-fix-lazy-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_rl/isaaclab_rl/rl_games/pbt/pbt_cfg.py b/source/isaaclab_rl/isaaclab_rl/rl_games/pbt/pbt_cfg.py index 2fdf88badb61..3b0aac88e506 100644 --- a/source/isaaclab_rl/isaaclab_rl/rl_games/pbt/pbt_cfg.py +++ b/source/isaaclab_rl/isaaclab_rl/rl_games/pbt/pbt_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py index f94e94112901..07b10d507c55 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py @@ -8,7 +8,7 @@ from dataclasses import MISSING from typing import Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rl_cfg import RslRlBaseRunnerCfg, RslRlMLPModelCfg diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py index 6a57d492b7f3..d7575ce258c6 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py @@ -8,7 +8,7 @@ from dataclasses import MISSING from typing import Literal -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rnd_cfg import RslRlRndCfg from .symmetry_cfg import RslRlSymmetryCfg diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/rnd_cfg.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/rnd_cfg.py index ed8f41e8d55a..b5fbef590964 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/rnd_cfg.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/rnd_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/symmetry_cfg.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/symmetry_cfg.py index 535308c2c348..12466272edd5 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/symmetry_cfg.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/symmetry_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_tasks/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_tasks/changelog.d/pbarejko-fix-lazy-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py index a80cf1ee33a2..784a18239f6e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/allegro_hand_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/allegro_hand_env_cfg.py index 6065f81a0868..8f32326763f6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/allegro_hand_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/allegro_hand_env_cfg.py @@ -15,8 +15,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py index c2e7f15852ff..f59450f655a1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/ant/ant_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/ant/ant_env_cfg.py index 7ee86abc0013..90dab2e8809d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/ant/ant_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/ant/ant_env_cfg.py @@ -15,7 +15,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py index 55b34655b639..9b9cbced3d8f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env_cfg.py index f2939b661508..630be7618d3f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env_cfg.py @@ -15,7 +15,7 @@ from isaaclab.sensors import ContactSensorCfg, RayCasterCfg, patterns from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_env_cfg.py index 7fae88707c5c..a8dae7034eb3 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_env_cfg.py @@ -12,7 +12,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .assembly_tasks_cfg import ASSET_DIR, Insertion diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_tasks_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_tasks_cfg.py index fcc965276c4e..635cedc27143 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_tasks_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_tasks_cfg.py @@ -5,8 +5,8 @@ import isaaclab.sim as sim_utils from isaaclab.assets import ArticulationCfg, RigidObjectCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass ASSET_DIR = f"{ISAACLAB_NUCLEUS_DIR}/AutoMate" diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_env_cfg.py index a2b7fbe49133..c5c197393b65 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_env_cfg.py @@ -13,7 +13,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .disassembly_tasks_cfg import ASSET_DIR, Extraction diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_tasks_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_tasks_cfg.py index 1c3037873031..53307fd40341 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_tasks_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_tasks_cfg.py @@ -5,8 +5,8 @@ import isaaclab.sim as sim_utils from isaaclab.assets import ArticulationCfg, RigidObjectCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass ASSET_DIR = f"{ISAACLAB_NUCLEUS_DIR}/AutoMate" diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cart_double_pendulum/cart_double_pendulum_env.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cart_double_pendulum/cart_double_pendulum_env.py index f146ef56330f..47ec6d2da69a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cart_double_pendulum/cart_double_pendulum_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cart_double_pendulum/cart_double_pendulum_env.py @@ -16,7 +16,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.sim.spawners.from_files import GroundPlaneCfg, spawn_ground_plane -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.math import sample_uniform from isaaclab_assets.robots.cart_double_pendulum import CART_DOUBLE_PENDULUM_CFG diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cart_double_pendulum/cart_double_pendulum_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cart_double_pendulum/cart_double_pendulum_env_cfg.py index 048750158cdc..538e2e4cd55f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cart_double_pendulum/cart_double_pendulum_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cart_double_pendulum/cart_double_pendulum_env_cfg.py @@ -9,7 +9,7 @@ from isaaclab.envs import DirectMARLEnvCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_assets.robots.cart_double_pendulum import CART_DOUBLE_PENDULUM_CFG diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py index 7d308b9f5c45..70c5c2d433b2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_env_cfg.py index 9539b99bfb89..2bf923446e46 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_env_cfg.py @@ -11,7 +11,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors import CameraCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils.presets import MultiBackendRendererCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py index 2b436f2d5e51..a245b46f73e2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py @@ -15,7 +15,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors import CameraCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg from isaaclab_tasks.utils.presets import MultiBackendRendererCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env_cfg.py index 2b93d0f0ed79..11720f0c238d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env_cfg.py @@ -13,7 +13,7 @@ from isaaclab.envs import DirectRLEnvCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole_showcase/cartpole/cartpole_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole_showcase/cartpole/cartpole_env_cfg.py index 9384305a07ed..2ff10fa8f8ab 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole_showcase/cartpole/cartpole_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole_showcase/cartpole/cartpole_env_cfg.py @@ -7,7 +7,7 @@ from gymnasium import spaces -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.direct.cartpole.cartpole_env_cfg import CartpoleEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole_showcase/cartpole_camera/cartpole_camera_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole_showcase/cartpole_camera/cartpole_camera_env_cfg.py index c81fe197bab1..d3e78a6bd4b7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole_showcase/cartpole_camera/cartpole_camera_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole_showcase/cartpole_camera/cartpole_camera_env_cfg.py @@ -9,7 +9,7 @@ import isaaclab.sim as sim_utils from isaaclab.sensors import CameraCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.direct.cartpole.cartpole_camera_env_cfg import CartpoleRGBCameraEnvCfg as CartpoleCameraEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_env_cfg.py index 028184ff7ba7..a250380e2566 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_env_cfg.py @@ -12,7 +12,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .factory_tasks_cfg import ASSET_DIR, FactoryTask, GearMesh, NutThread, PegInsert diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_tasks_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_tasks_cfg.py index 24b35617bce4..4841f58d161b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_tasks_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_tasks_cfg.py @@ -5,8 +5,8 @@ import isaaclab.sim as sim_utils from isaaclab.assets import ArticulationCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass ASSET_DIR = f"{ISAACLAB_NUCLEUS_DIR}/Factory" diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/forge/forge_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/forge/forge_env_cfg.py index 5da73aa2ae35..424793217746 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/forge/forge_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/forge/forge_env_cfg.py @@ -6,7 +6,7 @@ import isaaclab.envs.mdp as mdp from isaaclab.managers import EventTermCfg as EventTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.direct.factory.factory_env_cfg import OBS_DIM_CFG, STATE_DIM_CFG, CtrlCfg, FactoryEnvCfg, ObsRandCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/forge/forge_tasks_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/forge/forge_tasks_cfg.py index 1529543e1889..ed1a1fea3275 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/forge/forge_tasks_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/forge/forge_tasks_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.direct.factory.factory_tasks_cfg import FactoryTask, GearMesh, NutThread, PegInsert diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py index c467360e61a5..20c0e8923147 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/franka_cabinet_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/franka_cabinet_env_cfg.py index 32b7a88d3e9b..107d9443695e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/franka_cabinet_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/franka_cabinet_env_cfg.py @@ -12,8 +12,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py index 07ea4e8590f1..f3f1df1a4e2b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/humanoid_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/humanoid_env_cfg.py index b2b10dc2d911..93a085f4033b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/humanoid_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/humanoid_env_cfg.py @@ -15,7 +15,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid_amp/humanoid_amp_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid_amp/humanoid_amp_env_cfg.py index 324717dd6c18..9901d45454f0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid_amp/humanoid_amp_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid_amp/humanoid_amp_env_cfg.py @@ -15,7 +15,7 @@ from isaaclab.envs import DirectRLEnvCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_assets import HUMANOID_28_CFG diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py index 254072606f13..1ba7cb28899f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/quadcopter_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/quadcopter_env_cfg.py index d893535fe210..305d001a7821 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/quadcopter_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/quadcopter_env_cfg.py @@ -11,7 +11,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_assets import CRAZYFLIE_CFG # isort: skip diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py index fa4a96cb32e7..ff6610ce6b70 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/feature_extractor.py b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/feature_extractor.py index d7dedbf196a6..56c159a1446e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/feature_extractor.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/feature_extractor.py @@ -11,7 +11,7 @@ import torchvision from isaaclab.sensors import save_images_to_file -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass # Number of output channels for each supported camera data type. _DATA_TYPE_CHANNELS: dict[str, int] = { diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_env_cfg.py index 0a2258d0b0b1..bfe7da0aa435 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_env_cfg.py @@ -17,8 +17,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import GaussianNoiseCfg, NoiseModelWithAdditiveBiasCfg from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_vision_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_vision_env_cfg.py index 5612b8c6d54e..8a6901c0239b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_vision_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_vision_env_cfg.py @@ -8,7 +8,7 @@ import isaaclab.sim as sim_utils from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors import CameraCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg from isaaclab_tasks.utils.presets import MultiBackendRendererCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env_cfg.py index e2d8bdbd8d6d..fe32b46f503f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env_cfg.py @@ -17,7 +17,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.direct.shadow_hand.shadow_hand_env_cfg import ShadowHandRobotCfg from isaaclab_tasks.utils import PresetCfg, preset diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py index 56cb2c4fd3a0..a7c905171e27 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py index 53781c0ced16..4d1798c950ff 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py @@ -18,7 +18,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors import JointWrenchSensorCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.classic.humanoid.mdp as mdp from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py index c53312ee7dd4..0c371f473724 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg, RslRlSymmetryCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/cartpole_camera_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/cartpole_camera_env_cfg.py index 2c4092677fec..0452ca3d5996 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/cartpole_camera_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/cartpole_camera_env_cfg.py @@ -8,7 +8,7 @@ from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg from isaaclab.sensors import CameraCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.classic.cartpole.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/cartpole_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/cartpole_env_cfg.py index f351e635ff6a..9daabb2bb7ba 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/cartpole_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/cartpole_env_cfg.py @@ -18,7 +18,7 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.classic.cartpole.mdp as mdp from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py index 076ad94480eb..f310626ca779 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py @@ -13,7 +13,7 @@ ==================================================================================================== """ -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py index 3058fc02caf2..0526c248c270 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py @@ -18,7 +18,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors import JointWrenchSensorCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.classic.humanoid.mdp as mdp from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/commands/commands_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/commands/commands_cfg.py index cbcb4577308c..16657809b305 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/commands/commands_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/mdp/commands/commands_cfg.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from isaaclab.envs.mdp.commands.commands_cfg import UniformPoseCommandCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .drone_pose_command import DroneUniformPoseCommand diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py index aeddab56e5f8..eeba299a1a3e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/floating_obstacles_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/floating_obstacles_env_cfg.py index 1990750eefc0..eb6e9258719f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/floating_obstacles_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/floating_obstacles_env_cfg.py @@ -6,7 +6,7 @@ ## # Pre-defined configs ## -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.drone_arl.navigation.config.arl_robot_1.navigation_env_cfg import ( NavigationVelocityFloatingObstacleEnvCfg, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/navigation_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/navigation_env_cfg.py index 6b4039e254a0..ee52b7390f93 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/navigation_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/navigation_env_cfg.py @@ -23,8 +23,8 @@ from isaaclab.sensors import ContactSensorCfg from isaaclab.sensors.ray_caster.multi_mesh_ray_caster_camera_cfg import MultiMeshRayCasterCameraCfg from isaaclab.sensors.ray_caster.patterns import PinholeCameraPatternCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise from isaaclab_contrib.assets import MultirotorCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene_cfg.py index c42610dc10b7..ddb8c2efec27 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/navigation/config/arl_robot_1/scenes/obstacle_scenes/obstacle_scene_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py index 9a9b0de5bb38..d3cf82f5d946 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/no_obstacle_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/no_obstacle_env_cfg.py index 92a11d824420..f5e45096f9a4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/no_obstacle_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/no_obstacle_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_assets.robots.arl_robot_1 import ARL_ROBOT_1_CFG diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/track_position_state_based_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/track_position_state_based_env_cfg.py index de61f13b8a9a..b314533d1633 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/track_position_state_based_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/track_position_state_based_env_cfg.py @@ -18,8 +18,8 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise from isaaclab_contrib.assets import MultirotorCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/action_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/action_cfg.py index 320d7d738ea0..13572994ee49 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/action_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/action_cfg.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from isaaclab.managers.action_manager import ActionTermCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from ..mdp.actions import AgileBasedLowerBodyAction diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/agile_locomotion_observation_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/agile_locomotion_observation_cfg.py index 6fd0b6dbdf9d..a3d811313bd8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/agile_locomotion_observation_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/agile_locomotion_observation_cfg.py @@ -7,7 +7,7 @@ from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py index 06b64c781701..10fb9cdf9492 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py @@ -15,8 +15,8 @@ from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.locomanipulation.pick_place import mdp as locomanip_mdp from isaaclab_tasks.manager_based.manipulation.pick_place import mdp as manip_mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py index 1a2ef2826de4..1a5989c32886 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py @@ -15,8 +15,8 @@ from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.locomanipulation.pick_place import mdp as locomanip_mdp from isaaclab_tasks.manager_based.locomanipulation.pick_place.configs.action_cfg import AgileBasedLowerBodyActionCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py index 2118f550cc12..10a84ace6454 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/loco_manip_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/loco_manip_env_cfg.py index a83e3e2b1e62..c7c3a1d9fef4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/loco_manip_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/loco_manip_env_cfg.py @@ -9,7 +9,7 @@ from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import RewardTermCfg as RewTerm -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.manager_based.locomotion.velocity.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py index 334aa1768ed6..d90813553289 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/flat_env_cfg.py index 93541ff765ae..c29ea2500da4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/flat_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/rough_env_cfg.py index 2857d3dc0341..e77794159270 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/rough_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.locomotion.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py index 49c227aecdde..65b559e99cd2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg, RslRlSymmetryCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/flat_env_cfg.py index 7b745dfe1a07..88799d6df62e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/flat_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/rough_env_cfg.py index cf3fc2c3f232..46cc6818d6a6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/rough_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.locomotion.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py index 06072c7297fe..4b27a2f7452c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg, RslRlSymmetryCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/flat_env_cfg.py index 54c1076625a1..65795ad8230a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/flat_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/rough_env_cfg.py index 8c4cbb7a84c3..f27a1137b853 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/rough_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.locomotion.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg from isaaclab_tasks.utils import preset diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_distillation_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_distillation_cfg.py index faf362709ed0..153e1f9a50be 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_distillation_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_distillation_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import ( RslRlDistillationAlgorithmCfg, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_ppo_cfg.py index 67690049ee6f..643f9e2d4a98 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import ( RslRlMLPModelCfg, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py index 9a213f9d0f64..5a97b75f7936 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/rough_env_cfg.py index c1c49677e66a..7b32f10b72a6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/rough_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.locomotion.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py index f7cabdc37474..b36f91766a6f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/flat_env_cfg.py index e9539b6551c5..02bae7147b9e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/flat_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py index d8a2ef0874dd..b7dad5fd3a8c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab.managers import RewardTermCfg as RewTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.locomotion.velocity.mdp as mdp from isaaclab_tasks.manager_based.locomotion.velocity.velocity_env_cfg import ( diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py index 217c7482f19a..6fd4b5a1f183 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/flat_env_cfg.py index 48a647e17a64..fd7d12b969ae 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rough_env_cfg import DigitRoughEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/rough_env_cfg.py index aa0f433e4ecc..85b3148ce055 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/rough_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.managers import ObservationGroupCfg, ObservationTermCfg, RewardTermCfg, SceneEntityCfg, TerminationTermCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.manager_based.locomotion.velocity.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py index d4b55dadc60f..f3032231f816 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/flat_env_cfg.py index 1df1a91447ea..02a3b4d1252f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/flat_env_cfg.py @@ -8,7 +8,7 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py index 65dbb157c177..b377b9354c03 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab.managers import RewardTermCfg as RewTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.locomotion.velocity.mdp as mdp from isaaclab_tasks.manager_based.locomotion.velocity.velocity_env_cfg import ( diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py index de8ca9189ebe..a0bc3030e0ff 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/flat_env_cfg.py index 03f62cd96642..ff8a24d0217f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/flat_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/rough_env_cfg.py index e09ceee0d49d..407dbbad7422 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/rough_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.locomotion.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg from isaaclab_tasks.utils import preset diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py index e7b0e67b5c2b..fa979f08df46 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/flat_env_cfg.py index 9a1d481876d5..bb6c63472336 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/flat_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/rough_env_cfg.py index 758e9c4dbb09..35a38fc4b1ed 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/rough_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.locomotion.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg from isaaclab_tasks.utils import preset diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py index fe21c8d1da17..ef3fea049bda 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/flat_env_cfg.py index eb501ad12ba7..2b30240f5fbe 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/flat_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py index 167141c1747e..701808c9200a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab.managers import RewardTermCfg as RewTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.locomotion.velocity.mdp as mdp from isaaclab_tasks.manager_based.locomotion.velocity.velocity_env_cfg import ( diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py index 0c7e6f30dac6..55c585d7b53c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/flat_env_cfg.py index cffe129dca7a..f6bc4800e61f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/flat_env_cfg.py @@ -16,8 +16,8 @@ from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.manager_based.locomotion.velocity.config.spot.mdp as spot_mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/velocity_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/velocity_env_cfg.py index 7ee1d811734b..1b936e9fc0b9 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/velocity_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/velocity_env_cfg.py @@ -25,8 +25,8 @@ from isaaclab.sensors import RayCasterCfg, patterns from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.manager_based.locomotion.velocity.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/camera_config.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/camera_config.py index 405948726034..fbd9e9fe248c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/camera_config.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/camera_config.py @@ -12,7 +12,7 @@ import isaaclab.sim as sim_utils from isaaclab.sensors import CameraCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py index ed70792ca6a0..966422d42a73 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/robot_config.py @@ -16,7 +16,7 @@ import math from isaaclab.assets import ArticulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_assets.robots.unitree import G129_CFG_WITH_DEX3_BASE_FIX diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/g129_dex3_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/g129_dex3_env_cfg.py index 50e58134f14c..010658ea1077 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/g129_dex3_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/g129_dex3_env_cfg.py @@ -16,7 +16,7 @@ from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.assemble_trocar import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/cabinet_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/cabinet_env_cfg.py index 43601c83b95e..a1502f7b2de8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/cabinet_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/cabinet_env_cfg.py @@ -23,8 +23,8 @@ from isaaclab.sensors import FrameTransformerCfg from isaaclab.sensors.frame_transformer import OffsetCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py index aad72a2d8a69..611ea190e00e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/ik_abs_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/ik_abs_env_cfg.py index 2f47f3239580..3be27e7b532f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/ik_abs_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/ik_abs_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import joint_pos_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/ik_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/ik_rel_env_cfg.py index aaaa644ce1c2..83a25d05db8c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/ik_rel_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/ik_rel_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import joint_pos_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/joint_pos_env_cfg.py index 04624c0e6389..4ea654a28f50 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/joint_pos_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.sensors import FrameTransformerCfg from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.cabinet import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/agents/rsl_rl_ppo_cfg.py index 8dfd24624513..b6ea23947e6d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/cabinet_openarm_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/cabinet_openarm_env_cfg.py index 8adcddcb8b0f..a3ae35148232 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/cabinet_openarm_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/cabinet_openarm_env_cfg.py @@ -25,8 +25,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors import FrameTransformerCfg from isaaclab.sensors.frame_transformer import OffsetCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass ## # Pre-defined configs diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/joint_pos_env_cfg.py index 05d03942700e..e9bda871acad 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/joint_pos_env_cfg.py @@ -8,7 +8,7 @@ ## from isaaclab.sensors import FrameTransformerCfg from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.cabinet import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py index 20e68ba87fdf..529abf84b499 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg, RslRlRNNModelCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/joint_pos_env_cfg.py index bd8382b55212..cece802d760a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/joint_pos_env_cfg.py @@ -13,7 +13,7 @@ from isaaclab.managers import EventTermCfg as EventTerm from isaaclab.managers import RewardTermCfg as RewTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg import isaaclab_tasks.manager_based.manipulation.deploy.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/ros_inference_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/ros_inference_env_cfg.py index 504a3ccda288..b02b59e66f2c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/ros_inference_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/ros_inference_env_cfg.py @@ -9,7 +9,7 @@ from isaaclab.assets import RigidObjectCfg from isaaclab.managers import ObservationTermCfg as ObsTerm -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .joint_pos_env_cfg import Rizon4sGearAssemblyEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/agents/rsl_rl_ppo_cfg.py index d49d02f5990f..567f72a51dab 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg, RslRlRNNModelCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/joint_pos_env_cfg.py index 3cb8eeae956e..78b885cbb26d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/joint_pos_env_cfg.py @@ -12,7 +12,7 @@ from isaaclab.assets import ArticulationCfg from isaaclab.managers import EventTermCfg as EventTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.manipulation.deploy.mdp as mdp import isaaclab_tasks.manager_based.manipulation.deploy.mdp.events as gear_assembly_events diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/ros_inference_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/ros_inference_env_cfg.py index cdce81364692..cc18759d33d4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/ros_inference_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/ur_10e/ros_inference_env_cfg.py @@ -6,7 +6,7 @@ import math from isaaclab.assets import RigidObjectCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .joint_pos_env_cfg import UR10e2F85GearAssemblyEnvCfg, UR10e2F140GearAssemblyEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/gear_assembly_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/gear_assembly_env_cfg.py index 6a3fc7f080f4..d5db596be861 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/gear_assembly_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/gear_assembly_env_cfg.py @@ -20,8 +20,8 @@ from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim.simulation_cfg import SimulationCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg import isaaclab_tasks.manager_based.manipulation.deploy.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/noise_models.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/noise_models.py index 740099a169ee..69294a7323ac 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/noise_models.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/noise_models.py @@ -20,7 +20,7 @@ import torch -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.math import quat_from_euler_xyz, quat_mul from isaaclab.utils.noise import ConstantNoiseCfg, NoiseModel, NoiseModelCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/agents/rsl_rl_ppo_cfg.py index 85f2b38e3336..f599e3935d65 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/joint_pos_env_cfg.py index d453af36e4eb..a8f7fc592e75 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/joint_pos_env_cfg.py @@ -8,7 +8,7 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.markers.config import FRAME_MARKER_CFG from isaaclab.sensors.frame_transformer.frame_transformer_cfg import FrameTransformerCfg, OffsetCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.manipulation.deploy.mdp as mdp from isaaclab_tasks.manager_based.manipulation.deploy.reach.reach_env_cfg import ReachEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/ros_inference_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/ros_inference_env_cfg.py index 91de220071bc..25e37d44a2f7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/ros_inference_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/ros_inference_env_cfg.py @@ -5,7 +5,7 @@ import math -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .joint_pos_env_cfg import Rizon4sReachEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py index 2e5a4e8dfca9..a10de35aa326 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/joint_pos_env_cfg.py index 1cdc8fd77661..0f65a41cc183 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/joint_pos_env_cfg.py @@ -8,7 +8,7 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.markers.config import FRAME_MARKER_CFG from isaaclab.sensors.frame_transformer.frame_transformer_cfg import FrameTransformerCfg, OffsetCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.manipulation.deploy.mdp as mdp from isaaclab_tasks.manager_based.manipulation.deploy.reach.reach_env_cfg import ReachEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/ros_inference_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/ros_inference_env_cfg.py index af6732a4c5d7..c1b18e395b1a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/ros_inference_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/ros_inference_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .joint_pos_env_cfg import UR10eReachEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/reach_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/reach_env_cfg.py index 018ef3a49c9c..d7586533b017 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/reach_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/reach_env_cfg.py @@ -16,8 +16,8 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.manager_based.manipulation.deploy.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/adr_curriculum.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/adr_curriculum.py index de3aca917f75..9f7c074a7140 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/adr_curriculum.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/adr_curriculum.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause from isaaclab.managers import CurriculumTermCfg as CurrTerm -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/agents/rsl_rl_ppo_cfg.py index 907777f9614c..b1b5196859cf 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/agents/rsl_rl_ppo_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import ( RslRlCNNModelCfg, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/camera_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/camera_cfg.py index cfaae6bbca4c..3ad4be97b0f2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/camera_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/camera_cfg.py @@ -10,7 +10,7 @@ from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg from isaaclab.sensors import CameraCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py index 6513e8d7daa3..9bae34139cda 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab.managers import RewardTermCfg as RewTerm from isaaclab.managers import SceneEntityCfg from isaaclab.sensors import CameraCfg, ContactSensorCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py index 9f12f5b3b0b9..06faa0c25985 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py @@ -20,8 +20,8 @@ from isaaclab.markers import VisualizationMarkersCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import MeshCapsuleCfg, MeshConeCfg, MeshCuboidCfg, MeshSphereCfg, RigidBodyMaterialCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/commands/pose_commands_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/commands/pose_commands_cfg.py index 7594f0f636ca..b43523c45738 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/commands/pose_commands_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/commands/pose_commands_cfg.py @@ -9,8 +9,8 @@ import isaaclab.sim as sim_utils from isaaclab.managers import CommandTermCfg from isaaclab.markers import VisualizationMarkersCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .pose_commands import ObjectUniformPoseCommand diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py index ac773b10af3a..05a8d64018fd 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/allegro_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/allegro_env_cfg.py index 7223ce0234fe..41e53512d30e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/allegro_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/allegro_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.manipulation.inhand.inhand_env_cfg as inhand_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/inhand_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/inhand_env_cfg.py index 891d166c8e1a..59c61828d0e6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/inhand_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/inhand_env_cfg.py @@ -21,8 +21,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim.simulation_cfg import SimulationCfg from isaaclab.sim.spawners.materials import RigidBodyMaterialCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import GaussianNoiseCfg as Gnoise import isaaclab_tasks.manager_based.manipulation.inhand.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/mdp/commands/commands_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/mdp/commands/commands_cfg.py index 6fe3ad8235ba..11e1001a0b6e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/mdp/commands/commands_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/mdp/commands/commands_cfg.py @@ -8,8 +8,8 @@ import isaaclab.sim as sim_utils from isaaclab.managers import CommandTermCfg from isaaclab.markers import VisualizationMarkersCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py index c5010a9577df..6dbea70f59be 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_abs_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_abs_env_cfg.py index 4492b487375b..388837be4127 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_abs_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_abs_env_cfg.py @@ -10,8 +10,8 @@ from isaaclab.managers import EventTermCfg as EventTerm from isaaclab.managers import SceneEntityCfg from isaaclab.sim.spawners import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.manipulation.lift.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_rel_env_cfg.py index 5e5c95e7d472..a598c724a0ea 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_rel_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_rel_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import joint_pos_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/joint_pos_env_cfg.py index 58c161aab182..90d46450ba9b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/joint_pos_env_cfg.py @@ -8,8 +8,8 @@ from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.lift import mdp from isaaclab_tasks.manager_based.manipulation.lift.lift_env_cfg import LiftEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/agents/rsl_rl_ppo_cfg.py index fb962b9cd98f..0c547cd17b77 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/agents/rsl_rl_ppo_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/joint_pos_env_cfg.py index 40f6e6552599..c997f5ea770c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/joint_pos_env_cfg.py @@ -10,8 +10,8 @@ from isaaclab.sensors import FrameTransformerCfg from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.lift import mdp from isaaclab_tasks.manager_based.manipulation.lift.config.openarm.lift_openarm_env_cfg import LiftEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/lift_openarm_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/lift_openarm_env_cfg.py index 42d6555b4163..fcad5b343498 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/lift_openarm_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/lift_openarm_env_cfg.py @@ -26,8 +26,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors.frame_transformer.frame_transformer_cfg import FrameTransformerCfg from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from ... import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/lift_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/lift_env_cfg.py index e347a0840a33..f26f7703eb0c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/lift_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/lift_env_cfg.py @@ -21,8 +21,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors.frame_transformer.frame_transformer_cfg import FrameTransformerCfg from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from . import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py index 98c5aca681d8..f2e7c8b6775a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py @@ -29,8 +29,8 @@ # from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from . import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py index 3dd523de27f6..0ac003f6a8e4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab.controllers.pink_ik import DampingTaskCfg, FrameTaskCfg, NullSpacePostureTaskCfg, PinkIKControllerCfg from isaaclab.envs.mdp.actions.pink_actions_cfg import PinkInverseKinematicsActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.pick_place.exhaustpipe_gr1t2_base_env_cfg import ( ExhaustPipeGR1T2BaseEnvCfg, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py index ea2980d53b0a..6317e0ed2fbc 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py @@ -29,8 +29,8 @@ # from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from . import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py index db242eee50a0..5afad1f238fc 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab.controllers.pink_ik import DampingTaskCfg, FrameTaskCfg, NullSpacePostureTaskCfg, PinkIKControllerCfg from isaaclab.envs.mdp.actions.pink_actions_cfg import PinkInverseKinematicsActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.pick_place.nutpour_gr1t2_base_env_cfg import NutPourGR1T2BaseEnvCfg from isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg import ( diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py index 8c95df1041eb..5789ecda2031 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py @@ -19,8 +19,8 @@ from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR, retrieve_file_path +from isaaclab.utils.configclass import configclass from . import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py index 1c9f19bb3029..f0fc937c53eb 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py @@ -9,7 +9,7 @@ from isaaclab_teleop.xr_cfg import XrCfg from isaaclab.envs import ManagerBasedRLEnvCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .pickplace_gr1t2_env_cfg import ( ActionsCfg, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_unitree_g1_inspire_hand_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_unitree_g1_inspire_hand_env_cfg.py index e223e3649894..8afa97e6a42c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_unitree_g1_inspire_hand_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_unitree_g1_inspire_hand_env_cfg.py @@ -23,8 +23,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim.schemas.schemas_cfg import MassPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR, retrieve_file_path +from isaaclab.utils.configclass import configclass from . import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/place/config/agibot/place_toy2box_rmp_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/place/config/agibot/place_toy2box_rmp_rel_env_cfg.py index 4d70b1571a5f..165d68e80117 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/place/config/agibot/place_toy2box_rmp_rel_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/place/config/agibot/place_toy2box_rmp_rel_env_cfg.py @@ -23,8 +23,8 @@ from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg from isaaclab.sim.schemas.schemas_cfg import MassPropertiesCfg, RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.place import mdp as place_mdp from isaaclab_tasks.manager_based.manipulation.stack import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/place/config/agibot/place_upright_mug_rmp_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/place/config/agibot/place_upright_mug_rmp_rel_env_cfg.py index ad9b4d9292ec..0c1cbedfea47 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/place/config/agibot/place_upright_mug_rmp_rel_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/place/config/agibot/place_upright_mug_rmp_rel_env_cfg.py @@ -20,8 +20,8 @@ from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.place import mdp as place_mdp from isaaclab_tasks.manager_based.manipulation.place.config.agibot import place_toy2box_rmp_rel_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py index f523c5cc2207..de9445906e8f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_abs_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_abs_env_cfg.py index b090e568965e..869b99d4c65e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_abs_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_abs_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import joint_pos_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_rel_env_cfg.py index 024a42270d85..d36063b8c714 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_rel_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/ik_rel_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import joint_pos_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/joint_pos_env_cfg.py index 1d480cfb3cb0..ebeb12b58cc9 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/joint_pos_env_cfg.py @@ -5,7 +5,7 @@ import math -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.manipulation.reach.mdp as mdp from isaaclab_tasks.manager_based.manipulation.reach.reach_env_cfg import ReachEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/osc_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/osc_env_cfg.py index e612439fda70..24b94f266433 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/osc_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/osc_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.controllers.operational_space_cfg import OperationalSpaceControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import OperationalSpaceControllerActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import joint_pos_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/agents/rsl_rl_ppo_cfg.py index 0afc5496d059..4422b4719b15 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/joint_pos_env_cfg.py index 6b17b4174cb0..4eff840b708f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/joint_pos_env_cfg.py @@ -7,7 +7,7 @@ # Pre-defined configs ## -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.manipulation.reach.mdp as mdp from isaaclab_tasks.manager_based.manipulation.reach.config.openarm.bimanual.reach_openarm_bi_env_cfg import ReachEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/reach_openarm_bi_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/reach_openarm_bi_env_cfg.py index f111cb41cd20..c0868e377cc0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/reach_openarm_bi_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/reach_openarm_bi_env_cfg.py @@ -22,7 +22,7 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.manager_based.manipulation.reach.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/agents/rsl_rl_ppo_cfg.py index 9bfdf6530499..f9172d8c5c1a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/agents/rsl_rl_ppo_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/joint_pos_env_cfg.py index 2bfd6e326a5a..b94be83a1d9b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/joint_pos_env_cfg.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause from isaaclab.assets.articulation import ArticulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.manipulation.reach.mdp as mdp from isaaclab_tasks.manager_based.manipulation.reach.config.openarm.unimanual.reach_openarm_uni_env_cfg import ( diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/reach_openarm_uni_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/reach_openarm_uni_env_cfg.py index efde276b7dc6..58963fdd887e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/reach_openarm_uni_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/reach_openarm_uni_env_cfg.py @@ -22,8 +22,8 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.manager_based.manipulation.reach.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py index ef6ae64a45f5..05ab6eace04c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/joint_pos_env_cfg.py index 6ddf935768b8..3d7bc8f3449d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/joint_pos_env_cfg.py @@ -5,7 +5,7 @@ import math -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.manipulation.reach.mdp as mdp from isaaclab_tasks.manager_based.manipulation.reach.reach_env_cfg import ReachEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/reach_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/reach_env_cfg.py index d859e786cf61..12e18d311f9d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/reach_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/reach_env_cfg.py @@ -25,8 +25,8 @@ from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import CollisionPropertiesCfg, RigidBodyPropertiesCfg, UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.manager_based.manipulation.reach.mdp as mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_ik_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_ik_rel_env_cfg.py index 91ddcbb851cc..d1207f915d17 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_ik_rel_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_ik_rel_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import bin_stack_joint_pos_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_joint_pos_env_cfg.py index 08f651e954a5..c12d4b1e44b7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_joint_pos_env_cfg.py @@ -12,8 +12,8 @@ from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack import mdp from isaaclab_tasks.manager_based.manipulation.stack.mdp import franka_stack_events diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_abs_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_abs_env_cfg.py index bedcc4a7730d..96b92b69e610 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_abs_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_abs_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass try: import isaacteleop # noqa: F401 -- pipeline builders need isaacteleop at runtime diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_blueprint_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_blueprint_env_cfg.py index c96701e23ee6..5de552b355a5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_blueprint_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_blueprint_env_cfg.py @@ -19,7 +19,7 @@ from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg from isaaclab.sensors import CameraCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ... import mdp from . import stack_joint_pos_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg.py index 1d32adc3426e..724c2d56c440 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg.py @@ -14,8 +14,8 @@ from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack.mdp import franka_stack_events from isaaclab_tasks.manager_based.manipulation.stack.stack_env_cfg import StackEnvCfg, mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg_skillgen.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg_skillgen.py index a8ed078d07d1..55f2696d3f1e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg_skillgen.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg_skillgen.py @@ -8,7 +8,7 @@ from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ... import mdp from . import stack_joint_pos_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_instance_randomize_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_instance_randomize_env_cfg.py index 4f31184585dd..d9174d5d2ebd 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_instance_randomize_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_instance_randomize_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import stack_joint_pos_instance_randomize_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_visuomotor_cosmos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_visuomotor_cosmos_env_cfg.py index ab199c4dcc10..699d617c29a4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_visuomotor_cosmos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_visuomotor_cosmos_env_cfg.py @@ -8,7 +8,7 @@ from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg from isaaclab.sensors import CameraCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_visuomotor_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_visuomotor_env_cfg.py index 624df7a8dc1d..61a85efb5a3c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_visuomotor_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_visuomotor_env_cfg.py @@ -15,8 +15,8 @@ from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, NVIDIA_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack import mdp from isaaclab_tasks.manager_based.manipulation.stack.mdp import franka_stack_events diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py index 83f05e0497df..6eb032060d99 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py @@ -10,8 +10,8 @@ from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack import mdp from isaaclab_tasks.manager_based.manipulation.stack.mdp import franka_stack_events diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_instance_randomize_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_instance_randomize_env_cfg.py index a607f0c78c13..4ead882efe50 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_instance_randomize_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_instance_randomize_env_cfg.py @@ -12,8 +12,8 @@ from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack import mdp from isaaclab_tasks.manager_based.manipulation.stack.mdp import franka_stack_events diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_joint_pos_env_cfg.py index 8c8490ad1c7e..5186720a1a93 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_joint_pos_env_cfg.py @@ -17,8 +17,8 @@ from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg from isaaclab.sim.schemas.schemas_cfg import CollisionPropertiesCfg, RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack import mdp from isaaclab_tasks.manager_based.manipulation.stack.mdp import franka_stack_events diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_rmp_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_rmp_rel_env_cfg.py index 0a9e66310325..25f19e31e631 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_rmp_rel_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_rmp_rel_env_cfg.py @@ -15,7 +15,7 @@ from isaaclab.envs.mdp.actions.rmpflow_actions_cfg import RMPFlowActionCfg from isaaclab.sensors import CameraCfg, FrameTransformerCfg from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py index 0e6b2df08b29..44c2457e3dcc 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py @@ -9,7 +9,7 @@ from isaaclab.devices.keyboard import Se3KeyboardCfg from isaaclab.devices.spacemouse import Se3SpaceMouseCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import stack_joint_pos_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py index 296b95e103a5..40a241f9b904 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py @@ -13,8 +13,8 @@ from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_tasks.manager_based.manipulation.stack import mdp from isaaclab_tasks.manager_based.manipulation.stack.mdp import franka_stack_events diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/stack_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/stack_env_cfg.py index 7706fa3923e3..a27a01de305b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/stack_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/stack_env_cfg.py @@ -18,8 +18,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors.frame_transformer.frame_transformer_cfg import FrameTransformerCfg from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from . import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/stack_instance_randomize_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/stack_instance_randomize_env_cfg.py index 29c280ab98ef..fbb27932ce36 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/stack_instance_randomize_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/stack_instance_randomize_env_cfg.py @@ -16,8 +16,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors.frame_transformer.frame_transformer_cfg import FrameTransformerCfg from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from . import mdp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py index c02c9b0eb0d0..ecbc9fdf455f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/navigation_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/navigation_env_cfg.py index 4d4ad67e9305..eb99d7a59a98 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/navigation_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/navigation_env_cfg.py @@ -12,8 +12,8 @@ from isaaclab.managers import RewardTermCfg as RewTerm from isaaclab.managers import SceneEntityCfg from isaaclab.managers import TerminationTermCfg as DoneTerm -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass import isaaclab_tasks.manager_based.navigation.mdp as mdp from isaaclab_tasks.manager_based.locomotion.velocity.config.anymal_c.flat_env_cfg import AnymalCFlatEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/mdp/pre_trained_policy_action_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/mdp/pre_trained_policy_action_cfg.py index 0c7b54152dfb..49cf75281ad4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/mdp/pre_trained_policy_action_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/mdp/pre_trained_policy_action_cfg.py @@ -6,7 +6,7 @@ from dataclasses import MISSING from isaaclab.managers import ActionTermCfg, ObservationGroupCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py index 8e00b112f058..a843eeb3ab7e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py @@ -34,7 +34,8 @@ from omegaconf import OmegaConf from isaaclab.envs.utils.spaces import replace_env_cfg_spaces_with_strings, replace_strings_with_env_cfg_spaces -from isaaclab.utils import configclass, replace_slices_with_strings, replace_strings_with_slices +from isaaclab.utils import replace_slices_with_strings, replace_strings_with_slices +from isaaclab.utils.configclass import configclass from .preset_target import PresetTarget diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py b/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py index f6fac9cb9aa6..2aec60cce07f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py @@ -7,7 +7,7 @@ from isaaclab_ov.renderers import OVRTXRendererCfg from isaaclab_physx.renderers import IsaacRtxRendererCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg diff --git a/source/isaaclab_tasks/test/test_hydra.py b/source/isaaclab_tasks/test/test_hydra.py index 738884874899..19dd76ab02d4 100644 --- a/source/isaaclab_tasks/test/test_hydra.py +++ b/source/isaaclab_tasks/test/test_hydra.py @@ -13,7 +13,7 @@ import pytest -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import hydra as hydra_mod from isaaclab_tasks.utils.hydra import ( diff --git a/source/isaaclab_tasks/test/test_preset_cli.py b/source/isaaclab_tasks/test/test_preset_cli.py index d6670289a008..7687425cd9f3 100644 --- a/source/isaaclab_tasks/test/test_preset_cli.py +++ b/source/isaaclab_tasks/test/test_preset_cli.py @@ -410,7 +410,7 @@ def test_bucket_variants_routes_by_base_class_isinstance(): """ from isaaclab.physics import PhysicsCfg from isaaclab.renderers.renderer_cfg import RendererCfg - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils.preset_cli import _bucket_variants_by_target from isaaclab_tasks.utils.preset_target import PresetTarget @@ -531,7 +531,7 @@ def test_help_text_branch_strings(monkeypatch, capsys, build_key, expected_phras """ from isaaclab.physics import PhysicsCfg from isaaclab.renderers.renderer_cfg import RendererCfg - from isaaclab.utils import configclass + from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils.hydra import preset diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/allegro_hand/allegro_hand_warp_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/allegro_hand/allegro_hand_warp_env_cfg.py index 2d607d475329..b63983a6fffa 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/allegro_hand/allegro_hand_warp_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/allegro_hand/allegro_hand_warp_env_cfg.py @@ -13,8 +13,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab_assets.robots.allegro import ALLEGRO_HAND_CFG diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/ant/ant_env_warp.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/ant/ant_env_warp.py index a2c8f91fa3db..81df556a6a11 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/ant/ant_env_warp.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/ant/ant_env_warp.py @@ -13,7 +13,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_assets import ANT_CFG diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/cartpole/cartpole_warp_env.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/cartpole/cartpole_warp_env.py index ff3c87da847e..0826f6d98225 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/cartpole/cartpole_warp_env.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/cartpole/cartpole_warp_env.py @@ -17,7 +17,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.sim.spawners.from_files import GroundPlaneCfg, spawn_ground_plane -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_assets.robots.cartpole import CARTPOLE_CFG diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/humanoid/humanoid_warp_env.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/humanoid/humanoid_warp_env.py index 1dc4d23f7814..e5921916af25 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/humanoid/humanoid_warp_env.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/direct/humanoid/humanoid_warp_env.py @@ -13,7 +13,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_assets import HUMANOID_CFG diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/ant/ant_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/ant/ant_env_cfg.py index 106d1a78ba09..b96b3345815b 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/ant/ant_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/ant/ant_env_cfg.py @@ -17,7 +17,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks_experimental.manager_based.classic.humanoid.mdp as mdp diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/cartpole/cartpole_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/cartpole/cartpole_env_cfg.py index 898ac8be4fd0..ae187a593a16 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/cartpole/cartpole_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/cartpole/cartpole_env_cfg.py @@ -18,7 +18,7 @@ from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks_experimental.manager_based.classic.cartpole.mdp as mdp diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/humanoid/humanoid_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/humanoid/humanoid_env_cfg.py index 781541a495e6..64ba177e47a2 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/humanoid/humanoid_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/classic/humanoid/humanoid_env_cfg.py @@ -16,7 +16,7 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks_experimental.manager_based.classic.humanoid.mdp as mdp diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/a1/flat_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/a1/flat_env_cfg.py index b27f8098d629..f213286d0cfb 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/a1/flat_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/a1/flat_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rough_env_cfg import UnitreeA1RoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/a1/rough_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/a1/rough_env_cfg.py index 03aec88a4018..ee0f236043a2 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/a1/rough_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/a1/rough_env_cfg.py @@ -5,7 +5,7 @@ from isaaclab_experimental.managers import TerminationTermCfg as DoneTerm -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks_experimental.manager_based.locomotion.velocity.mdp as mdp from isaaclab_tasks_experimental.manager_based.locomotion.velocity.velocity_env_cfg import ( diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_b/flat_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_b/flat_env_cfg.py index 28c0dc5c26b6..a5c825bf2e98 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_b/flat_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_b/flat_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rough_env_cfg import AnymalBRoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_b/rough_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_b/rough_env_cfg.py index 9811356ef220..3a7d80af470f 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_b/rough_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_b/rough_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks_experimental.manager_based.locomotion.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_c/flat_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_c/flat_env_cfg.py index e82cf9559d61..3cc348a97675 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_c/flat_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_c/flat_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rough_env_cfg import AnymalCRoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_c/rough_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_c/rough_env_cfg.py index 36af75613c07..1d2f2676c64a 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_c/rough_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_c/rough_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks_experimental.manager_based.locomotion.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py index 1f98c1b56120..702443e815e2 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rough_env_cfg import AnymalDRoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_d/rough_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_d/rough_env_cfg.py index 1cafa006ee64..71f49edd2e68 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_d/rough_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/anymal_d/rough_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks_experimental.manager_based.locomotion.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/cassie/flat_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/cassie/flat_env_cfg.py index bcc670cf3788..818c7aa30a1e 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/cassie/flat_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/cassie/flat_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rough_env_cfg import CassieRoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py index 43341babeb3d..2fc44ea36cdc 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab_experimental.managers import RewardTermCfg as RewTerm from isaaclab_experimental.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks_experimental.manager_based.locomotion.velocity.mdp as mdp from isaaclab_tasks_experimental.manager_based.locomotion.velocity.velocity_env_cfg import ( diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/g1/flat_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/g1/flat_env_cfg.py index 3d9047a98506..e99dd95ff82f 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/g1/flat_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/g1/flat_env_cfg.py @@ -7,7 +7,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rough_env_cfg import G1RoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py index db4cc159e4c5..7f9d12d0f201 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab_experimental.managers import RewardTermCfg as RewTerm from isaaclab_experimental.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks_experimental.manager_based.locomotion.velocity.mdp as mdp from isaaclab_tasks_experimental.manager_based.locomotion.velocity.velocity_env_cfg import ( diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go1/flat_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go1/flat_env_cfg.py index e4fbc73e1d08..dd4012882075 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go1/flat_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go1/flat_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rough_env_cfg import UnitreeGo1RoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go1/rough_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go1/rough_env_cfg.py index 2864bbba3130..cb3a602394a6 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go1/rough_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go1/rough_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks_experimental.manager_based.locomotion.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go2/flat_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go2/flat_env_cfg.py index ad8a8aa862f0..349cc450ca9f 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go2/flat_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go2/flat_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rough_env_cfg import UnitreeGo2RoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go2/rough_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go2/rough_env_cfg.py index ff13b7e86176..a25748da9885 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go2/rough_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/go2/rough_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_tasks_experimental.manager_based.locomotion.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/h1/flat_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/h1/flat_env_cfg.py index 22648c27a2c5..a7021343464a 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/h1/flat_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/h1/flat_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .rough_env_cfg import H1RoughEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py index edb71956a3fc..1b65e018c9e4 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py @@ -6,7 +6,7 @@ from isaaclab_experimental.managers import RewardTermCfg as RewTerm from isaaclab_experimental.managers import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks_experimental.manager_based.locomotion.velocity.mdp as mdp from isaaclab_tasks_experimental.manager_based.locomotion.velocity.velocity_env_cfg import ( diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/velocity_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/velocity_env_cfg.py index 7442d3654ba3..52d53c3db268 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/velocity_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/locomotion/velocity/velocity_env_cfg.py @@ -20,8 +20,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors import ContactSensorCfg from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks_experimental.manager_based.locomotion.velocity.mdp as mdp diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/config/franka/joint_pos_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/config/franka/joint_pos_env_cfg.py index 5b3c7ec0c2f4..d6d36afd15f5 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/config/franka/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/config/franka/joint_pos_env_cfg.py @@ -8,7 +8,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks_experimental.manager_based.manipulation.reach.mdp as mdp from isaaclab_tasks_experimental.manager_based.manipulation.reach.reach_env_cfg import ReachEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/config/ur_10/joint_pos_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/config/ur_10/joint_pos_env_cfg.py index 7eddda91f5ac..42104e23b1b2 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/config/ur_10/joint_pos_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/config/ur_10/joint_pos_env_cfg.py @@ -8,7 +8,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass import isaaclab_tasks_experimental.manager_based.manipulation.reach.mdp as mdp from isaaclab_tasks_experimental.manager_based.manipulation.reach.reach_env_cfg import ReachEnvCfg diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/reach_env_cfg.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/reach_env_cfg.py index d019b0531402..e83a27e44db5 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/reach_env_cfg.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/manager_based/manipulation/reach/reach_env_cfg.py @@ -18,7 +18,7 @@ from isaaclab.managers import EventTermCfg as EventTerm from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks_experimental.manager_based.manipulation.reach.mdp as mdp diff --git a/source/isaaclab_teleop/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_teleop/changelog.d/pbarejko-fix-lazy-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py index a15d09bebb01..877d4c47cf87 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py +++ b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py @@ -14,7 +14,7 @@ from isaacteleop.teleop_session_manager import DeadlinePacingConfig, RetargetingExecutionConfig -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from .control_events import TELEOP_CONTROL_CHANNEL_UUID from .xr_cfg import XrCfg diff --git a/source/isaaclab_teleop/isaaclab_teleop/xr_cfg.py b/source/isaaclab_teleop/isaaclab_teleop/xr_cfg.py index 7382f8570252..3a9997a3737e 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/xr_cfg.py +++ b/source/isaaclab_teleop/isaaclab_teleop/xr_cfg.py @@ -13,7 +13,7 @@ import numpy as np -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass class XrAnchorRotationMode(enum.Enum): diff --git a/source/isaaclab_teleop/test/test_oxr_device.py b/source/isaaclab_teleop/test/test_oxr_device.py index be2a604a6489..b574dc5d039e 100644 --- a/source/isaaclab_teleop/test/test_oxr_device.py +++ b/source/isaaclab_teleop/test/test_oxr_device.py @@ -30,7 +30,7 @@ from isaaclab.devices.retargeter_base import RetargeterBase, RetargeterCfg from isaaclab.envs import ManagerBasedEnv, ManagerBasedEnvCfg from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass class NoOpRetargeter(RetargeterBase): 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..068731bbd377 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py @@ -7,7 +7,7 @@ from __future__ import annotations -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.visualizers.visualizer_cfg import VisualizerCfg 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 711e86e03b31..e52bb273c140 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py @@ -5,7 +5,7 @@ """Configuration for Newton OpenGL Visualizer.""" -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.visualizers.visualizer_cfg import VisualizerCfg 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 780b346f802b..dad671bf57f7 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer_cfg.py @@ -7,7 +7,7 @@ from __future__ import annotations -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.visualizers.visualizer_cfg import VisualizerCfg 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 f3f2aa39b0c2..68ab3116b45a 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer_cfg.py @@ -7,7 +7,7 @@ from __future__ import annotations -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.visualizers.visualizer_cfg import VisualizerCfg diff --git a/tools/template/templates/agents/rsl_rl_ppo_cfg b/tools/template/templates/agents/rsl_rl_ppo_cfg index a29f0ae96833..4e7d73970784 100644 --- a/tools/template/templates/agents/rsl_rl_ppo_cfg +++ b/tools/template/templates/agents/rsl_rl_ppo_cfg @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/tools/template/templates/tasks/direct_multi-agent/env_cfg b/tools/template/templates/tasks/direct_multi-agent/env_cfg index 3b207209b736..82eead89ef31 100644 --- a/tools/template/templates/tasks/direct_multi-agent/env_cfg +++ b/tools/template/templates/tasks/direct_multi-agent/env_cfg @@ -9,7 +9,7 @@ from isaaclab.assets import ArticulationCfg from isaaclab.envs import DirectMARLEnvCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/tools/template/templates/tasks/direct_single-agent/env_cfg b/tools/template/templates/tasks/direct_single-agent/env_cfg index 10588cd3e845..af63e341cdd5 100644 --- a/tools/template/templates/tasks/direct_single-agent/env_cfg +++ b/tools/template/templates/tasks/direct_single-agent/env_cfg @@ -9,7 +9,7 @@ from isaaclab.assets import ArticulationCfg from isaaclab.envs import DirectRLEnvCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass diff --git a/tools/template/templates/tasks/manager-based_single-agent/env_cfg b/tools/template/templates/tasks/manager-based_single-agent/env_cfg index 3ab42ecf166b..ff183ceb0604 100644 --- a/tools/template/templates/tasks/manager-based_single-agent/env_cfg +++ b/tools/template/templates/tasks/manager-based_single-agent/env_cfg @@ -15,7 +15,7 @@ from isaaclab.managers import RewardTermCfg as RewTerm from isaaclab.managers import SceneEntityCfg from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from . import mdp From 9297faf491bae7cd23f83b2aad17e9e0d09b2e07 Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Sat, 16 May 2026 23:50:49 +0800 Subject: [PATCH 087/133] Avoid disk I/O when preparing USD stage for OVRTX renderer (#5631) # Description Avoid disk I/O when preparing USD stage for OVRTX renderer ## 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 --- .../huidongc-avoid-ovrtx-disk-rw.rst | 17 +++++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 60 ++++++++++------ .../renderers/ovrtx_renderer_cfg.py | 8 +-- .../isaaclab_ov/renderers/ovrtx_usd.py | 68 +++++++------------ 4 files changed, 80 insertions(+), 73 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/huidongc-avoid-ovrtx-disk-rw.rst diff --git a/source/isaaclab_ov/changelog.d/huidongc-avoid-ovrtx-disk-rw.rst b/source/isaaclab_ov/changelog.d/huidongc-avoid-ovrtx-disk-rw.rst new file mode 100644 index 000000000000..650a7b276acc --- /dev/null +++ b/source/isaaclab_ov/changelog.d/huidongc-avoid-ovrtx-disk-rw.rst @@ -0,0 +1,17 @@ +Changed +^^^^^^^ + +* Changed :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.temp_usd_dir` defaults to ``None``. Set it to a writable + directory when you want the combined stage written to disk for debugging. + +Removed +^^^^^^^ + +* Removed :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.temp_usd_suffix`. When a temp file is written, the renderer + uses ``ovrtx_renderer_stage.usda`` filename under the configured temp directory. + +Fixed +^^^^^ + +* Avoided OVRTX staging disk I/O by exporting the prepared USD to memory and loading it with ``open_usd_from_string`` + instead of always writing intermediate scene and combined USD files. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 4535fd62e478..bf918dedf084 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -22,6 +22,8 @@ import logging import math import os +import tempfile +from pathlib import Path from typing import TYPE_CHECKING, Any logger = logging.getLogger(__name__) @@ -58,9 +60,9 @@ sync_newton_transforms_kernel, ) from .ovrtx_usd import ( + build_render_product_as_string, create_scene_partition_attributes, - export_stage_for_ovrtx, - inject_cameras_into_usd, + export_stage_to_string, ) if TYPE_CHECKING: @@ -164,7 +166,7 @@ def __init__(self, cfg: OVRTXRendererCfg): self._object_binding = None self._object_newton_indices: wp.array | None = None self._initialized_scene = False - self._exported_usd_path: str | None = None + self._exported_usd_string: str | None = None self._camera_rel_path: str | None = None self._output_semantic_color_buffer: wp.array | None = None @@ -192,10 +194,10 @@ def __init__(self, cfg: OVRTXRendererCfg): logger.info("OVRTX renderer created successfully") def prepare_stage(self, stage: Any, num_envs: int) -> None: - """Export the USD stage for OVRTX before create_render_data. + """Prepare the USD stage for OVRTX before :meth:`create_render_data`. - Adds cloning attributes and exports the stage to a temporary file. - The exported path is used by create_render_data when loading into OVRTX. + Adds cloning attributes and exports the stage to a string held on the renderer until + :meth:`create_render_data` is called. """ if stage is None: return @@ -203,10 +205,7 @@ def prepare_stage(self, stage: Any, num_envs: int) -> None: logger.info("Preparing stage for export (%d envs, cloning=%s)...", num_envs, self._use_ovrtx_cloning) create_scene_partition_attributes(stage, num_envs, self._use_ovrtx_cloning, not _IS_OVRTX_0_3_0_OR_NEWER) - export_path = "/tmp/stage_before_ovrtx.usda" - export_stage_for_ovrtx(stage, export_path, num_envs, self._use_ovrtx_cloning) - self._exported_usd_path = export_path - logger.info("Exported to %s", export_path) + self._exported_usd_string = export_stage_to_string(stage, num_envs, self._use_ovrtx_cloning) def _initialize_from_spec(self, spec: CameraRenderSpec): """Initialize the OVRTX renderer with internal environment cloning. @@ -225,14 +224,10 @@ def _initialize_from_spec(self, spec: CameraRenderSpec): raise RuntimeError(f"Expected camera prim under '{env_0_prefix}', got '{first_cam_path}'") self._camera_rel_path = spec.camera_path_relative_to_env_0 - usd_scene_path = self._exported_usd_path - - if usd_scene_path is not None: + if self._exported_usd_string is not None: logger.info("Injecting camera definitions...") - combined_usd_path, render_product_path = inject_cameras_into_usd( - usd_scene_path, - self.cfg, + render_product_string, render_product_path = build_render_product_as_string( width=width, height=height, num_envs=num_envs, @@ -242,15 +237,36 @@ def _initialize_from_spec(self, spec: CameraRenderSpec): ) self._render_product_paths.append(render_product_path) + combined_usd_string = self._exported_usd_string + "\n\n" + render_product_string + self._exported_usd_string = None # Free memory + + if self.cfg.temp_usd_dir is not None: + temp_usd_dir = Path(self.cfg.temp_usd_dir) + elif not _IS_OVRTX_0_3_0_OR_NEWER: + # OVRTX 0.2.0 is not able to load USD from a string, so we need to write to a temporary file. + temp_usd_dir = Path(tempfile.gettempdir()) / "ovrtx" + else: + temp_usd_dir = None + + if temp_usd_dir is not None: + temp_usd_dir.mkdir(parents=True, exist_ok=True) + temp_usd_path = temp_usd_dir / "ovrtx_renderer_stage.usda" + with open(temp_usd_path, "w", encoding="utf-8") as f: + f.write(combined_usd_string) + logger.info("Wrote combined USD stage to %s", temp_usd_path) + else: + temp_usd_path = None + logger.info("Loading USD into OvRTX...") try: if _IS_OVRTX_0_3_0_OR_NEWER: - self._renderer.open_usd(combined_usd_path) - logger.info("USD loaded as root layer (path: %s)", combined_usd_path) + self._renderer.open_usd_from_string(combined_usd_string) + logger.info("OVRTX loaded USD from string successfully") else: - handle = self._renderer.add_usd(combined_usd_path, path_prefix=None) + assert temp_usd_path is not None # OVRTX < 0.3.0 always materializes combined USD on disk. + handle = self._renderer.add_usd(str(temp_usd_path), path_prefix=None) self._usd_handles.append(handle) - logger.info("USD loaded (path: %s, handle: %s)", combined_usd_path, handle) + logger.info("OVRTX loaded USD from file successfully (path: %s, handle: %s)", temp_usd_path, handle) except Exception as e: logger.exception("Error loading USD: %s", e) raise @@ -258,7 +274,7 @@ def _initialize_from_spec(self, spec: CameraRenderSpec): if self._use_ovrtx_cloning and num_envs > 1: logger.info("Using OVRTX internal cloning") self._clone_environments_in_ovrtx(num_envs) - self._update_scene_partitions_after_clone(combined_usd_path, num_envs) + self._update_scene_partitions_after_clone(num_envs) self._initialized_scene = True @@ -299,7 +315,7 @@ def _clone_environments_in_ovrtx(self, num_envs: int): logger.error("Failed to clone environments: %s", e) raise RuntimeError(f"OvRTX environment cloning failed: {e}") - def _update_scene_partitions_after_clone(self, usd_file_path: str, num_envs: int): + def _update_scene_partitions_after_clone(self, num_envs: int): """Update scene partition attributes on cloned environments and cameras in OvRTX.""" logger.info("Writing scene partitions for %d environments...", num_envs) partition_tokens = [f"env_{i}" for i in range(num_envs)] diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py index 6c5a7fc29a0f..1d4f287cd169 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py @@ -5,9 +5,6 @@ """Configuration for OVRTX Renderer.""" -import tempfile -from pathlib import Path - from isaaclab.renderers.renderer_cfg import RendererCfg from isaaclab.utils.configclass import configclass @@ -26,14 +23,11 @@ class OVRTXRendererCfg(RendererCfg): renderer_type: str = "ovrtx" """Type identifier for OVRTX renderer.""" - temp_usd_dir: str = str(Path(tempfile.gettempdir()) / "ovrtx") + temp_usd_dir: str | None = None """Directory for temporary combined USD files (scene + injected cameras). Used by the OVRTX renderer when building the render scope; must be writable. """ - temp_usd_suffix: str = ".usda" - """File suffix for temporary combined USD files (e.g. '.usda' or '.usdc').""" - use_ovrtx_cloning: bool = True """When True, export only env_0 and use OVRTX ``clone_usd``. When False, export full multi-environment stage. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index 03b7b94c51fc..16d941366bad 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -7,15 +7,9 @@ import logging import math -import tempfile -from pathlib import Path -from typing import TYPE_CHECKING from pxr import Sdf, Usd, UsdGeom -if TYPE_CHECKING: - from .ovrtx_renderer_cfg import OVRTXRendererCfg - logger = logging.getLogger(__name__) @@ -105,9 +99,7 @@ def _tiled_resolution(num_envs: int, width: int, height: int) -> tuple[int, int] return num_cols * width, num_rows * height -def inject_cameras_into_usd( - usd_scene_path: str, - cfg: "OVRTXRendererCfg", +def build_render_product_as_string( width: int, height: int, num_envs: int, @@ -115,24 +107,21 @@ def inject_cameras_into_usd( minimal_mode: int | None = None, camera_rel_path: str = "Camera", ) -> tuple[str, str]: - """Inject camera and render product definitions into an existing USD file. + """Build the render product USD snippet as a string. - Reads the USD file, appends a Render scope (cameras + RenderProduct + Vars), - writes to a temp file in cfg.temp_usd_dir, and returns (path_to_combined_usd, render_product_path). + This string is meant to be appended to an exported stage (ASCII) before loading into OVRTX. Args: - usd_scene_path: Path to the base USD scene. - cfg: OVRTX renderer config (simple_shading_mode, temp_usd_dir, temp_usd_suffix). - width: Tile width from sensor config. - height: Tile height from sensor config. + width: Tile width from sensor config [px]. + height: Tile height from sensor config [px]. num_envs: Number of environments from scene. data_types: Data types from sensor config. minimal_mode: RTX minimal mode. None if not requested. Valid values are 1, 2, 3. camera_rel_path: Camera prim path relative to the env root (e.g. ``"Camera"`` or ``"Robot/head_cam"``). - """ - with open(usd_scene_path) as f: - original_usd = f.read() + Returns: + Tuple of (render product USD snippet as a string, absolute render product prim path). + """ data_types = data_types if data_types else ["rgb"] tiled_width, tiled_height = _tiled_resolution(num_envs, width, height) @@ -152,14 +141,7 @@ def inject_cameras_into_usd( tiled_height, minimal_mode, ) - combined_usd = original_usd.rstrip() + "\n\n" + camera_content - - Path(cfg.temp_usd_dir).mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile(mode="w", suffix=cfg.temp_usd_suffix, delete=False, dir=cfg.temp_usd_dir) as f: - f.write(combined_usd) - temp_path = f.name - logger.info("Created combined USD: %s", temp_path) - return temp_path, render_product_path + return camera_content, render_product_path def create_scene_partition_attributes( @@ -207,40 +189,38 @@ def create_scene_partition_attributes( logger.debug("Set scene partition '%s' on prim '%s'", scene_partition, prim.GetPath()) -def export_stage_for_ovrtx(stage, export_path: str, num_envs: int, use_ovrtx_cloning: bool = True) -> str: - """Export the stage to a USD file; when num_envs > 1, only env_0 is exported for OVRTX cloning. +def export_stage_to_string(stage, num_envs: int, use_ovrtx_cloning: bool = True) -> str: + """Export the stage to a string; when num_envs > 1, only env_0 is exported for OVRTX cloning. When num_envs > 1, deactivates env_1..env_{num_envs-1} before export and reactivates - them after, so the file contains only env_0. The stage is modified in place. + them after, so the exported content contains only env_0. The stage is modified in place. Args: stage: USD stage to export. - export_path: Path for the exported file. num_envs: Number of environments. + use_ovrtx_cloning: Whether OVRTX cloning is enabled. Returns: - export_path (same as input). + The exported stage as a string. """ - deactivated = [] + deactivated_prims = [] if use_ovrtx_cloning and num_envs > 1: - logger.info("Deactivating %d cloned environments...", num_envs - 1) + logger.info("Deactivating %d environment roots...", num_envs - 1) for env_idx in range(1, num_envs): env_path = f"/World/envs/env_{env_idx}" prim = stage.GetPrimAtPath(env_path) if prim.IsValid() and prim.IsActive(): prim.SetActive(False) - deactivated.append(prim) - if env_idx <= 3 or env_idx == num_envs - 1: - logger.info("Deactivated: %s", env_path) - if num_envs > 5: - logger.info("... (deactivated %d environments total)", len(deactivated)) + deactivated_prims.append(prim) + logger.debug("Deactivated environment root: %s", env_path) + + logger.info("Deactivated %d environment roots in total", len(deactivated_prims)) try: - stage.Export(export_path) - return export_path + return stage.ExportToString() finally: - if deactivated: - logger.info("Reactivating %d environments...", len(deactivated)) - for prim in deactivated: + if deactivated_prims: + logger.info("Reactivating %d environment roots...", len(deactivated_prims)) + for prim in deactivated_prims: if prim.IsValid(): prim.SetActive(True) From c67ed383cfb2c3d00fa84aed96c4e5f9d260c4c5 Mon Sep 17 00:00:00 2001 From: nvsekkin <72572910+nvsekkin@users.noreply.github.com> Date: Sat, 16 May 2026 11:25:58 -0700 Subject: [PATCH 088/133] Move rendering-correctness tests into dedicated CI jobs (#5649) --- .github/workflows/build.yaml | 46 +++++++++++++++++-- .../esekkin-ci-rendering-correctness-job.skip | 3 ++ tools/test_settings.py | 27 +++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/esekkin-ci-rendering-correctness-job.skip diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0fe48f5d5891..22deff079a4d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -346,7 +346,6 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" - extra-pip-packages: "ovrtx" shard-index: "0" shard-count: "3" container-name: isaac-lab-tasks-1-test @@ -369,7 +368,6 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" - extra-pip-packages: "ovrtx" shard-index: "1" shard-count: "3" container-name: isaac-lab-tasks-2-test @@ -392,7 +390,6 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" - extra-pip-packages: "ovrtx" shard-index: "2" shard-count: "3" container-name: isaac-lab-tasks-3-test @@ -696,6 +693,49 @@ jobs: filter-pattern: "isaaclab_tasks" include-files: "test_environments_training.py" container-name: isaac-lab-environments-training-test + + test-rendering-correctness: + name: "rendering-correctness" + runs-on: [self-hosted, gpu] + timeout-minutes: 120 + continue-on-error: true + needs: [build, config] + if: needs.build.result == 'success' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + lfs: true + - uses: ./.github/actions/run-package-tests + with: + image-tag: ${{ env.CI_IMAGE_TAG }} + isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} + isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} + filter-pattern: "isaaclab_tasks" + include-files: "test_rendering_cartpole.py,test_rendering_dexsuite_kuka.py,test_rendering_registered_tasks.py,test_rendering_shadow_hand.py" + container-name: isaac-lab-rendering-correctness-test + + test-rendering-correctness-kitless: + name: "rendering-correctness-kitless" + runs-on: [self-hosted, gpu] + timeout-minutes: 120 + continue-on-error: true + needs: [build, config] + if: needs.build.result == 'success' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + lfs: true + - uses: ./.github/actions/run-package-tests + with: + image-tag: ${{ env.CI_IMAGE_TAG }} + isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} + isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} + filter-pattern: "isaaclab_tasks" + extra-pip-packages: "ovrtx" + include-files: "test_rendering_cartpole_kitless.py,test_rendering_dexsuite_kuka_kitless.py,test_rendering_shadow_hand_kitless.py" + container-name: isaac-lab-rendering-correctness-kitless-test #endregion #region disabled quarantined tests diff --git a/source/isaaclab_tasks/changelog.d/esekkin-ci-rendering-correctness-job.skip b/source/isaaclab_tasks/changelog.d/esekkin-ci-rendering-correctness-job.skip new file mode 100644 index 000000000000..72c519063879 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/esekkin-ci-rendering-correctness-job.skip @@ -0,0 +1,3 @@ +CI-only change. The rendering-correctness tests now run in two dedicated +``test-rendering-correctness`` and ``test-rendering-correctness-kitless`` +jobs instead of being mixed into the ``isaaclab_tasks [N/3]`` shards. diff --git a/tools/test_settings.py b/tools/test_settings.py index d06c282497c9..e30f4f2236d3 100644 --- a/tools/test_settings.py +++ b/tools/test_settings.py @@ -117,6 +117,29 @@ quarantine them from regular CI. """ +RENDERING_CORRECTNESS_TESTS = [ + "test_rendering_cartpole.py", + "test_rendering_dexsuite_kuka.py", + "test_rendering_registered_tasks.py", + "test_rendering_shadow_hand.py", +] +"""Rendering-correctness tests that run in-kit (non-kitless variants). + +These tests are skipped in the generic ``isaaclab_tasks [N/3]`` CI jobs +and run in the dedicated ``test-rendering-correctness`` CI job. +""" + +RENDERING_CORRECTNESS_KITLESS_TESTS = [ + "test_rendering_cartpole_kitless.py", + "test_rendering_dexsuite_kuka_kitless.py", + "test_rendering_shadow_hand_kitless.py", +] +"""Kitless rendering-correctness tests (OVRTX golden-image comparisons). + +These tests are skipped in the generic ``isaaclab_tasks [N/3]`` CI jobs +and run in the dedicated ``test-rendering-correctness-kitless`` CI job. +""" + TESTS_TO_SKIP = [ # lab "test_argparser_launch.py", # app.close issue @@ -132,6 +155,10 @@ # quarantined tests - run in dedicated CI job that does not block PR merges *QUARANTINED_TESTS, "test_environments_training.py", # Long-running RL training test; runs in dedicated CI job + # rendering-correctness tests - run in dedicated test-rendering-correctness CI job + *RENDERING_CORRECTNESS_TESTS, + # kitless rendering-correctness tests - run in dedicated test-rendering-correctness-kitless CI job + *RENDERING_CORRECTNESS_KITLESS_TESTS, ] """A list of tests to skip in CI (see conftest.py).""" From f8d167c8d3da540bbb74f87793c3d3d3aad95c1a Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Sat, 16 May 2026 16:42:30 -0700 Subject: [PATCH 089/133] Test run time improvement: remove AppLauncher from tests that do not require it (#5646) --- .../changelog.d/pbarejko-kitless-test-suites.skip | 0 source/isaaclab/test/actuators/test_dc_motor.py | 9 --------- .../test/actuators/test_ideal_pd_actuator.py | 9 --------- .../test/controllers/test_local_frame_task.py | 5 ----- .../controllers/test_null_space_posture_task.py | 7 ------- .../test/controllers/test_pink_ik_components.py | 5 ----- .../test/devices/test_device_constructors.py | 9 --------- source/isaaclab/test/envs/test_null_command_term.py | 9 --------- source/isaaclab/test/envs/test_spaces_utils.py | 9 --------- .../test/sensors/test_multi_mesh_ray_caster.py | 5 ----- .../test/terrains/test_terrain_generator.py | 9 --------- .../test/test_mock_interfaces/test_mock_sensors.py | 6 ------ source/isaaclab/test/utils/test_circular_buffer.py | 9 --------- source/isaaclab/test/utils/test_configclass.py | 11 ----------- source/isaaclab/test/utils/test_delay_buffer.py | 9 --------- source/isaaclab/test/utils/test_dict.py | 11 ----------- source/isaaclab/test/utils/test_episode_data.py | 9 --------- .../test/utils/test_hdf5_dataset_file_handler.py | 9 --------- source/isaaclab/test/utils/test_logger.py | 9 --------- source/isaaclab/test/utils/test_math.py | 13 ------------- source/isaaclab/test/utils/test_modifiers.py | 9 --------- source/isaaclab/test/utils/test_noise.py | 2 -- source/isaaclab/test/utils/test_string.py | 11 ----------- .../changelog.d/pbarejko-kitless-test-suites.skip | 0 .../changelog.d/pbarejko-kitless-test-suites.skip | 0 .../isaaclab_rl/test/test_rsl_rl_cfg_deprecation.py | 9 --------- .../changelog.d/pbarejko-kitless-test-suites.skip | 0 .../test/test_openxr_device_constructors.py | 9 --------- 28 files changed, 202 deletions(-) create mode 100644 source/isaaclab/changelog.d/pbarejko-kitless-test-suites.skip create mode 100644 source/isaaclab_contrib/changelog.d/pbarejko-kitless-test-suites.skip create mode 100644 source/isaaclab_rl/changelog.d/pbarejko-kitless-test-suites.skip create mode 100644 source/isaaclab_teleop/changelog.d/pbarejko-kitless-test-suites.skip diff --git a/source/isaaclab/changelog.d/pbarejko-kitless-test-suites.skip b/source/isaaclab/changelog.d/pbarejko-kitless-test-suites.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/test/actuators/test_dc_motor.py b/source/isaaclab/test/actuators/test_dc_motor.py index 26ad2de0526d..81682cc5d224 100644 --- a/source/isaaclab/test/actuators/test_dc_motor.py +++ b/source/isaaclab/test/actuators/test_dc_motor.py @@ -3,15 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.app import AppLauncher - -HEADLESS = True - -# if not AppLauncher.instance(): -simulation_app = AppLauncher(headless=HEADLESS).app - -"""Rest of imports follows""" - import pytest import torch diff --git a/source/isaaclab/test/actuators/test_ideal_pd_actuator.py b/source/isaaclab/test/actuators/test_ideal_pd_actuator.py index d77e5e12c34a..5fc3a675574c 100644 --- a/source/isaaclab/test/actuators/test_ideal_pd_actuator.py +++ b/source/isaaclab/test/actuators/test_ideal_pd_actuator.py @@ -3,15 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.app import AppLauncher - -HEADLESS = True - -# if not AppLauncher.instance(): -simulation_app = AppLauncher(headless=HEADLESS).app - -"""Rest of imports follows""" - import pytest import torch diff --git a/source/isaaclab/test/controllers/test_local_frame_task.py b/source/isaaclab/test/controllers/test_local_frame_task.py index e3ebec56c27b..524b68f83c8a 100644 --- a/source/isaaclab/test/controllers/test_local_frame_task.py +++ b/source/isaaclab/test/controllers/test_local_frame_task.py @@ -5,11 +5,6 @@ """Test cases for LocalFrameTask class.""" -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - from pathlib import Path import numpy as np diff --git a/source/isaaclab/test/controllers/test_null_space_posture_task.py b/source/isaaclab/test/controllers/test_null_space_posture_task.py index 23e75937374b..fa78704bb600 100644 --- a/source/isaaclab/test/controllers/test_null_space_posture_task.py +++ b/source/isaaclab/test/controllers/test_null_space_posture_task.py @@ -2,13 +2,6 @@ # All rights reserved. # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - """Unit tests for NullSpacePostureTask with simplified robot configuration using Pink library directly.""" import numpy as np diff --git a/source/isaaclab/test/controllers/test_pink_ik_components.py b/source/isaaclab/test/controllers/test_pink_ik_components.py index 2533e78e0123..ea5cf8ae344d 100644 --- a/source/isaaclab/test/controllers/test_pink_ik_components.py +++ b/source/isaaclab/test/controllers/test_pink_ik_components.py @@ -5,11 +5,6 @@ """Test cases for PinkKinematicsConfiguration class.""" -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - from pathlib import Path import numpy as np diff --git a/source/isaaclab/test/devices/test_device_constructors.py b/source/isaaclab/test/devices/test_device_constructors.py index c05652a2411c..6c939503fce5 100644 --- a/source/isaaclab/test/devices/test_device_constructors.py +++ b/source/isaaclab/test/devices/test_device_constructors.py @@ -3,15 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - import importlib import json diff --git a/source/isaaclab/test/envs/test_null_command_term.py b/source/isaaclab/test/envs/test_null_command_term.py index c394fc94d5ce..48b48d09a438 100644 --- a/source/isaaclab/test/envs/test_null_command_term.py +++ b/source/isaaclab/test/envs/test_null_command_term.py @@ -3,15 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - from collections import namedtuple import pytest diff --git a/source/isaaclab/test/envs/test_spaces_utils.py b/source/isaaclab/test/envs/test_spaces_utils.py index f170173ea384..13a0725503f9 100644 --- a/source/isaaclab/test/envs/test_spaces_utils.py +++ b/source/isaaclab/test/envs/test_spaces_utils.py @@ -8,15 +8,6 @@ from __future__ import annotations -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - import numpy as np import torch from gymnasium.spaces import Box, Dict, Discrete, MultiDiscrete, Tuple diff --git a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py index 37e4818c7991..dcd58fa5acd6 100644 --- a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py +++ b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py @@ -6,11 +6,6 @@ from __future__ import annotations -from isaaclab.app import AppLauncher - -# launch omniverse app. Used for warp. -app_launcher = AppLauncher(headless=True) - import numpy as np import pytest import torch diff --git a/source/isaaclab/test/terrains/test_terrain_generator.py b/source/isaaclab/test/terrains/test_terrain_generator.py index 804176458cb5..56bee0b2546b 100644 --- a/source/isaaclab/test/terrains/test_terrain_generator.py +++ b/source/isaaclab/test/terrains/test_terrain_generator.py @@ -3,15 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - import os import shutil diff --git a/source/isaaclab/test/test_mock_interfaces/test_mock_sensors.py b/source/isaaclab/test/test_mock_interfaces/test_mock_sensors.py index 949677f1c398..be69316438f7 100644 --- a/source/isaaclab/test/test_mock_interfaces/test_mock_sensors.py +++ b/source/isaaclab/test/test_mock_interfaces/test_mock_sensors.py @@ -5,12 +5,6 @@ """Unit tests for mock sensor interfaces.""" -from isaaclab.app import AppLauncher - -# launch the simulator -app_launcher = AppLauncher(headless=True) -simulation_app = app_launcher.app - import pytest import torch import warp as wp diff --git a/source/isaaclab/test/utils/test_circular_buffer.py b/source/isaaclab/test/utils/test_circular_buffer.py index 7517aca468c5..121e383f3c2a 100644 --- a/source/isaaclab/test/utils/test_circular_buffer.py +++ b/source/isaaclab/test/utils/test_circular_buffer.py @@ -3,15 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app in headless mode -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - import pytest import torch diff --git a/source/isaaclab/test/utils/test_configclass.py b/source/isaaclab/test/utils/test_configclass.py index 60f77367e066..1c2f13c1ef1c 100644 --- a/source/isaaclab/test/utils/test_configclass.py +++ b/source/isaaclab/test/utils/test_configclass.py @@ -5,17 +5,6 @@ from __future__ import annotations -# NOTE: While we don't actually use the simulation app in this test, we still need to launch it -# because warp is only available in the context of a running simulation -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - import copy import os from collections.abc import Callable diff --git a/source/isaaclab/test/utils/test_delay_buffer.py b/source/isaaclab/test/utils/test_delay_buffer.py index a66802e72978..d70d5b77965f 100644 --- a/source/isaaclab/test/utils/test_delay_buffer.py +++ b/source/isaaclab/test/utils/test_delay_buffer.py @@ -3,15 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app in headless mode -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows from here.""" - from collections.abc import Generator import pytest diff --git a/source/isaaclab/test/utils/test_dict.py b/source/isaaclab/test/utils/test_dict.py index 464a26d878ed..b2cbd8bb0e6d 100644 --- a/source/isaaclab/test/utils/test_dict.py +++ b/source/isaaclab/test/utils/test_dict.py @@ -3,17 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -# NOTE: While we don't actually use the simulation app in this test, we still need to launch it -# because warp is only available in the context of a running simulation -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - import random import pytest diff --git a/source/isaaclab/test/utils/test_episode_data.py b/source/isaaclab/test/utils/test_episode_data.py index e7d14adc8aa6..a2d570d9d6ef 100644 --- a/source/isaaclab/test/utils/test_episode_data.py +++ b/source/isaaclab/test/utils/test_episode_data.py @@ -2,15 +2,6 @@ # All rights reserved. # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app in headless mode -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows from here.""" - import pytest import torch diff --git a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py index 123ee95a1157..11e8a434b1ac 100644 --- a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py +++ b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py @@ -2,15 +2,6 @@ # All rights reserved. # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app in headless mode -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows from here.""" - import os import shutil import tempfile diff --git a/source/isaaclab/test/utils/test_logger.py b/source/isaaclab/test/utils/test_logger.py index 1aed1e377e1f..4a6d30135d98 100644 --- a/source/isaaclab/test/utils/test_logger.py +++ b/source/isaaclab/test/utils/test_logger.py @@ -5,15 +5,6 @@ """Tests for logging utilities.""" -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - import logging import os import re diff --git a/source/isaaclab/test/utils/test_math.py b/source/isaaclab/test/utils/test_math.py index 000bf00eb859..c2e5b3550816 100644 --- a/source/isaaclab/test/utils/test_math.py +++ b/source/isaaclab/test/utils/test_math.py @@ -3,19 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first. - -This is only needed because of warp dependency. -""" - -from isaaclab.app import AppLauncher - -# launch omniverse app in headless mode -simulation_app = AppLauncher(headless=True).app - - -"""Rest everything follows.""" - import math from math import pi as PI diff --git a/source/isaaclab/test/utils/test_modifiers.py b/source/isaaclab/test/utils/test_modifiers.py index d01771256059..6e0e39820fdf 100644 --- a/source/isaaclab/test/utils/test_modifiers.py +++ b/source/isaaclab/test/utils/test_modifiers.py @@ -3,15 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - from dataclasses import MISSING import pytest diff --git a/source/isaaclab/test/utils/test_noise.py b/source/isaaclab/test/utils/test_noise.py index e9ab107c69f6..6fe2b0d54ff0 100644 --- a/source/isaaclab/test/utils/test_noise.py +++ b/source/isaaclab/test/utils/test_noise.py @@ -3,8 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Test noise utilities.""" - import pytest import torch diff --git a/source/isaaclab/test/utils/test_string.py b/source/isaaclab/test/utils/test_string.py index 22f51ab6f483..e55d1b1238ea 100644 --- a/source/isaaclab/test/utils/test_string.py +++ b/source/isaaclab/test/utils/test_string.py @@ -3,17 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -# NOTE: While we don't actually use the simulation app in this test, we still need to launch it -# because warp is only available in the context of a running simulation -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - import random import pytest diff --git a/source/isaaclab_contrib/changelog.d/pbarejko-kitless-test-suites.skip b/source/isaaclab_contrib/changelog.d/pbarejko-kitless-test-suites.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_rl/changelog.d/pbarejko-kitless-test-suites.skip b/source/isaaclab_rl/changelog.d/pbarejko-kitless-test-suites.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_rl/test/test_rsl_rl_cfg_deprecation.py b/source/isaaclab_rl/test/test_rsl_rl_cfg_deprecation.py index ad8e79190ce4..b8a952313aae 100644 --- a/source/isaaclab_rl/test/test_rsl_rl_cfg_deprecation.py +++ b/source/isaaclab_rl/test/test_rsl_rl_cfg_deprecation.py @@ -5,15 +5,6 @@ """Tests for handle_deprecated_rsl_rl_cfg across rsl-rl version boundaries.""" -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -app_launcher = AppLauncher(headless=True) -simulation_app = app_launcher.app - -"""Rest everything follows.""" - from dataclasses import MISSING import pytest diff --git a/source/isaaclab_teleop/changelog.d/pbarejko-kitless-test-suites.skip b/source/isaaclab_teleop/changelog.d/pbarejko-kitless-test-suites.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_teleop/test/test_openxr_device_constructors.py b/source/isaaclab_teleop/test/test_openxr_device_constructors.py index ddc4ce275864..5210359ec825 100644 --- a/source/isaaclab_teleop/test/test_openxr_device_constructors.py +++ b/source/isaaclab_teleop/test/test_openxr_device_constructors.py @@ -3,15 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - import importlib from typing import cast From 0dd2412a6ae52eb811e0746fa888ea6294084236 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Sat, 16 May 2026 20:34:52 -0700 Subject: [PATCH 090/133] Split RayCaster into backend-specific implementations (#5510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Refactors `RayCaster` and `RayCasterCamera` into a backend-agnostic base + per-backend implementations under `isaaclab_physx.sensors.ray_caster` and `isaaclab_newton.sensors.ray_caster`, mirroring the iconic split pattern already used by `Pva`, `FrameTransformer`, and `ContactSensor`. The PhysX backend tracks the parent rigid body directly via `RigidObjectView` instead of going through `FabricFrameView`, which fixes the staleness regression from #5179: sensors parented under an articulation / rigid body were returning their spawn-time pose forever during headless training, silently freezing height-scan observations in rough-terrain locomotion (and any similar IMU / camera path that read through `FrameView`). The Newton backend uses the site-based pattern from `Pva` / `FrameTransformer`: walk USD to the rigid-body ancestor, register a body-attached site via `NewtonManager.cl_register_site`, and read per-step transforms off a `SensorFrameTransform` against a shared world-origin reference. Static parents bypass the site machinery (a single `body=-1` global site can't represent per-env world origins) and serve a cached per-env `wp.transformf` array. `MultiMeshRayCaster` / `MultiMeshRayCasterCamera` re-parent onto the new base but keep their `FrameView`-backed body tracker, so the staleness behavior persists there. Tracked as `xfail` in `test_ray_caster_sensor.py` — extending the backend split to MultiMesh is a follow-up. The cfg surface, `class_type` strings, and runtime semantics are unchanged for callers; existing user code does not need to migrate. Fixes #5476 (the `FabricFrameView` contract regression-test PR — the bug it documents is fixed for the single-mesh path here). ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) ## Screenshots N/A — backend refactor, no UI changes. ## 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 added a changelog fragment under \`source//changelog.d/\` for every touched package (do **not** edit \`CHANGELOG.rst\` or bump \`extension.toml\` — CI handles that) - [x] I have added my name to the \`CONTRIBUTORS.md\` or my name already exists there --- .../octi-raycaster-backend-split.minor.rst | 26 + .../isaaclab/sensors/ray_caster/__init__.pyi | 8 + .../ray_caster/base_multi_mesh_ray_caster.py | 473 +++++++++++++ .../base_multi_mesh_ray_caster_camera.py | 262 +++++++ .../sensors/ray_caster/base_ray_caster.py | 360 ++++++++++ .../ray_caster/base_ray_caster_camera.py | 587 ++++++++++++++++ .../isaaclab/sensors/ray_caster/kernels.py | 231 +++++-- .../ray_caster/multi_mesh_ray_caster.py | 439 +----------- .../multi_mesh_ray_caster_camera.py | 284 +------- .../multi_mesh_ray_caster_camera_data.py | 5 +- .../ray_caster/multi_mesh_ray_caster_data.py | 4 +- .../isaaclab/sensors/ray_caster/ray_caster.py | 368 +--------- .../sensors/ray_caster/ray_caster_camera.py | 645 +----------------- .../sensors/check_multi_mesh_ray_caster.py | 29 +- .../isaaclab/test/sensors/check_ray_caster.py | 20 +- .../test_multi_mesh_ray_caster_camera.py | 247 ++++++- .../test/sensors/test_ray_caster_camera.py | 115 ++-- .../sensors/test_ray_caster_integration.py | 169 ++++- .../test/sensors/test_ray_caster_kernels.py | 278 ++++---- .../test/sensors/test_ray_caster_sensor.py | 51 +- .../octi-raycaster-backend-split.minor.rst | 21 + .../isaaclab_newton/sensors/__init__.pyi | 5 + .../sensors/ray_caster/__init__.py | 10 + .../sensors/ray_caster/__init__.pyi | 16 + .../ray_caster/multi_mesh_ray_caster.py | 14 + .../multi_mesh_ray_caster_camera.py | 14 + .../sensors/ray_caster/ray_caster.py | 312 +++++++++ .../sensors/ray_caster/ray_caster_camera.py | 14 + .../octi-raycaster-backend-split.minor.rst | 26 + .../isaaclab_physx/sensors/__init__.pyi | 5 + .../sensors/ray_caster/__init__.py | 10 + .../sensors/ray_caster/__init__.pyi | 16 + .../ray_caster/multi_mesh_ray_caster.py | 14 + .../multi_mesh_ray_caster_camera.py | 14 + .../sensors/ray_caster/ray_caster.py | 210 ++++++ .../sensors/ray_caster/ray_caster_camera.py | 14 + .../octi-raycaster-backend-split.minor.rst | 8 + .../config/kuka_allegro/camera_cfg.py | 58 +- .../manipulation/dexsuite/dexsuite_env_cfg.py | 2 +- 39 files changed, 3343 insertions(+), 2041 deletions(-) create mode 100644 source/isaaclab/changelog.d/octi-raycaster-backend-split.minor.rst create mode 100644 source/isaaclab/isaaclab/sensors/ray_caster/base_multi_mesh_ray_caster.py create mode 100644 source/isaaclab/isaaclab/sensors/ray_caster/base_multi_mesh_ray_caster_camera.py create mode 100644 source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster.py create mode 100644 source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster_camera.py create mode 100644 source/isaaclab_newton/changelog.d/octi-raycaster-backend-split.minor.rst create mode 100644 source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/__init__.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/__init__.pyi create mode 100644 source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/multi_mesh_ray_caster.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/multi_mesh_ray_caster_camera.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/ray_caster.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/ray_caster_camera.py create mode 100644 source/isaaclab_physx/changelog.d/octi-raycaster-backend-split.minor.rst create mode 100644 source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/__init__.py create mode 100644 source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/__init__.pyi create mode 100644 source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/multi_mesh_ray_caster.py create mode 100644 source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/multi_mesh_ray_caster_camera.py create mode 100644 source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster.py create mode 100644 source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster_camera.py create mode 100644 source/isaaclab_tasks/changelog.d/octi-raycaster-backend-split.minor.rst diff --git a/source/isaaclab/changelog.d/octi-raycaster-backend-split.minor.rst b/source/isaaclab/changelog.d/octi-raycaster-backend-split.minor.rst new file mode 100644 index 000000000000..96c6b1d58ec0 --- /dev/null +++ b/source/isaaclab/changelog.d/octi-raycaster-backend-split.minor.rst @@ -0,0 +1,26 @@ +Added +^^^^^ + +* Added :class:`~isaaclab.sensors.ray_caster.BaseRayCaster`, + :class:`~isaaclab.sensors.ray_caster.BaseRayCasterCamera`, + :class:`~isaaclab.sensors.ray_caster.BaseMultiMeshRayCaster`, and + :class:`~isaaclab.sensors.ray_caster.BaseMultiMeshRayCasterCamera` + carrying the backend-agnostic ray-caster logic. Backend subclasses + override only the body-tracker and target-mesh-tracker hooks. + +Changed +^^^^^^^ + +* **Breaking:** Changed :class:`~isaaclab.sensors.camera.CameraData` + camera-owned buffers to :class:`~isaaclab.utils.warp.ProxyArray`. + Access torch tensor operations through the explicit ``.torch`` view. +* :class:`~isaaclab.sensors.ray_caster.RayCaster`, + :class:`~isaaclab.sensors.ray_caster.RayCasterCamera`, + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster`, and + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera` are now + :class:`~isaaclab.utils.backend_utils.FactoryBase` shims dispatching + to PhysX / Newton implementations. Cfg surface and runtime semantics + unchanged. +* Changed ray-caster camera update paths to keep pose, ray, depth, normal, + and mesh-id buffers Warp-owned internally, while exposing public camera + outputs through :class:`~isaaclab.utils.warp.ProxyArray`. diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/__init__.pyi b/source/isaaclab/isaaclab/sensors/ray_caster/__init__.pyi index 44b76de2fa20..034c0ff84ddb 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/__init__.pyi +++ b/source/isaaclab/isaaclab/sensors/ray_caster/__init__.pyi @@ -4,6 +4,10 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "BaseMultiMeshRayCaster", + "BaseMultiMeshRayCasterCamera", + "BaseRayCaster", + "BaseRayCasterCamera", "MultiMeshRayCaster", "MultiMeshRayCasterCamera", "MultiMeshRayCasterCameraCfg", @@ -18,6 +22,10 @@ __all__ = [ "patterns", ] +from .base_multi_mesh_ray_caster import BaseMultiMeshRayCaster +from .base_multi_mesh_ray_caster_camera import BaseMultiMeshRayCasterCamera +from .base_ray_caster import BaseRayCaster +from .base_ray_caster_camera import BaseRayCasterCamera from .multi_mesh_ray_caster import MultiMeshRayCaster from .multi_mesh_ray_caster_camera import MultiMeshRayCasterCamera from .multi_mesh_ray_caster_camera_cfg import MultiMeshRayCasterCameraCfg diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/base_multi_mesh_ray_caster.py b/source/isaaclab/isaaclab/sensors/ray_caster/base_multi_mesh_ray_caster.py new file mode 100644 index 000000000000..43920af58338 --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/ray_caster/base_multi_mesh_ray_caster.py @@ -0,0 +1,473 @@ +# 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 re +from typing import TYPE_CHECKING + +import numpy as np +import trimesh +import warp as wp + +from pxr import UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim.simulation_context import SimulationContext +from isaaclab.utils.mesh import PRIMITIVE_MESH_TYPES, create_trimesh_from_geom_mesh, create_trimesh_from_geom_shape +from isaaclab.utils.warp import ProxyArray, convert_to_warp_mesh +from isaaclab.utils.warp import kernels as warp_kernels + +from .base_ray_caster import BaseRayCaster +from .kernels import copy_mesh_poses_to_table_kernel, fill_ray_hits_distance_inf_kernel +from .multi_mesh_ray_caster_data import MultiMeshRayCasterData + +if TYPE_CHECKING: + from isaaclab.cloner import ClonePlan + + from .multi_mesh_ray_caster_cfg import MultiMeshRayCasterCfg + +logger = logging.getLogger(__name__) + + +def _matrix_from_quat_xyzw(quat: np.ndarray) -> np.ndarray: + """Return a rotation matrix from an ``(x, y, z, w)`` quaternion.""" + x, y, z, w = quat + two_s = 2.0 / np.dot(quat, quat) + return np.array( + [ + [1.0 - two_s * (y * y + z * z), two_s * (x * y - z * w), two_s * (x * z + y * w)], + [two_s * (x * y + z * w), 1.0 - two_s * (x * x + z * z), two_s * (y * z - x * w)], + [two_s * (x * z - y * w), two_s * (y * z + x * w), 1.0 - two_s * (x * x + y * y)], + ], + dtype=np.float64, + ) + + +class BaseMultiMeshRayCaster(BaseRayCaster): + """A multi-mesh ray-casting sensor. + + The ray-caster uses a set of rays to detect collisions with meshes in the scene. The rays are + defined in the sensor's local coordinate frame. The sensor can be configured to ray-cast against + 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` 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: + + - Raycasting against multiple target types : Supports primitive shapes (spheres, cubes, etc.) as well as arbitrary + meshes. + - Dynamic mesh tracking : Keeps track of specified meshes, enabling raycasting against moving parts + (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 + + ray_caster_cfg = MultiMeshRayCasterCfg( + prim_path="{ENV_REGEX_NS}/Robot", + mesh_prim_paths=[ + "/World/Ground", + MultiMeshRayCasterCfg.RaycastTargetCfg(prim_expr="{ENV_REGEX_NS}/Robot/LF_.*/visuals"), + MultiMeshRayCasterCfg.RaycastTargetCfg(prim_expr="{ENV_REGEX_NS}/Robot/RF_.*/visuals"), + MultiMeshRayCasterCfg.RaycastTargetCfg(prim_expr="{ENV_REGEX_NS}/Robot/LH_.*/visuals"), + MultiMeshRayCasterCfg.RaycastTargetCfg(prim_expr="{ENV_REGEX_NS}/Robot/RH_.*/visuals"), + MultiMeshRayCasterCfg.RaycastTargetCfg(prim_expr="{ENV_REGEX_NS}/Robot/base/visuals"), + ], + ray_alignment="world", + pattern_cfg=patterns.GridPatternCfg(resolution=0.02, size=(2.5, 2.5), direction=(0, 0, -1)), + ) + + """ + + cfg: MultiMeshRayCasterCfg + """The configuration parameters.""" + + def __init__(self, cfg: MultiMeshRayCasterCfg): + """Initializes the ray-caster object. + + Args: + cfg: The configuration parameters. + """ + super().__init__(cfg) + + self._num_meshes_per_env: dict[str, int] = {} + + self._raycast_targets_cfg: list[MultiMeshRayCasterCfg.RaycastTargetCfg] = [] + for target in self.cfg.mesh_prim_paths: + if isinstance(target, str): + target_cfg = cfg.RaycastTargetCfg(prim_expr=target, track_mesh_transforms=False) + else: + target_cfg = target + target_cfg.prim_expr = target_cfg.prim_expr.format(ENV_REGEX_NS="/World/envs/env_.*") + self._raycast_targets_cfg.append(target_cfg) + + 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" + f"\tupdate period (s) : {self.cfg.update_period}\n" + f"\tnumber of meshes : {self._num_envs} x {sum(self._num_meshes_per_env.values())} \n" + f"\tnumber of sensors : {self._view_count}\n" + f"\tnumber of rays/sensor: {self.num_rays}\n" + f"\ttotal number of rays : {self.num_rays * self._view_count}" + ) + + """ + Properties + """ + + @property + def data(self) -> MultiMeshRayCasterData: + self._update_outdated_buffers() + return self._data + + """ + Implementation. + """ + + def _initialize_warp_meshes(self): + """Initialize mesh buffers from the ClonePlan when env-scoped, else from the stage.""" + sim = SimulationContext.instance() + plan = sim.get_clone_plan() if sim is not None else None + target_records_by_expr = {} + dummy_mesh_id: int | None = None + self._mesh_views = [] + + # Build one per-env mesh list for each configured raycast target. + for target_cfg in self._raycast_targets_cfg: + records_per_env, dummy_mesh_id, tracked_target_exprs = self._build_mesh_records( + target_cfg, plan, dummy_mesh_id + ) + self._num_meshes_per_env[target_cfg.prim_expr] = max(len(records) for records in records_per_env) + target_records_by_expr[target_cfg.prim_expr] = records_per_env + self._mesh_views.append( + self._create_tracked_target_view(tracked_target_exprs) if target_cfg.track_mesh_transforms else None + ) + + if dummy_mesh_id is None: + raise RuntimeError( + f"No meshes found for ray-casting! Please check the mesh prim paths: {self.cfg.mesh_prim_paths}" + ) + + total_meshes_per_env = sum( + self._num_meshes_per_env[target_cfg.prim_expr] for target_cfg in self._raycast_targets_cfg + ) + mesh_ids = np.full((self._num_envs, total_meshes_per_env), dummy_mesh_id, dtype=np.uint64) + mesh_positions = np.full((self._num_envs, total_meshes_per_env, 3), 1.0e9, dtype=np.float32) + mesh_orientations = np.zeros((self._num_envs, total_meshes_per_env, 4), dtype=np.float32) + mesh_orientations[..., 3] = 1.0 + + mesh_offset = 0 + for target_cfg in self._raycast_targets_cfg: + records_per_env = target_records_by_expr[target_cfg.prim_expr] + target_width = self._num_meshes_per_env[target_cfg.prim_expr] + for env_id, records in enumerate(records_per_env): + if not records: + continue + count = len(records) + record_mesh_ids, record_positions, record_orientations = zip(*records) + target_slice = slice(mesh_offset, mesh_offset + count) + mesh_ids[env_id, target_slice] = np.asarray(record_mesh_ids, dtype=np.uint64) + mesh_positions[env_id, target_slice] = np.asarray(record_positions, dtype=np.float32) + mesh_orientations[env_id, target_slice] = np.asarray(record_orientations, dtype=np.float32) + mesh_offset += target_width + + self._mesh_ids_wp = wp.array2d(mesh_ids, dtype=wp.uint64, device=self.device) + self._mesh_positions_w = wp.array2d(mesh_positions, dtype=wp.vec3f, device=self.device) + self._mesh_orientations_w = wp.array2d(mesh_orientations, dtype=wp.quatf, device=self.device) + + def _build_mesh_records( + self, + target_cfg: MultiMeshRayCasterCfg.RaycastTargetCfg, + plan: ClonePlan | None, + dummy_mesh_id: int | None, + ): + """Build mesh records for the target configuration.""" + records_per_env = [[] for _ in range(self._num_envs)] + target_in_plan = False + tracked_target_exprs: list[str] = [target_cfg.prim_expr] + + # Prefer ClonePlan data for env-scoped targets; destination USD prims may not exist. + if plan is not None and target_cfg.track_mesh_transforms: + target_path = re.sub(r"env_\.\*", "env_0", target_cfg.prim_expr) + plan_tracked_target_exprs: list[str] = [] + for row, (source_root, destination_template) in enumerate(zip(plan.sources, plan.destinations)): + if "{}" not in destination_template: + continue + + dest_path = destination_template.format(0) + suffix = target_path.removeprefix(dest_path) + if suffix == target_path or (suffix and not suffix.startswith("/")): + continue + + target_in_plan = True + env_ids = plan.clone_mask[row].nonzero(as_tuple=False).squeeze(-1) + if env_ids.numel() == 0: + continue + + # Load meshes from the authored source row. + source_prims = sim_utils.find_matching_prims(source_root + suffix) + if not source_prims: + raise RuntimeError(f"No ClonePlan source prims matched '{source_root + suffix}'.") + + mesh_ids: list[int] = [] + row_tracked_target_exprs: list[str] = [] + for source_prim in source_prims: + owner_prim = source_prim + while owner_prim and owner_prim.IsValid() and str(owner_prim.GetPath()) != "/": + if owner_prim.HasAPI(UsdPhysics.RigidBodyAPI): + break + owner_prim = owner_prim.GetParent() + if owner_prim is None or not owner_prim.IsValid() or not owner_prim.HasAPI(UsdPhysics.RigidBodyAPI): + raise RuntimeError( + f"Cannot track ClonePlan target '{target_cfg.prim_expr}' because source prim " + f"'{source_prim.GetPath()}' has no rigid-body ancestor." + ) + mesh_id = self._load_target_prim_warp_mesh(source_prim, target_cfg, reference_prim=owner_prim) + dummy_mesh_id = mesh_id if dummy_mesh_id is None else dummy_mesh_id + mesh_ids.append(mesh_id) + owner_path = str(owner_prim.GetPath()) + if owner_path == source_root: + owner_suffix = "" + elif owner_path.startswith(source_root + "/"): + owner_suffix = owner_path[len(source_root) :] + else: + raise RuntimeError( + f"Tracked target owner '{owner_path}' is not under ClonePlan source root '{source_root}'." + ) + row_tracked_target_exprs.append(destination_template.replace("{}", ".*") + owner_suffix) + + if len(row_tracked_target_exprs) > len(plan_tracked_target_exprs): + plan_tracked_target_exprs = row_tracked_target_exprs + + # Geometry is selected by ClonePlan; live pose is supplied by backend body/site views. + for env_id in env_ids.tolist(): + for mesh_id in mesh_ids: + records_per_env[env_id].append((mesh_id, (1.0e9, 1.0e9, 1.0e9), (0.0, 0.0, 0.0, 1.0))) + + if target_in_plan: + if not plan_tracked_target_exprs: + raise RuntimeError( + f"No tracked body expressions were resolved for target '{target_cfg.prim_expr}'." + ) + return records_per_env, dummy_mesh_id, plan_tracked_target_exprs + + # Fall back to authored USD prims for global targets and scenes without ClonePlan data. + target_prims = sim_utils.find_matching_prims(target_cfg.prim_expr) + if not target_prims: + raise RuntimeError(f"Failed to find a prim at path expression: {target_cfg.prim_expr}") + + records = [] + tracked_target_exprs = [] + for target_prim in target_prims: + reference_prim = target_prim + if target_cfg.track_mesh_transforms: + while reference_prim and reference_prim.IsValid() and str(reference_prim.GetPath()) != "/": + if reference_prim.HasAPI(UsdPhysics.RigidBodyAPI): + break + reference_prim = reference_prim.GetParent() + if ( + reference_prim is None + or not reference_prim.IsValid() + or not reference_prim.HasAPI(UsdPhysics.RigidBodyAPI) + ): + raise RuntimeError( + f"Cannot track non-physics ray-cast target '{target_cfg.prim_expr}'. " + "Set track_mesh_transforms=False for static targets, or apply RigidBodyAPI to dynamic targets." + ) + tracked_target_exprs.append(str(reference_prim.GetPath())) + + mesh_id = self._load_target_prim_warp_mesh(target_prim, target_cfg, reference_prim=reference_prim) + dummy_mesh_id = mesh_id if dummy_mesh_id is None else dummy_mesh_id + pos, quat = sim_utils.resolve_prim_pose(reference_prim) + pos = (float(pos[0]), float(pos[1]), float(pos[2])) + quat = (float(quat[0]), float(quat[1]), float(quat[2]), float(quat[3])) + records.append((mesh_id, pos, quat)) + + if len(records) == 1: + return [list(records) for _ in range(self._num_envs)], dummy_mesh_id, tracked_target_exprs + + # Multiple USD matches are expected to be laid out evenly by environment. + if len(records) % self._num_envs != 0: + raise RuntimeError( + f"Target expression '{target_cfg.prim_expr}' matched {len(records)} mesh records, " + f"which cannot be evenly partitioned across {self._num_envs} environments." + ) + n_meshes = len(records) // self._num_envs + records_per_env = [records[env_id * n_meshes : (env_id + 1) * n_meshes] for env_id in range(self._num_envs)] + + return records_per_env, dummy_mesh_id, tracked_target_exprs + + def _load_target_prim_warp_mesh(self, target_prim, target_cfg, reference_prim=None) -> int: + reference_prim = target_prim if reference_prim is None else reference_prim + prim_key = (f"{target_prim.GetPath()}@{reference_prim.GetPath()}", self._device) + if prim_key in BaseMultiMeshRayCaster.meshes: + return BaseMultiMeshRayCaster.meshes[prim_key].id + + mesh_prims = sim_utils.get_all_matching_child_prims( + target_prim.GetPath(), lambda prim: prim.GetTypeName() in PRIMITIVE_MESH_TYPES + ["Mesh"] + ) + if len(mesh_prims) == 0: + raise RuntimeError( + f"No mesh prims found at path: {target_prim.GetPath()} with supported types:" + f" {PRIMITIVE_MESH_TYPES + ['Mesh']}" + ) + + trimesh_meshes = [] + for mesh_prim in mesh_prims: + if mesh_prim is None or not mesh_prim.IsValid(): + raise RuntimeError(f"Invalid mesh prim path: {target_prim}") + if mesh_prim.GetTypeName() == "Mesh": + mesh = create_trimesh_from_geom_mesh(mesh_prim) + else: + mesh = create_trimesh_from_geom_shape(mesh_prim) + mesh.apply_scale(sim_utils.resolve_prim_scale(mesh_prim)) + relative_pos, relative_quat = sim_utils.resolve_prim_pose(mesh_prim, reference_prim) + relative_pos = np.asarray(relative_pos, dtype=np.float64) + relative_quat = np.asarray(relative_quat, dtype=np.float64) + transform = np.eye(4) + transform[:3, :3] = _matrix_from_quat_xyzw(relative_quat) + transform[:3, 3] = relative_pos + mesh.apply_transform(transform) + trimesh_meshes.append(mesh) + + if len(trimesh_meshes) == 1: + trimesh_mesh = trimesh_meshes[0] + elif target_cfg.merge_prim_meshes: + trimesh_mesh = trimesh.util.concatenate(trimesh_meshes) + else: + raise RuntimeError( + f"Multiple mesh prims found at path: {target_prim.GetPath()} but merging is disabled. Please" + " enable `merge_prim_meshes` in the configuration or specify each mesh separately." + ) + + wp_mesh = convert_to_warp_mesh(trimesh_mesh.vertices, trimesh_mesh.faces, device=self._device) + BaseMultiMeshRayCaster.meshes[prim_key] = wp_mesh + logger.info( + f"Read '{len(mesh_prims)}' mesh prims under path '{target_prim.GetPath()}' with" + f" {len(trimesh_mesh.vertices)} vertices and {len(trimesh_mesh.faces)} faces." + ) + return wp_mesh.id + + def _create_tracked_target_view(self, target_prim_paths: str | list[str]): + raise NotImplementedError("Tracked multi-mesh targets must be implemented by the active physics backend.") + + def _initialize_rays_impl(self): + super()._initialize_rays_impl() + # Persistent buffer for tracking closest-hit distance across meshes (for atomic_min) + self._ray_distance_wp = wp.empty((self._view_count, self.num_rays), dtype=wp.float32, device=self._device) + if self.cfg.update_mesh_ids: + self._data.ray_mesh_ids = ProxyArray( + wp.zeros((self._view_count, self.num_rays), dtype=wp.int16, device=self._device) + ) + else: + # Dummy 1×1 buffer so the kernel launch always has a valid array to bind + self._ray_mesh_id_wp = 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_wp = wp.empty((1, 1), dtype=wp.vec3, device=self._device) + self._dummy_face_id_wp = 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 + the rectangular mesh pose buffers. 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 + + pos_w, ori_w = view.get_world_poses(None) + view_count = getattr(view, "count", pos_w.warp.shape[0]) + meshes_per_env = view_count + if view_count != 1: + # Backend views return a flat list across envs; the mesh table is indexed per env. + meshes_per_env = view_count // self._num_envs + + wp.launch( + copy_mesh_poses_to_table_kernel, + dim=(self._num_envs, meshes_per_env), + inputs=[ + pos_w.warp, + ori_w.warp, + int(meshes_per_env), + int(mesh_idx), + bool(view_count == 1), + self._mesh_positions_w, + self._mesh_orientations_w, + ], + device=self._device, + ) + mesh_idx += self._num_meshes_per_env[target_cfg.prim_expr] + + 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() + + # Fill output and distance buffers with inf for masked environments + wp.launch( + fill_ray_hits_distance_inf_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, False], + outputs=[self._data._ray_hits_w, self._ray_distance_wp, self._dummy_normal_wp], + device=self._device, + ) + + n_meshes = self._mesh_ids_wp.shape[1] + return_normal = False + return_face_id = False + write_mesh_ids = self.cfg.update_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_wp, + self._dummy_normal_wp, + self._dummy_face_id_wp, + self._data.ray_mesh_ids.warp if self.cfg.update_mesh_ids else self._ray_mesh_id_wp, + self._mesh_positions_w, + self._mesh_orientations_w, + float(self.cfg.max_distance), + int(return_normal), + int(return_face_id), + int(write_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 + self._mesh_views = [] diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/base_multi_mesh_ray_caster_camera.py b/source/isaaclab/isaaclab/sensors/ray_caster/base_multi_mesh_ray_caster_camera.py new file mode 100644 index 000000000000..cc9f660edc62 --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/ray_caster/base_multi_mesh_ray_caster_camera.py @@ -0,0 +1,262 @@ +# 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 + +from typing import TYPE_CHECKING + +import warp as wp + +from isaaclab.utils.warp import ProxyArray +from isaaclab.utils.warp import kernels as warp_kernels + +from . import kernels as ray_caster_kernels +from .base_multi_mesh_ray_caster import BaseMultiMeshRayCaster +from .base_ray_caster_camera import BaseRayCasterCamera +from .multi_mesh_ray_caster_camera_data import MultiMeshRayCasterCameraData + +if TYPE_CHECKING: + from .multi_mesh_ray_caster_camera_cfg import MultiMeshRayCasterCameraCfg + + +class BaseMultiMeshRayCasterCamera(BaseRayCasterCamera, BaseMultiMeshRayCaster): + """A multi-mesh ray-casting camera sensor. + + The ray-caster camera uses a set of rays to get the distances to meshes in the scene. The rays are + defined in the sensor's local coordinate frame. The sensor has the same interface as the + :class:`isaaclab.sensors.Camera` that implements the camera class through USD camera prims. + However, this class provides a faster image generation. The sensor converts meshes from the list of + primitive paths provided in the configuration to Warp meshes. The camera then ray-casts against these + Warp meshes only. + + Currently, only the following annotators are supported: + + - ``"distance_to_camera"``: An image containing the distance to camera optical center. + - ``"distance_to_image_plane"``: An image containing distances of 3D points from camera plane along camera's z-axis. + - ``"normals"``: An image containing the local surface normal vectors at each pixel. + """ + + cfg: MultiMeshRayCasterCameraCfg + """The configuration parameters.""" + + def __init__(self, cfg: MultiMeshRayCasterCameraCfg): + """Initializes the camera object. + + Args: + cfg: The configuration parameters. + + Raises: + ValueError: If the provided data types are not supported by the ray-caster camera. + """ + self._check_supported_data_types(cfg) + # initialize base class + BaseMultiMeshRayCaster.__init__(self, cfg) + # create empty variables for storing output data + self._data = MultiMeshRayCasterCameraData() + + def __str__(self) -> str: + """Returns: A string containing information about the instance.""" + return ( + f"Multi-Mesh Ray-Caster-Camera @ '{self.cfg.prim_path}': \n" + f"\tview type : {self._view.__class__}\n" + f"\tupdate period (s) : {self.cfg.update_period}\n" + f"\tnumber of meshes : {self._num_envs} x {sum(self._num_meshes_per_env.values())}\n" + f"\tnumber of sensors : {self._view.count}\n" + f"\tnumber of rays/sensor: {self.num_rays}\n" + f"\ttotal number of rays : {self.num_rays * self._view.count}\n" + f"\timage shape : {self.image_shape}" + ) + + def _initialize_warp_meshes(self): + # The camera MRO would pick the single-mesh camera path; use the multi-mesh setup. + BaseMultiMeshRayCaster._initialize_warp_meshes(self) + + def _create_buffers(self): + super()._create_buffers() + self._data.image_mesh_ids = ProxyArray( + wp.zeros((self._num_envs, *self.image_shape, 1), dtype=wp.int16, device=self.device) + ) + + def _initialize_rays_impl(self): + # 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_wp / _ray_mesh_id_wp 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._frame_wp = wp.zeros(self._view.count, dtype=wp.int64, device=self._device) + self._frame = wp.to_torch(self._frame_wp) + + # Build camera output buffers (intrinsics, image data, etc.) + self._create_buffers() + self._compute_intrinsic_matrices() + + # 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.torch, self._device + ) + self.num_rays = ray_directions_local.shape[1] + + # Store local (sensor-frame) ray arrays as ProxyArrays for Warp kernel dispatch. + self.ray_starts = ProxyArray(wp.from_torch(ray_starts_local.contiguous(), dtype=wp.vec3f)) + self.ray_directions = ProxyArray(wp.from_torch(ray_directions_local.contiguous(), dtype=wp.vec3f)) + + # Camera-frame offset and drift buffers. + self._create_offset_buffers(self._view.count) + + # World-frame ray buffers. + self._ray_starts_w = wp.empty((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + self._ray_directions_w = wp.empty((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + + # Ray hit positions as a warp array; expose a ProxyArray for debug visualisation. + self.ray_hits_w = ProxyArray(wp.empty((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device)) + + # Per-ray closest-hit distance for atomic_min across meshes + self._ray_distance_cam_wp = wp.empty((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.empty((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + + # Mesh-id buffers from MultiMeshRayCaster._initialize_rays_impl + if self.cfg.update_mesh_ids: + self._ray_mesh_id_wp = wp.zeros((self._view.count, self.num_rays), dtype=wp.int16, device=self._device) + else: + self._ray_mesh_id_wp = 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_wp = wp.empty((1, 1), dtype=wp.int32, device=self._device) + + 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,). + """ + transforms = self._get_view_transforms_wp() + wp.launch( + ray_caster_kernels.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.warp, + self.ray_cast_drift.warp, + self.ray_starts.warp, + self.ray_directions.warp, + int(ray_caster_kernels.ALIGNMENT_BASE), + ], + outputs=[ + self._data.pos_w.warp, + self._data.quat_w_world.warp, + 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.""" + self._update_ray_infos(env_mask) + + # Increment frame count for updated environments + self._update_frame(env_mask, frame_op=1) + + self._update_mesh_transforms() + + return_normal = "normals" in self.cfg.data_types + + # Fill ray hit, distance, and optional normal buffers with inf for masked environments. + wp.launch( + ray_caster_kernels.fill_ray_hits_distance_inf_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, return_normal], + outputs=[self.ray_hits_w.warp, self._ray_distance_cam_wp, self._ray_normal_w], + device=self._device, + ) + + n_meshes = self._mesh_ids_wp.shape[1] + + # 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.warp, + self._ray_distance_cam_wp, + self._ray_normal_w, + self._ray_face_id_wp, + self._ray_mesh_id_wp, + self._mesh_positions_w, + self._mesh_orientations_w, + float(ray_caster_kernels.CAMERA_RAYCAST_MAX_DIST), + int(return_normal), + int(False), + int(self.cfg.update_mesh_ids), + ], + device=self._device, + ) + + if "distance_to_image_plane" in self.cfg.data_types: + wp.launch( + ray_caster_kernels.compute_distance_to_image_plane_to_image_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[ + env_mask, + self._data.quat_w_world.warp, + self._ray_distance_cam_wp, + self._ray_directions_w, + int(self.image_shape[1]), + bool(self._depth_clip_enabled), + float(self.cfg.max_distance), + self._depth_clip_fill_value, + ], + outputs=[ + self._data.output["distance_to_image_plane"].warp, + ], + device=self._device, + ) + + if "distance_to_camera" in self.cfg.data_types: + wp.launch( + ray_caster_kernels.copy_float2d_to_image1_depth_clipped_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[ + env_mask, + self._ray_distance_cam_wp, + int(self.image_shape[1]), + bool(self._depth_clip_enabled), + float(self.cfg.max_distance), + self._depth_clip_fill_value, + ], + outputs=[ + self._data.output["distance_to_camera"].warp, + ], + device=self._device, + ) + + if return_normal: + wp.launch( + ray_caster_kernels.copy_vec3_2d_to_image3_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, self._ray_normal_w, int(self.image_shape[1]), self._data.output["normals"].warp], + device=self._device, + ) + + if self.cfg.update_mesh_ids: + wp.launch( + ray_caster_kernels.copy_int16_2d_to_image1_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, self._ray_mesh_id_wp, int(self.image_shape[1]), self._data.image_mesh_ids.warp], + device=self._device, + ) diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster.py b/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster.py new file mode 100644 index 000000000000..27af7fa8b0be --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster.py @@ -0,0 +1,360 @@ +# 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 +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np +import torch +import warp as wp + +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.terrains.trimesh.utils import make_plane +from isaaclab.utils.warp import ProxyArray, convert_to_warp_mesh +from isaaclab.utils.warp.kernels import raycast_mesh_masked_kernel + +from ..sensor_base import SensorBase +from . import kernels as ray_caster_kernels +from .ray_caster_data import RayCasterData + +if TYPE_CHECKING: + from .ray_caster_cfg import RayCasterCfg + +logger = logging.getLogger(__name__) + + +class BaseRayCaster(SensorBase): + """A ray-casting sensor. + + The ray-caster uses a set of rays to detect collisions with meshes in the scene. The rays are + defined in the sensor's local coordinate frame. The sensor can be configured to ray-cast against + 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` 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 + is a work in progress. + """ + + cfg: RayCasterCfg + """The configuration parameters.""" + + meshes: ClassVar[dict[tuple[str, str], wp.Mesh]] = {} + """A dictionary to store warp meshes for raycasting, shared across all instances. + + The keys are ``(prim_path, device)`` tuples and values are the corresponding warp Mesh objects. Meshes are + created lazily for the sensor's active device, not eagerly for every device. 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) when multiple simulation contexts or tests use different devices in the same Python process.""" + _instance_count: ClassVar[int] = 0 + """A counter to track the number of RayCaster instances, used to manage class variable lifecycle.""" + + def __init__(self, cfg: RayCasterCfg): + """Initializes the ray-caster object. + + Args: + cfg: The configuration parameters. + """ + BaseRayCaster._instance_count += 1 + super().__init__(cfg) + # Resolve physics-body paths and spawn the sensor Xform child if needed. + self._requested_prim_path = self.cfg.prim_path + self._resolve_and_spawn("raycaster") + self._data = RayCasterData() + + 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" + f"\tupdate period (s) : {self.cfg.update_period}\n" + f"\tnumber of meshes : {len(BaseRayCaster.meshes)}\n" + f"\tnumber of sensors : {self._view_count}\n" + f"\tnumber of rays/sensor: {self.num_rays}\n" + f"\ttotal number of rays : {self.num_rays * self._view_count}" + ) + + """ + Properties + """ + + @property + def num_instances(self) -> int: + return self._view_count + + @property + def data(self) -> RayCasterData: + # update sensors if needed + self._update_outdated_buffers() + # return the data + return self._data + + """ + Operations. + """ + + def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None): + # reset the timers and counters + super().reset(env_ids, env_mask) + # resolve to indices for torch indexing + if env_ids is not None: + num_envs_ids = len(env_ids) + elif env_mask is not None: + env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) + num_envs_ids = len(env_ids) + else: + env_ids = slice(None) + num_envs_ids = self._view_count + # resample drift (uses torch views for indexing) + r = torch.empty(num_envs_ids, 3, device=self.device) + self.drift.torch[env_ids] = r.uniform_(*self.cfg.drift_range) + # 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.torch[env_ids] = math_utils.sample_uniform( + ranges[:, 0], ranges[:, 1], (num_envs_ids, 3), device=self.device + ) + + """ + Implementation. + """ + + def _initialize_impl(self): + super()._initialize_impl() + self._initialize_pose_tracking() + if not hasattr(self, "_view_count"): + view: Any = self._view + self._view_count = view.count + + # 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() + self._initialize_rays_impl() + + def _initialize_pose_tracking(self) -> None: + """Initialize backend-specific sensor pose tracking. + + Backend subclasses must set ``_view_count`` and provide transforms + through either ``_view.get_world_poses(indices=None)`` or an override of + :meth:`_get_view_transforms_wp`. They must also set ``_offset_pos_wp`` + and ``_offset_quat_wp`` to the sensor-frame offset relative to the + tracked backend body/site. + """ + raise NotImplementedError(f"{self.__class__.__name__} must initialize backend pose tracking.") + + def _initialize_warp_meshes(self): + # check number of mesh prims provided + if len(self.cfg.mesh_prim_paths) != 1: + raise NotImplementedError( + f"RayCaster currently only supports one mesh prim. Received: {len(self.cfg.mesh_prim_paths)}" + ) + + # read prims to ray-cast + for mesh_prim_path in self.cfg.mesh_prim_paths: + mesh_key = (mesh_prim_path, self._device) + if mesh_key in BaseRayCaster.meshes: + continue + + mesh_prim = sim_utils.get_first_matching_child_prim( + mesh_prim_path, lambda prim: prim.GetTypeName() == "Plane" + ) + if mesh_prim is None: + mesh_prim = sim_utils.get_first_matching_child_prim( + mesh_prim_path, lambda prim: prim.GetTypeName() == "Mesh" + ) + if mesh_prim is None or not mesh_prim.IsValid(): + raise RuntimeError(f"Invalid mesh prim path: {mesh_prim_path}") + mesh_prim = UsdGeom.Mesh(mesh_prim) + points = np.asarray(mesh_prim.GetPointsAttr().Get()) + 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) + 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) + logger.info(f"Created infinite plane mesh prim: {mesh_prim.GetPath()}.") + BaseRayCaster.meshes[mesh_key] = wp_mesh + + if all((path, self._device) not in BaseRayCaster.meshes for path in self.cfg.mesh_prim_paths): + raise RuntimeError(f"No meshes found for ray-casting! Please check paths: {self.cfg.mesh_prim_paths}") + + def _initialize_rays_impl(self): + # 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) + ray_directions_torch = math_utils.quat_apply( + offset_quat.repeat(len(ray_directions_torch), 1), ray_directions_torch + ) + ray_starts_torch += offset_pos + + # Repeat for each environment + ray_starts_torch = ray_starts_torch.repeat(self._view_count, 1, 1).contiguous() + ray_directions_torch = ray_directions_torch.repeat(self._view_count, 1, 1).contiguous() + + # Keep public aliases warp-first; kernels use the underlying Warp arrays. + self.ray_starts = ProxyArray(wp.from_torch(ray_starts_torch, dtype=wp.vec3f)) + self.ray_directions = ProxyArray(wp.from_torch(ray_directions_torch, dtype=wp.vec3f)) + + # Drift buffers are warp-first; reset uses explicit .torch views for sampling. + self.drift = ProxyArray(wp.zeros(self._view_count, dtype=wp.vec3f, device=self._device)) + self.ray_cast_drift = ProxyArray(wp.zeros(self._view_count, dtype=wp.vec3f, device=self._device)) + + # World-frame ray buffers + self._ray_starts_w = wp.empty((self._view_count, self.num_rays), dtype=wp.vec3f, device=self._device) + self._ray_directions_w = wp.empty((self._view_count, self.num_rays), dtype=wp.vec3f, device=self._device) + + # 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 frame view as a warp array of ``wp.transformf``. + + Returns: + 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 the backend pose tracker. + """ + pos_w, quat_w = self._view.get_world_poses() + pos_torch = pos_w.torch.reshape(-1, 3) + quat_torch = quat_w.torch.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.""" + transforms = self._get_view_transforms_wp() + + wp.launch( + ray_caster_kernels.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.warp, + self.ray_cast_drift.warp, + self.ray_starts.warp, + self.ray_directions.warp, + 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.""" + self._update_ray_infos(env_mask) + + # Fill ray hits with inf before raycasting + wp.launch( + ray_caster_kernels.fill_vec3_inf_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, wp.inf, self._data._ray_hits_w], + device=self._device, + ) + + # Ray-cast against the mesh + wp.launch( + raycast_mesh_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[ + BaseRayCaster.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 hits + wp.launch( + ray_caster_kernels.apply_z_drift_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, self.ray_cast_drift.warp, self._data._ray_hits_w], + device=self._device, + ) + + def _set_debug_vis_impl(self, debug_vis: bool): + if debug_vis: + if not hasattr(self, "ray_visualizer"): + self.ray_visualizer = VisualizationMarkers(self.cfg.visualizer_cfg) + 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: + return + ray_hits_torch = wp.to_torch(self._data._ray_hits_w) + # remove possible inf values + 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 + if viz_points.shape[0] == 0: + return + + self.ray_visualizer.visualize(viz_points) + + """ + Internal simulation callbacks. + """ + + def _invalidate_initialize_callback(self, event): + """Invalidates the scene elements.""" + super()._invalidate_initialize_callback(event) + self._view = None + + def __del__(self): + BaseRayCaster._instance_count -= 1 + if BaseRayCaster._instance_count == 0: + BaseRayCaster.meshes.clear() diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster_camera.py b/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster_camera.py new file mode 100644 index 000000000000..98a82bbac299 --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/ray_caster/base_ray_caster_camera.py @@ -0,0 +1,587 @@ +# 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 +from collections.abc import Sequence +from typing import TYPE_CHECKING, ClassVar, Literal + +import torch +import warp as wp + +from pxr import UsdGeom + +import isaaclab.utils.math as math_utils +from isaaclab.sensors.camera import CameraData +from isaaclab.utils.warp import ProxyArray +from isaaclab.utils.warp.kernels import raycast_mesh_masked_kernel + +from ..sensor_base import SensorBase +from . import kernels as ray_caster_kernels +from .base_ray_caster import BaseRayCaster + +if TYPE_CHECKING: + from .ray_caster_camera_cfg import RayCasterCameraCfg + +logger = logging.getLogger(__name__) + + +class BaseRayCasterCamera(BaseRayCaster): + """A ray-casting camera sensor. + + The ray-caster camera uses a set of rays to get the distances to meshes in the scene. The rays are + defined in the sensor's local coordinate frame. The sensor has the same interface as the + :class:`isaaclab.sensors.Camera` that implements the camera class through USD camera prims. + However, this class provides a faster image generation. The sensor converts meshes from the list of + primitive paths provided in the configuration to Warp meshes. The camera then ray-casts against these + Warp meshes only. + + Currently, only the following annotators are supported: + + - ``"distance_to_camera"``: An image containing the distance to camera optical center. + - ``"distance_to_image_plane"``: An image containing distances of 3D points from camera plane along camera's z-axis. + - ``"normals"``: An image containing the local surface normal vectors at each pixel. + + .. note:: + Currently, only static meshes are supported. Extending the warp mesh to support dynamic meshes + is a work in progress. + """ + + cfg: RayCasterCameraCfg + """The configuration parameters.""" + UNSUPPORTED_TYPES: ClassVar[set[str]] = { + "rgb", + "instance_id_segmentation", + "instance_id_segmentation_fast", + "instance_segmentation", + "instance_segmentation_fast", + "semantic_segmentation", + "skeleton_data", + "motion_vectors", + "bounding_box_2d_tight", + "bounding_box_2d_tight_fast", + "bounding_box_2d_loose", + "bounding_box_2d_loose_fast", + "bounding_box_3d", + "bounding_box_3d_fast", + } + """A set of sensor types that are not supported by the ray-caster camera.""" + + def __init__(self, cfg: RayCasterCameraCfg): + """Initializes the camera object. + + Args: + cfg: The configuration parameters. + + Raises: + ValueError: If the provided data types are not supported by the ray-caster camera. + """ + # perform check on supported data types + self._check_supported_data_types(cfg) + # initialize base class + super().__init__(cfg) + # create empty variables for storing output data + self._data = CameraData() + + def __str__(self) -> str: + """Returns: A string containing information about the instance.""" + return ( + f"Ray-Caster-Camera @ '{self.cfg.prim_path}': \n" + f"\tview type : {self._view.__class__}\n" + f"\tupdate period (s) : {self.cfg.update_period}\n" + f"\tnumber of meshes : {len(BaseRayCaster.meshes)}\n" + f"\tnumber of sensors : {self._view.count}\n" + f"\tnumber of rays/sensor: {self.num_rays}\n" + f"\ttotal number of rays : {self.num_rays * self._view.count}\n" + f"\timage shape : {self.image_shape}" + ) + + @property + def data(self) -> CameraData: + # update sensors if needed + self._update_outdated_buffers() + # return the data + return self._data + + @property + def image_shape(self) -> tuple[int, int]: + """A tuple containing (height, width) of the camera sensor.""" + return (self.cfg.pattern_cfg.height, self.cfg.pattern_cfg.width) + + @property + def frame(self) -> torch.tensor: + """Frame number when the measurement took place.""" + return self._frame + + def set_intrinsic_matrices( + self, matrices: torch.Tensor, focal_length: float = 1.0, env_ids: Sequence[int] | None = None + ): + """Set the intrinsic matrix of the camera. + + Args: + matrices: The intrinsic matrices for the camera. Shape is (N, 3, 3). + focal_length: Focal length to use when computing aperture values (in cm). Defaults to 1.0. + env_ids: A sensor ids to manipulate. Defaults to None, which means all sensor indices. + """ + # resolve env_ids + if env_ids is None: + env_ids = slice(None) + # save new intrinsic matrices and focal length + self._data.intrinsic_matrices.torch[env_ids] = matrices.to(self._device) + self._focal_length = focal_length + # recompute ray directions + ray_starts_torch = self.ray_starts.torch if hasattr(self.ray_starts, "torch") else self.ray_starts + ray_directions_torch = ( + self.ray_directions.torch if hasattr(self.ray_directions, "torch") else self.ray_directions + ) + ray_starts_torch[env_ids], ray_directions_torch[env_ids] = self.cfg.pattern_cfg.func( + self.cfg.pattern_cfg, self._data.intrinsic_matrices.torch[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 = ray_starts_torch.contiguous() + self._ray_directions_contiguous = ray_directions_torch.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): + env_mask = self._resolve_indices_and_mask(env_ids, env_mask) + # reset the timestamps + SensorBase.reset(self, env_mask=env_mask) + # reset the data through the same Warp path used by updates. + # note: this recomputation is useful if one performs events such as randomizations on the camera poses. + self._update_ray_infos(env_mask) + self._update_frame(env_mask, frame_op=2) + + def set_world_poses( + self, + positions: torch.Tensor | None = None, + orientations: torch.Tensor | None = None, + env_ids: Sequence[int] | None = None, + convention: Literal["opengl", "ros", "world"] = "ros", + ): + """Set the pose of the camera w.r.t. the world frame using specified convention. + + Since different fields use different conventions for camera orientations, the method allows users to + set the camera poses in the specified convention. Possible conventions are: + + - :obj:`"opengl"` - forward axis: -Z - up axis +Y - Offset is applied in the OpenGL (Usd.Camera) convention + - :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.math.convert_camera_frame_orientation_convention` for more details + on the conventions. + + Args: + positions: The cartesian coordinates (in meters). Shape is (N, 3). + Defaults to None, in which case the camera position in not changed. + orientations: The quaternion orientation in (x, y, z, w). Shape is (N, 4). + Defaults to None, in which case the camera orientation in not changed. + env_ids: A sensor ids to manipulate. Defaults to None, which means all sensor indices. + convention: The convention in which the poses are fed. Defaults to "ros". + + Raises: + RuntimeError: If the camera prim is not set. Need to call :meth:`initialize` method first. + """ + # resolve env_ids for compact source arrays and env mask for output refresh + if env_ids is None or isinstance(env_ids, slice): + env_ids_wp = self._empty_env_ids_wp + env_mask = self._ALL_ENV_MASK + count = self._num_envs + use_env_ids = False + else: + self._target_env_ids_torch = torch.as_tensor(env_ids, dtype=torch.int32, device=self._device).reshape(-1) + env_ids_wp = wp.from_torch(self._target_env_ids_torch, dtype=wp.int32) + self._reset_mask.zero_() + self._reset_mask_torch[self._target_env_ids_torch.to(dtype=torch.long)] = True + env_mask = self._reset_mask + count = self._target_env_ids_torch.numel() + use_env_ids = True + + target_positions_wp = self._offset_pos_wp + if positions is not None: + positions = torch.as_tensor(positions, dtype=torch.float32, device=self._device).reshape(-1, 3) + if positions.shape[0] == 1 and count != 1: + positions = positions.expand(count, -1) + elif positions.shape[0] != count: + raise ValueError(f"Expected {count} camera positions, got {positions.shape[0]}.") + self._target_positions_torch = positions.contiguous() + target_positions_wp = wp.from_torch(self._target_positions_torch, dtype=wp.vec3f) + + target_quats_wp = self._offset_quat_wp + if orientations is not None: + # convert rotation matrix from input convention to world + quat_w_set = math_utils.convert_camera_frame_orientation_convention( + torch.as_tensor(orientations, dtype=torch.float32, device=self._device), + origin=convention, + target="world", + ) + quat_w_set = quat_w_set.reshape(-1, 4) + if quat_w_set.shape[0] == 1 and count != 1: + quat_w_set = quat_w_set.expand(count, -1) + elif quat_w_set.shape[0] != count: + raise ValueError(f"Expected {count} camera orientations, got {quat_w_set.shape[0]}.") + self._target_quats_torch = quat_w_set.contiguous() + target_quats_wp = wp.from_torch(self._target_quats_torch, dtype=wp.quatf) + + wp.launch( + ray_caster_kernels.update_camera_offsets_kernel, + dim=count, + inputs=[ + self._get_view_transforms_wp(), + env_ids_wp, + target_positions_wp, + target_quats_wp, + use_env_ids, + positions is not None, + orientations is not None, + self._offset_pos_wp, + self._offset_quat_wp, + ], + device=self._device, + ) + + # update the data through the same Warp path used by normal sensor updates + self._update_ray_infos(env_mask) + + def set_world_poses_from_view( + self, eyes: torch.Tensor, targets: torch.Tensor, env_ids: Sequence[int] | None = None + ): + """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). + 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. + + Raises: + RuntimeError: If the camera prim is not set. Need to call :meth:`initialize` method first. + NotImplementedError: If the stage up-axis is not "Y" or "Z". + """ + # get up axis of current stage + up_axis = UsdGeom.GetStageUpAxis(self.stage) + # camera position and rotation in opengl convention + orientations = math_utils.quat_from_matrix( + math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis=up_axis, device=self._device) + ) + self.set_world_poses(eyes, orientations, env_ids, convention="opengl") + + def _create_offset_buffers(self, count: int): + """Create Warp-owned camera offset and drift buffers.""" + 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" + ) + offset_pos = [tuple(float(v) for v in self.cfg.offset.pos)] * count + offset_quat = [tuple(float(v) for v in quat_w.squeeze(0).tolist())] * count + self._offset_pos_wp = wp.array(offset_pos, dtype=wp.vec3f, device=self._device) + self._offset_quat_wp = wp.array(offset_quat, dtype=wp.quatf, device=self._device) + self.drift = ProxyArray(wp.zeros(count, dtype=wp.vec3f, device=self._device)) + self.ray_cast_drift = ProxyArray(wp.zeros(count, dtype=wp.vec3f, device=self._device)) + self._empty_env_ids_wp = wp.empty(0, dtype=wp.int32, device=self._device) + + def _initialize_rays_impl(self): + # Frame count is updated by Warp kernels; expose a torch view for the public API. + self._frame_wp = wp.zeros(self._view.count, dtype=wp.int64, device=self._device) + self._frame = wp.to_torch(self._frame_wp) + # create buffers + self._create_buffers() + # compute intrinsic matrices + self._compute_intrinsic_matrices() + # compute ray starts and directions + self.ray_starts, self.ray_directions = self.cfg.pattern_cfg.func( + self.cfg.pattern_cfg, self._data.intrinsic_matrices.torch, self._device + ) + self.num_rays = self.ray_directions.shape[1] + + # Offset/drift buffers are warp-primary so kernels always see current values without re-wrapping. + self._create_offset_buffers(self._view.count) + + # Warp buffers for world-frame rays (used by update kernel) + self._ray_starts_w = wp.empty((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + self._ray_directions_w = wp.empty((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) + + # Intermediate warp buffers for ray results (filled with inf before each raycasting step) + self._ray_distance_wp = wp.empty((self._view.count, self.num_rays), dtype=wp.float32, device=self._device) + if "normals" in self.cfg.data_types: + self._ray_normal_w = wp.empty((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + else: + self._ray_normal_w = wp.empty((1, 1), dtype=wp.vec3f, device=self._device) + + # Ray hit buffer used by raycasting and debug visualization. + self.ray_hits_w = ProxyArray(wp.empty((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device)) + + def _update_ray_infos(self, env_mask: wp.array): + """Updates camera poses and world-frame ray buffers via a single warp kernel.""" + transforms = self._get_view_transforms_wp() + wp.launch( + ray_caster_kernels.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.warp, + self.ray_cast_drift.warp, + self._ray_starts_local, + self._ray_directions_local, + int(ray_caster_kernels.ALIGNMENT_BASE), + ], + outputs=[ + self._data.pos_w.warp, + self._data.quat_w_world.warp, + self._ray_starts_w, + self._ray_directions_w, + ], + device=self._device, + ) + + def _update_frame(self, env_mask: wp.array, frame_op: int): + """Update frame counters for masked environments.""" + wp.launch( + ray_caster_kernels.update_frame_masked_kernel, + dim=self._num_envs, + inputs=[env_mask, frame_op, self._frame_wp], + device=self._device, + ) + + def _update_buffers_impl(self, env_mask: wp.array): + """Fills the buffers of the sensor data.""" + # increment frame count + self._update_frame(env_mask, frame_op=1) + + self._update_ray_infos(env_mask) + + # Determine whether to compute normals. + need_normal = int("normals" in self.cfg.data_types) + + # Fill ray hit, distance, and optional normal buffers with inf before raycasting. + wp.launch( + ray_caster_kernels.fill_ray_hits_distance_inf_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, bool(need_normal)], + outputs=[self.ray_hits_w.warp, self._ray_distance_wp, 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=[ + BaseRayCaster.meshes[(self.cfg.mesh_prim_paths[0], self._device)].id, + env_mask, + self._ray_starts_w, + self._ray_directions_w, + float(ray_caster_kernels.CAMERA_RAYCAST_MAX_DIST), + int(True), # return_distance: always needed for depth output + need_normal, + self.ray_hits_w.warp, + self._ray_distance_wp, + self._ray_normal_w, + ], + device=self._device, + ) + + if "distance_to_image_plane" in self.cfg.data_types: + wp.launch( + ray_caster_kernels.compute_distance_to_image_plane_to_image_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[ + env_mask, + self._data.quat_w_world.warp, + self._ray_distance_wp, + self._ray_directions_w, + int(self.image_shape[1]), + bool(self._depth_clip_enabled), + float(self.cfg.max_distance), + self._depth_clip_fill_value, + ], + outputs=[ + self._data.output["distance_to_image_plane"].warp, + ], + device=self._device, + ) + + if "distance_to_camera" in self.cfg.data_types: + wp.launch( + ray_caster_kernels.copy_float2d_to_image1_depth_clipped_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[ + env_mask, + self._ray_distance_wp, + int(self.image_shape[1]), + bool(self._depth_clip_enabled), + float(self.cfg.max_distance), + self._depth_clip_fill_value, + ], + outputs=[ + self._data.output["distance_to_camera"].warp, + ], + device=self._device, + ) + + if "normals" in self.cfg.data_types: + wp.launch( + ray_caster_kernels.copy_vec3_2d_to_image3_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, self._ray_normal_w, int(self.image_shape[1]), self._data.output["normals"].warp], + device=self._device, + ) + + def _debug_vis_callback(self, event): + # Debug visualization can be toggled before ray buffers are initialized. + if not hasattr(self, "ray_hits_w"): + return + # filter out missed rays (inf values) before visualizing + ray_hits_flat = self.ray_hits_w.torch.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) + + 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 + # reason: we cannot obtain this data from simplified warp-based ray caster + common_elements = set(cfg.data_types) & BaseRayCasterCamera.UNSUPPORTED_TYPES + if common_elements: + raise ValueError( + f"RayCasterCamera class does not support the following sensor types: {common_elements}." + "\n\tThis is because these sensor types cannot be obtained in a fast way using ''warp''." + "\n\tHint: If you need to work with these sensor types, we recommend using the USD camera" + " interface from the isaaclab.sensors.camera module." + ) + + def _create_buffers(self): + """Create buffers for storing data.""" + self._depth_clip_enabled = True + if self.cfg.depth_clipping_behavior == "none": + self._depth_clip_enabled = False + self._depth_clip_fill_value = 0.0 + elif self.cfg.depth_clipping_behavior == "max": + self._depth_clip_fill_value = float(self.cfg.max_distance) + elif self.cfg.depth_clipping_behavior == "zero": + self._depth_clip_fill_value = 0.0 + else: + raise ValueError( + f"Unknown depth_clipping_behavior: {self.cfg.depth_clipping_behavior!r}." + " Valid values are 'max', 'zero', and 'none'." + ) + # create the data object + # -- pose of the cameras + self._data.create_buffers(self._view.count, self._device) + # -- intrinsic matrix + self._data.intrinsic_matrices.torch[:, 2, 2] = 1.0 + self._data.image_shape = self.image_shape + # -- output data + # create the buffers to store the annotator data. + self._data._output = {} + self._data.info = {name: None for name in self.cfg.data_types} + for name in self.cfg.data_types: + if name in ["distance_to_image_plane", "distance_to_camera"]: + shape = (self.cfg.pattern_cfg.height, self.cfg.pattern_cfg.width, 1) + elif name in ["normals"]: + shape = (self.cfg.pattern_cfg.height, self.cfg.pattern_cfg.width, 3) + else: + raise ValueError(f"Received unknown data type: {name}. Please check the configuration.") + # allocate tensor to store the data + self._data.output[name] = ProxyArray( + wp.zeros((self._view.count, *shape), dtype=wp.float32, device=self._device) + ) + + def _compute_intrinsic_matrices(self): + """Computes the intrinsic matrices for the camera based on the config provided.""" + # get the sensor properties + pattern_cfg = self.cfg.pattern_cfg + + # check if vertical aperture is provided + # if not then it is auto-computed based on the aspect ratio to preserve squared pixels + if pattern_cfg.vertical_aperture is None: + pattern_cfg.vertical_aperture = pattern_cfg.horizontal_aperture * pattern_cfg.height / pattern_cfg.width + + # compute the intrinsic matrix + f_x = pattern_cfg.width * pattern_cfg.focal_length / pattern_cfg.horizontal_aperture + f_y = pattern_cfg.height * pattern_cfg.focal_length / pattern_cfg.vertical_aperture + c_x = pattern_cfg.horizontal_aperture_offset * f_x + pattern_cfg.width / 2 + c_y = pattern_cfg.vertical_aperture_offset * f_y + pattern_cfg.height / 2 + # allocate the intrinsic matrices + self._data.intrinsic_matrices.torch[:, 0, 0] = f_x + self._data.intrinsic_matrices.torch[:, 0, 2] = c_x + self._data.intrinsic_matrices.torch[:, 1, 1] = f_y + self._data.intrinsic_matrices.torch[:, 1, 2] = c_y + + # save focal length + self._focal_length = pattern_cfg.focal_length + + def _compute_view_world_poses(self, env_ids: Sequence[int]) -> tuple[torch.Tensor, torch.Tensor]: + """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. Call + ``self._view.get_world_poses(indices)`` directly instead. The returned + ProxyArray pair exposes ``.warp`` and ``.torch`` accessors. + + Returns: + A tuple of the position (in meters) and quaternion (x, y, z, w). + + + """ + logger.warning( + "The function '_compute_view_world_poses' is deprecated." + " Call 'self._view.get_world_poses(indices)' directly instead." + ) + + indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) if env_ids is not None else None + pos_w, quat_w = self._view.get_world_poses(indices) + return pos_w.torch.clone(), quat_w.torch.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. + + This function applies the offset pose to the pose of the view the camera is attached to. + + .. deprecated v2.3.1: + This function will be removed in a future release. Instead, use the code block below: + + .. code-block:: python + + indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) + pos_w, quat_w = self._view.get_world_poses(indices) + # The returned ProxyArray pair exposes .warp and .torch accessors + pos_w, quat_w = pos_w.torch.clone(), quat_w.torch.clone() + pos_w, quat_w = math_utils.combine_frame_transforms( + pos_w, quat_w, self._offset_pos[env_ids], self._offset_quat[env_ids] + ) + + Returns: + A tuple of the position (in meters) and quaternion (x, y, z, w) in "world" convention. + """ + logger.warning( + "The function '_compute_camera_world_poses' is deprecated." + " Call 'self._view.get_world_poses(indices)' and 'math_utils.combine_frame_transforms' directly instead." + ) + + indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) if env_ids is not None else None + pos_w, quat_w = self._view.get_world_poses(indices) + offset_pos = wp.to_torch(self._offset_pos_wp) + offset_quat = wp.to_torch(self._offset_quat_wp) + return math_utils.combine_frame_transforms( + pos_w.torch.clone(), quat_w.torch.clone(), offset_pos[env_ids], offset_quat[env_ids] + ) diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/kernels.py b/source/isaaclab/isaaclab/sensors/ray_caster/kernels.py index 98c54ea7141d..4bcd10aba339 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/kernels.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/kernels.py @@ -171,91 +171,226 @@ def apply_z_drift_kernel( @wp.kernel(enable_backward=False) -def fill_float2d_masked_kernel( +def fill_ray_hits_distance_inf_kernel( # input env_mask: wp.array(dtype=wp.bool), - val: wp.float32, + fill_normals: bool, # output - data: wp.array2d(dtype=wp.float32), + ray_hits: wp.array2d(dtype=wp.vec3f), + ray_distance: wp.array2d(dtype=wp.float32), + ray_normals: wp.array2d(dtype=wp.vec3f), ): - """Fill a 2D float32 array with a given value for masked environments. + """Fill ray hit, distance, and optionally normal buffers with infinity 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). + fill_normals: Whether to fill ``ray_normals``. + ray_hits: Ray hit positions to fill with ``wp.inf``. Shape is (num_envs, num_rays). + ray_distance: Ray distances to fill with ``wp.inf``. Shape is (num_envs, num_rays). + ray_normals: Ray normals to fill with ``wp.inf`` when requested. Shape is (num_envs, num_rays). """ env, ray = wp.tid() if not env_mask[env]: return - data[env, ray] = val + inf_vec = wp.vec3f(wp.inf, wp.inf, wp.inf) + ray_hits[env, ray] = inf_vec + ray_distance[env, ray] = wp.inf + if fill_normals: + ray_normals[env, ray] = inf_vec @wp.kernel(enable_backward=False) -def compute_distance_to_image_plane_masked_kernel( +def update_frame_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), + frame_op: int, # output - distance_to_image_plane: wp.array2d(dtype=wp.float32), + frame: wp.array(dtype=wp.int64), ): - """Compute distance-to-image-plane from ray depth and direction for masked environments. + """Update frame counters 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. + ``frame_op`` uses 1 for increment and 2 for reset. + """ + env = wp.tid() + if not env_mask[env]: + return + if frame_op == 1: + frame[env] = frame[env] + wp.int64(1) + elif frame_op == 2: + frame[env] = wp.int64(0) - 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). +@wp.kernel(enable_backward=False) +def update_camera_offsets_kernel( + # input + transforms: wp.array(dtype=wp.transformf), + env_ids: wp.array(dtype=wp.int32), + target_positions: wp.array(dtype=wp.vec3f), + target_quats: wp.array(dtype=wp.quatf), + use_env_ids: bool, + update_position: bool, + update_orientation: bool, + # output + offset_pos: wp.array(dtype=wp.vec3f), + offset_quat: wp.array(dtype=wp.quatf), +): + """Update camera-frame offsets from target world poses. + + Launch with ``dim=count`` where ``count`` is either the number of selected + environments or all environments. ``target_positions`` and ``target_quats`` + are compact arrays indexed by the launch id. """ + src_id = wp.tid() + env_id = src_id + if use_env_ids: + env_id = env_ids[src_id] + + view_transform = transforms[env_id] + view_pos = wp.transform_get_translation(view_transform) + view_quat = wp.transform_get_rotation(view_transform) + + if update_position: + offset_pos[env_id] = wp.quat_rotate_inv(view_quat, target_positions[src_id] - view_pos) + if update_orientation: + offset_quat[env_id] = wp.quat_inverse(view_quat) * target_quats[src_id] + + +@wp.kernel(enable_backward=False) +def copy_float2d_to_image1_depth_clipped_masked_kernel( + # input + env_mask: wp.array(dtype=wp.bool), + src: wp.array2d(dtype=wp.float32), + width: int, + clip_depth: bool, + max_dist: wp.float32, + fill_val: wp.float32, + # output + dst: wp.array4d(dtype=wp.float32), +): + """Copy a flat float buffer to ``(N, H, W, 1)`` camera output with optional depth clipping.""" env, ray = wp.tid() if not env_mask[env]: return + value = src[env, ray] + if clip_depth and (value > max_dist or wp.isnan(value)): + value = fill_val + row = ray // width + col = ray - row * width + dst[env, row, col, 0] = value - 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 copy_vec3_2d_to_image3_masked_kernel( + # input + env_mask: wp.array(dtype=wp.bool), + src: wp.array2d(dtype=wp.vec3f), + width: int, + # output + dst: wp.array4d(dtype=wp.float32), +): + """Copy a flat per-ray vec3 buffer to ``(N, H, W, 3)`` camera output.""" + env, ray = wp.tid() + if not env_mask[env]: + return + row = ray // width + col = ray - row * width + value = src[env, ray] + dst[env, row, col, 0] = value[0] + dst[env, row, col, 1] = value[1] + dst[env, row, col, 2] = value[2] @wp.kernel(enable_backward=False) -def apply_depth_clipping_masked_kernel( +def copy_int16_2d_to_image1_masked_kernel( # input env_mask: wp.array(dtype=wp.bool), - max_dist: wp.float32, - fill_val: wp.float32, + src: wp.array2d(dtype=wp.int16), + width: int, # output - depth: wp.array2d(dtype=wp.float32), + dst: wp.array4d(dtype=wp.int16), ): - """Clip depth values in-place, replacing values above max_dist or NaN with fill_val. + """Copy a flat per-ray int16 buffer to ``(N, H, W, 1)`` camera output.""" + env, ray = wp.tid() + if not env_mask[env]: + return + row = ray // width + col = ray - row * width + dst[env, row, col, 0] = src[env, ray] - 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). - """ +@wp.kernel(enable_backward=False) +def copy_mesh_poses_to_table_kernel( + # input + positions_src: wp.array(dtype=wp.vec3f), + orientations_src: wp.array(dtype=wp.quatf), + meshes_per_env: int, + mesh_offset: int, + broadcast_single_source: bool, + # output + positions_dst: wp.array2d(dtype=wp.vec3f), + orientations_dst: wp.array2d(dtype=wp.quatf), +): + """Copy flat tracked-mesh poses into the rectangular per-env mesh table.""" + env, local_mesh = wp.tid() + src_index = local_mesh + if not broadcast_single_source: + src_index = env * meshes_per_env + local_mesh + dst_index = mesh_offset + local_mesh + positions_dst[env, dst_index] = positions_src[src_index] + orientations_dst[env, dst_index] = orientations_src[src_index] + + +@wp.kernel(enable_backward=False) +def copy_mesh_transforms_to_table_kernel( + # input + transforms_src: wp.array(dtype=wp.transformf), + meshes_per_env: int, + mesh_offset: int, + broadcast_single_source: bool, + # output + positions_dst: wp.array2d(dtype=wp.vec3f), + orientations_dst: wp.array2d(dtype=wp.quatf), +): + """Copy flat tracked-mesh transforms into the rectangular per-env mesh table.""" + env, local_mesh = wp.tid() + src_index = local_mesh + if not broadcast_single_source: + src_index = env * meshes_per_env + local_mesh + dst_index = mesh_offset + local_mesh + xform = transforms_src[src_index] + positions_dst[env, dst_index] = wp.transform_get_translation(xform) + orientations_dst[env, dst_index] = wp.transform_get_rotation(xform) + + +@wp.kernel(enable_backward=False) +def compute_distance_to_image_plane_to_image_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), + width: int, + clip_depth: bool, + max_dist: wp.float32, + fill_val: wp.float32, + # output + dst: wp.array4d(dtype=wp.float32), +): + """Compute distance-to-image-plane, optionally clip it, and write camera output.""" 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 + + depth = ray_distance[env, ray] + dir_w = ray_directions_w[env, ray] + disp_w = wp.vec3f(depth * dir_w[0], depth * dir_w[1], depth * dir_w[2]) + disp_cam = wp.quat_rotate_inv(quat_w[env], disp_w) + value = disp_cam[0] + if clip_depth and (value > max_dist or wp.isnan(value)): + value = fill_val + + row = ray // width + col = ray - row * width + dst[env, row, col, 0] = value 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 f9d0493a0f61..8e61c9ffb639 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 @@ -5,441 +5,12 @@ from __future__ import annotations -import logging -import re -from typing import TYPE_CHECKING, ClassVar +from isaaclab.utils.backend_utils import FactoryBase -import numpy as np -import torch -import trimesh -import warp as wp +from .base_multi_mesh_ray_caster import BaseMultiMeshRayCaster -import isaaclab.sim as sim_utils -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_caster import RayCaster +class MultiMeshRayCaster(FactoryBase, BaseMultiMeshRayCaster): + """Backend-dispatching multi-mesh ray-caster sensor.""" -if TYPE_CHECKING: - from .multi_mesh_ray_caster_cfg import MultiMeshRayCasterCfg - -logger = logging.getLogger(__name__) - - -class MultiMeshRayCaster(RayCaster): - """A multi-mesh ray-casting sensor. - - The ray-caster uses a set of rays to detect collisions with meshes in the scene. The rays are - defined in the sensor's local coordinate frame. The sensor can be configured to ray-cast against - 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` 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: - - - Raycasting against multiple target types : Supports primitive shapes (spheres, cubes, etc.) as well as arbitrary - meshes. - - Dynamic mesh tracking : Keeps track of specified meshes, enabling raycasting against moving parts - (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 - - ray_caster_cfg = MultiMeshRayCasterCfg( - prim_path="{ENV_REGEX_NS}/Robot", - mesh_prim_paths=[ - "/World/Ground", - MultiMeshRayCasterCfg.RaycastTargetCfg(prim_expr="{ENV_REGEX_NS}/Robot/LF_.*/visuals"), - MultiMeshRayCasterCfg.RaycastTargetCfg(prim_expr="{ENV_REGEX_NS}/Robot/RF_.*/visuals"), - MultiMeshRayCasterCfg.RaycastTargetCfg(prim_expr="{ENV_REGEX_NS}/Robot/LH_.*/visuals"), - MultiMeshRayCasterCfg.RaycastTargetCfg(prim_expr="{ENV_REGEX_NS}/Robot/RH_.*/visuals"), - MultiMeshRayCasterCfg.RaycastTargetCfg(prim_expr="{ENV_REGEX_NS}/Robot/base/visuals"), - ], - ray_alignment="world", - pattern_cfg=patterns.GridPatternCfg(resolution=0.02, size=(2.5, 2.5), direction=(0, 0, -1)), - ) - - """ - - cfg: MultiMeshRayCasterCfg - """The configuration parameters.""" - - 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. - """ - - def __init__(self, cfg: MultiMeshRayCasterCfg): - """Initializes the ray-caster object. - - Args: - cfg: The configuration parameters. - """ - super().__init__(cfg) - - self._num_meshes_per_env: dict[str, int] = {} - - self._raycast_targets_cfg: list[MultiMeshRayCasterCfg.RaycastTargetCfg] = [] - for target in self.cfg.mesh_prim_paths: - 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) - - for cfg in self._raycast_targets_cfg: - cfg.prim_expr = cfg.prim_expr.format(ENV_REGEX_NS="/World/envs/env_.*") - - 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" - f"\tupdate period (s) : {self.cfg.update_period}\n" - f"\tnumber of meshes : {self._num_envs} x {sum(self._num_meshes_per_env.values())} \n" - f"\tnumber of sensors : {self._view.count}\n" - f"\tnumber of rays/sensor: {self.num_rays}\n" - f"\ttotal number of rays : {self.num_rays * self._view.count}" - ) - - """ - Properties - """ - - @property - def data(self) -> MultiMeshRayCasterData: - self._update_outdated_buffers() - return self._data - - """ - Implementation. - """ - - def _initialize_warp_meshes(self): - """Parse mesh prim expressions, build (or reuse) Warp meshes, and cache per-env mesh IDs. - - High-level steps (per target expression): - - 1. Resolve matching prims by regex/path expression. - 2. Collect supported mesh child prims; merge into a single mesh if configured. - 3. Deduplicate identical vertex buffers (exact match) to avoid uploading duplicates to Warp. - 4. Partition mesh IDs per environment or mark as globally shared. - 5. Optionally create physics views (articulation / rigid body / fallback XForm) and cache local offsets. - - Exceptions: - Raises a RuntimeError if: - - - No prims match the provided expression. - - No supported mesh prims are found under a matched prim. - - Multiple mesh prims are found but merging is disabled. - - """ - multi_mesh_ids: dict[str, list[list[int]]] = {} - for target_cfg in self._raycast_targets_cfg: - target_prim_path = target_cfg.prim_expr - # 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" - " in `mesh_prim_paths`? This mesh will be skipped." - ) - continue - - 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}") - - is_global_prim = len(target_prims) == 1 - - loaded_vertices: list[np.ndarray | None] = [] - wp_mesh_ids = [] - - for target_prim in target_prims: - 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())) - 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 - - mesh_prims = sim_utils.get_all_matching_child_prims( - target_prim.GetPath(), lambda prim: prim.GetTypeName() in PRIMITIVE_MESH_TYPES + ["Mesh"] - ) - if len(mesh_prims) == 0: - warn_msg = ( - f"No mesh prims found at path: {target_prim.GetPath()} with supported types:" - f" {PRIMITIVE_MESH_TYPES + ['Mesh']}" - " Skipping this target." - ) - for prim in sim_utils.get_all_matching_child_prims(target_prim.GetPath(), lambda prim: True): - warn_msg += f"\n - Available prim '{prim.GetPath()}' of type '{prim.GetTypeName()}'" - logger.warning(warn_msg) - continue - - trimesh_meshes = [] - - for mesh_prim in mesh_prims: - if mesh_prim is None or not mesh_prim.IsValid(): - raise RuntimeError(f"Invalid mesh prim path: {target_prim}") - - if mesh_prim.GetTypeName() == "Mesh": - mesh = create_trimesh_from_geom_mesh(mesh_prim) - else: - mesh = create_trimesh_from_geom_shape(mesh_prim) - scale = sim_utils.resolve_prim_scale(mesh_prim) - mesh.apply_scale(scale) - - relative_pos, relative_quat = sim_utils.resolve_prim_pose(mesh_prim, target_prim) - relative_pos = torch.tensor(relative_pos, dtype=torch.float32) - relative_quat = torch.tensor(relative_quat, dtype=torch.float32) - - rotation = matrix_from_quat(relative_quat) - transform = np.eye(4) - transform[:3, :3] = rotation.numpy() - transform[:3, 3] = relative_pos.numpy() - mesh.apply_transform(transform) - - trimesh_meshes.append(mesh) - - if len(trimesh_meshes) == 1: - trimesh_mesh = trimesh_meshes[0] - elif target_cfg.merge_prim_meshes: - trimesh_mesh = trimesh.util.concatenate(trimesh_meshes) - else: - raise RuntimeError( - f"Multiple mesh prims found at path: {target_prim.GetPath()} but merging is disabled. Please" - " enable `merge_prim_meshes` in the configuration or specify each mesh separately." - ) - - 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.") - 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()), self._device)] = wp_mesh - wp_mesh_ids.append(wp_mesh.id) - - if registered_idx != -1: - logger.info(f"Found duplicate mesh for mesh prims under path '{target_prim.GetPath()}'.") - else: - logger.info( - f"Read '{len(mesh_prims)}' mesh prims under path '{target_prim.GetPath()}' with" - f" {len(trimesh_mesh.vertices)} vertices and {len(trimesh_mesh.faces)} faces." - ) - - if is_global_prim: - 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: - multi_mesh_ids[target_prim_path] = [] - mesh_idx = 0 - n_meshes_per_env = len(wp_mesh_ids) // self._num_envs - self._num_meshes_per_env[target_prim_path] = n_meshes_per_env - for _ in range(self._num_envs): - multi_mesh_ids[target_prim_path].append(wp_mesh_ids[mesh_idx : mesh_idx + n_meshes_per_env]) - mesh_idx += n_meshes_per_env - - if target_cfg.track_mesh_transforms: - 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]): - 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 = 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) - - mesh_idx = 0 - for target_cfg in self._raycast_targets_cfg: - n_meshes = self._num_meshes_per_env[target_cfg.prim_expr] - - pos_w, ori_w = [], [] - for prim in sim_utils.find_matching_prims(target_cfg.prim_expr): - translation, quat = sim_utils.resolve_prim_pose(prim) - pos_w.append(translation) - ori_w.append(quat) - 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_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 - - multi_mesh_ids_flattened = [] - for env_idx in range(self._num_envs): - meshes_in_env = [] - for target_cfg in self._raycast_targets_cfg: - meshes_in_env.extend(multi_mesh_ids[target_cfg.prim_expr][env_idx]) - multi_mesh_ids_flattened.append(meshes_in_env) - - self._mesh_views = [ - self.mesh_views[target_cfg.prim_expr] if target_cfg.track_mesh_transforms else None - for target_cfg in self._raycast_targets_cfg - ] - - 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._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 = view.get_world_poses(None) - pos_w, ori_w = pos_w.torch, ori_w.torch - 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 - - count = view.count - 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_torch[:, mesh_idx : mesh_idx + count] = pos_w - self._mesh_orientations_w_torch[:, mesh_idx : mesh_idx + count] = ori_w - mesh_idx += count - - 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, - ) - - # 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__() - if RayCaster._instance_count == 0: - MultiMeshRayCaster.mesh_views.clear() - - -""" -Helper functions -""" - - -def _registered_points_idx(points: np.ndarray, registered_points: list[np.ndarray | None]) -> int: - """Check if the points are already registered in the list of registered points. - - Args: - points: The points to check. - registered_points: The list of registered points. - - Returns: - The index of the registered points if found, otherwise -1. - """ - for idx, reg_points in enumerate(registered_points): - if reg_points is None: - continue - if reg_points.shape == points.shape and (reg_points == points).all(): - return idx - return -1 + _backend_class_names = {"physx": "MultiMeshRayCaster", "newton": "MultiMeshRayCaster"} 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 a868d17c7848..e201e61d1f23 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,286 +5,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from isaaclab.utils.backend_utils import FactoryBase -import torch -import warp as wp +from .base_multi_mesh_ray_caster_camera import BaseMultiMeshRayCasterCamera -import isaaclab.utils.math as math_utils -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_caster_camera import RayCasterCamera +class MultiMeshRayCasterCamera(FactoryBase, BaseMultiMeshRayCasterCamera): + """Backend-dispatching multi-mesh ray-caster camera sensor.""" -if TYPE_CHECKING: - from .multi_mesh_ray_caster_camera_cfg import MultiMeshRayCasterCameraCfg - - -class MultiMeshRayCasterCamera(RayCasterCamera, MultiMeshRayCaster): - """A multi-mesh ray-casting camera sensor. - - The ray-caster camera uses a set of rays to get the distances to meshes in the scene. The rays are - defined in the sensor's local coordinate frame. The sensor has the same interface as the - :class:`isaaclab.sensors.Camera` that implements the camera class through USD camera prims. - However, this class provides a faster image generation. The sensor converts meshes from the list of - primitive paths provided in the configuration to Warp meshes. The camera then ray-casts against these - Warp meshes only. - - Currently, only the following annotators are supported: - - - ``"distance_to_camera"``: An image containing the distance to camera optical center. - - ``"distance_to_image_plane"``: An image containing distances of 3D points from camera plane along camera's z-axis. - - ``"normals"``: An image containing the local surface normal vectors at each pixel. - """ - - cfg: MultiMeshRayCasterCameraCfg - """The configuration parameters.""" - - def __init__(self, cfg: MultiMeshRayCasterCameraCfg): - """Initializes the camera object. - - Args: - cfg: The configuration parameters. - - Raises: - ValueError: If the provided data types are not supported by the ray-caster camera. - """ - self._check_supported_data_types(cfg) - # initialize base class - MultiMeshRayCaster.__init__(self, cfg) - # create empty variables for storing output data - self._data = MultiMeshRayCasterCameraData() - - def __str__(self) -> str: - """Returns: A string containing information about the instance.""" - return ( - f"Multi-Mesh Ray-Caster-Camera @ '{self.cfg.prim_path}': \n" - f"\tview type : {self._view.__class__}\n" - f"\tupdate period (s) : {self.cfg.update_period}\n" - f"\tnumber of meshes : {len(MultiMeshRayCaster.meshes)}\n" - f"\tnumber of sensors : {self._view.count}\n" - f"\tnumber of rays/sensor: {self.num_rays}\n" - f"\ttotal number of rays : {self.num_rays * self._view.count}\n" - f"\timage shape : {self.image_shape}" - ) - - """ - Implementation. - """ - - def _initialize_warp_meshes(self): - MultiMeshRayCaster._initialize_warp_meshes(self) - - def _create_buffers(self): - super()._create_buffers() - self._data.image_mesh_ids = torch.zeros( - self._num_envs, *self.image_shape, 1, device=self.device, dtype=torch.int16 - ) - - def _initialize_rays_impl(self): - # 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) - self._frame = torch.zeros(self._view.count, device=self._device, dtype=torch.long) - - # Build camera output buffers (intrinsics, image data, etc.) - self._create_buffers() - self._compute_intrinsic_matrices() - - # 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 = 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_offset.repeat(self._view.count, 1) - self._offset_pos = torch.tensor(list(self.cfg.offset.pos), device=self._device).repeat(self._view.count, 1) - - # 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) - - # 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_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 camera world poses by composing view pose with sensor offset - indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) - pos_w, quat_w = self._view.get_world_poses(indices) - pos_w, quat_w = pos_w.torch, quat_w.torch - pos_w, quat_w = math_utils.combine_frame_transforms( - pos_w, quat_w, self._offset_pos[env_ids], self._offset_quat[env_ids] - ) - # Store camera pose in CameraData (torch tensors) and warp-backed orientation buffer - self._data.pos_w.torch[env_ids] = pos_w - self._data.quat_w_world.torch[env_ids] = quat_w - self._quat_w_wp_torch[env_ids] = quat_w - - # 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) - - 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_mask) - - # Increment frame count for updated environments - self._frame[env_ids] += 1 - - 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, - ) - - if "distance_to_image_plane" in self.cfg.data_types: - 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: - # 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 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] = wp.to_torch(self._ray_mesh_id_w)[env_ids].view( - -1, *self.image_shape, 1 - ) + _backend_class_names = {"physx": "MultiMeshRayCasterCamera", "newton": "MultiMeshRayCasterCamera"} 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 21338f0a0616..687185c3e0bb 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 @@ -5,9 +5,8 @@ """Data container for the multi-mesh ray-cast camera sensor.""" -import torch - from isaaclab.sensors.camera import CameraData +from isaaclab.utils.warp import ProxyArray class MultiMeshRayCasterCameraData(CameraData): @@ -19,7 +18,7 @@ class MultiMeshRayCasterCameraData(CameraData): warp-native :class:`RayCasterData`. """ - image_mesh_ids: torch.Tensor = None + image_mesh_ids: ProxyArray = None """The mesh ids of the image pixels. Shape is (N, H, W, 1), where N is the number of sensors, H and W are the height and width of the image, diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_data.py b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_data.py index b9ae187591be..331dcb4af79f 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_data.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_data.py @@ -6,7 +6,7 @@ """Data container for the multi-mesh ray-cast sensor.""" -import torch +from isaaclab.utils.warp import ProxyArray from .ray_caster_data import RayCasterData @@ -14,7 +14,7 @@ class MultiMeshRayCasterData(RayCasterData): """Data container for the multi-mesh ray-cast sensor.""" - ray_mesh_ids: torch.Tensor = None + ray_mesh_ids: ProxyArray = None """The mesh ids of the ray hits. Shape is (N, B, 1), where N is the number of sensors, B is the number of rays diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py index ab8ca8b7d4ce..4b21117c0c20 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py @@ -5,370 +5,12 @@ from __future__ import annotations -import logging -from collections.abc import Sequence -from typing import TYPE_CHECKING, ClassVar +from isaaclab.utils.backend_utils import FactoryBase -import numpy as np -import torch -import warp as wp +from .base_ray_caster import BaseRayCaster -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 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 +class RayCaster(FactoryBase, BaseRayCaster): + """Backend-dispatching ray-caster sensor.""" -from ..sensor_base import SensorBase -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 - -logger = logging.getLogger(__name__) - - -class RayCaster(SensorBase): - """A ray-casting sensor. - - The ray-caster uses a set of rays to detect collisions with meshes in the scene. The rays are - defined in the sensor's local coordinate frame. The sensor can be configured to ray-cast against - 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` 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 - is a work in progress. - """ - - cfg: RayCasterCfg - """The configuration parameters.""" - - meshes: ClassVar[dict[tuple[str, str], wp.Mesh]] = {} - """A dictionary to store warp meshes for raycasting, shared across all instances. - - 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.""" - - def __init__(self, cfg: RayCasterCfg): - """Initializes the ray-caster object. - - Args: - cfg: The configuration parameters. - """ - 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: - """Returns: A string containing information about the instance.""" - return ( - f"Ray-caster @ '{self.cfg.prim_path}': \n" - f"\tview type : {self._view.__class__}\n" - f"\tupdate period (s) : {self.cfg.update_period}\n" - f"\tnumber of meshes : {len(RayCaster.meshes)}\n" - f"\tnumber of sensors : {self._view.count}\n" - f"\tnumber of rays/sensor: {self.num_rays}\n" - f"\ttotal number of rays : {self.num_rays * self._view.count}" - ) - - """ - Properties - """ - - @property - def num_instances(self) -> int: - return self._view.count - - @property - def data(self) -> RayCasterData: - # update sensors if needed - self._update_outdated_buffers() - # return the data - return self._data - - """ - Operations. - """ - - def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None): - # reset the timers and counters - super().reset(env_ids, env_mask) - # resolve to indices for torch indexing - if env_ids is not None: - num_envs_ids = len(env_ids) - elif env_mask is not None: - env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) - num_envs_ids = len(env_ids) - else: - env_ids = slice(None) - num_envs_ids = self._view.count - # 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 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( - ranges[:, 0], ranges[:, 1], (num_envs_ids, 3), device=self.device - ) - - """ - Implementation. - """ - - def _initialize_impl(self): - super()._initialize_impl() - # 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) - - # 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() - self._initialize_rays_impl() - - def _initialize_warp_meshes(self): - # check number of mesh prims provided - if len(self.cfg.mesh_prim_paths) != 1: - raise NotImplementedError( - f"RayCaster currently only supports one mesh prim. Received: {len(self.cfg.mesh_prim_paths)}" - ) - - # read prims to ray-cast - for mesh_prim_path in self.cfg.mesh_prim_paths: - mesh_key = (mesh_prim_path, self._device) - if mesh_key in RayCaster.meshes: - continue - - mesh_prim = sim_utils.get_first_matching_child_prim( - mesh_prim_path, lambda prim: prim.GetTypeName() == "Plane" - ) - if mesh_prim is None: - mesh_prim = sim_utils.get_first_matching_child_prim( - mesh_prim_path, lambda prim: prim.GetTypeName() == "Mesh" - ) - if mesh_prim is None or not mesh_prim.IsValid(): - raise RuntimeError(f"Invalid mesh prim path: {mesh_prim_path}") - mesh_prim = UsdGeom.Mesh(mesh_prim) - points = np.asarray(mesh_prim.GetPointsAttr().Get()) - 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) - 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) - logger.info(f"Created infinite plane mesh prim: {mesh_prim.GetPath()}.") - RayCaster.meshes[mesh_key] = wp_mesh - - 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 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) - ray_directions_torch = math_utils.quat_apply( - offset_quat.repeat(len(ray_directions_torch), 1), ray_directions_torch - ) - ray_starts_torch += offset_pos - - # 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) - - # 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 frame view as a warp array of ``wp.transformf``. - - Returns: - 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`. - """ - pos_w, quat_w = self._view.get_world_poses() - pos_torch = pos_w.torch.reshape(-1, 3) - quat_torch = quat_w.torch.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.""" - 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.""" - 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 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 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): - if debug_vis: - if not hasattr(self, "ray_visualizer"): - self.ray_visualizer = VisualizationMarkers(self.cfg.visualizer_cfg) - 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: - return - ray_hits_torch = wp.to_torch(self._data._ray_hits_w) - # remove possible inf values - 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 - if viz_points.shape[0] == 0: - return - - self.ray_visualizer.visualize(viz_points) - - """ - Internal simulation callbacks. - """ - - def _invalidate_initialize_callback(self, event): - """Invalidates the scene elements.""" - super()._invalidate_initialize_callback(event) - self._view = None - - def __del__(self): - RayCaster._instance_count -= 1 - if RayCaster._instance_count == 0: - RayCaster.meshes.clear() + _backend_class_names = {"physx": "RayCaster", "newton": "RayCaster"} 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 650dfee54ac3..13765d23d441 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py @@ -5,647 +5,12 @@ from __future__ import annotations -import logging -from collections.abc import Sequence -from typing import TYPE_CHECKING, ClassVar, Literal +from isaaclab.utils.backend_utils import FactoryBase -import torch -import warp as wp +from .base_ray_caster_camera import BaseRayCasterCamera -from pxr import UsdGeom -import isaaclab.utils.math as math_utils -from isaaclab.sensors.camera import CameraData -from isaaclab.utils.warp import ProxyArray -from isaaclab.utils.warp.kernels import raycast_mesh_masked_kernel +class RayCasterCamera(FactoryBase, BaseRayCasterCamera): + """Backend-dispatching ray-caster camera sensor.""" -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_caster import RayCaster - -if TYPE_CHECKING: - from .ray_caster_camera_cfg import RayCasterCameraCfg - -# import logger -logger = logging.getLogger(__name__) - - -class RayCasterCamera(RayCaster): - """A ray-casting camera sensor. - - The ray-caster camera uses a set of rays to get the distances to meshes in the scene. The rays are - defined in the sensor's local coordinate frame. The sensor has the same interface as the - :class:`isaaclab.sensors.Camera` that implements the camera class through USD camera prims. - However, this class provides a faster image generation. The sensor converts meshes from the list of - primitive paths provided in the configuration to Warp meshes. The camera then ray-casts against these - Warp meshes only. - - Currently, only the following annotators are supported: - - - ``"distance_to_camera"``: An image containing the distance to camera optical center. - - ``"distance_to_image_plane"``: An image containing distances of 3D points from camera plane along camera's z-axis. - - ``"normals"``: An image containing the local surface normal vectors at each pixel. - - .. note:: - Currently, only static meshes are supported. Extending the warp mesh to support dynamic meshes - is a work in progress. - """ - - cfg: RayCasterCameraCfg - """The configuration parameters.""" - UNSUPPORTED_TYPES: ClassVar[set[str]] = { - "rgb", - "instance_id_segmentation", - "instance_id_segmentation_fast", - "instance_segmentation", - "instance_segmentation_fast", - "semantic_segmentation", - "skeleton_data", - "motion_vectors", - "bounding_box_2d_tight", - "bounding_box_2d_tight_fast", - "bounding_box_2d_loose", - "bounding_box_2d_loose_fast", - "bounding_box_3d", - "bounding_box_3d_fast", - } - """A set of sensor types that are not supported by the ray-caster camera.""" - - def __init__(self, cfg: RayCasterCameraCfg): - """Initializes the camera object. - - Args: - cfg: The configuration parameters. - - Raises: - ValueError: If the provided data types are not supported by the ray-caster camera. - """ - # perform check on supported data types - self._check_supported_data_types(cfg) - # initialize base class - super().__init__(cfg) - # create empty variables for storing output data - self._data = CameraData() - - def __str__(self) -> str: - """Returns: A string containing information about the instance.""" - return ( - f"Ray-Caster-Camera @ '{self.cfg.prim_path}': \n" - f"\tview type : {self._view.__class__}\n" - f"\tupdate period (s) : {self.cfg.update_period}\n" - f"\tnumber of meshes : {len(RayCaster.meshes)}\n" - f"\tnumber of sensors : {self._view.count}\n" - f"\tnumber of rays/sensor: {self.num_rays}\n" - f"\ttotal number of rays : {self.num_rays * self._view.count}\n" - f"\timage shape : {self.image_shape}" - ) - - """ - Properties - """ - - @property - def data(self) -> CameraData: - # update sensors if needed - self._update_outdated_buffers() - # return the data - return self._data - - @property - def image_shape(self) -> tuple[int, int]: - """A tuple containing (height, width) of the camera sensor.""" - return (self.cfg.pattern_cfg.height, self.cfg.pattern_cfg.width) - - @property - def frame(self) -> torch.tensor: - """Frame number when the measurement took place.""" - return self._frame - - """ - Operations. - """ - - def set_intrinsic_matrices( - self, matrices: torch.Tensor, focal_length: float = 1.0, env_ids: Sequence[int] | None = None - ): - """Set the intrinsic matrix of the camera. - - Args: - matrices: The intrinsic matrices for the camera. Shape is (N, 3, 3). - focal_length: Focal length to use when computing aperture values (in cm). Defaults to 1.0. - env_ids: A sensor ids to manipulate. Defaults to None, which means all sensor indices. - """ - # resolve env_ids - if env_ids is None: - env_ids = slice(None) - # save new intrinsic matrices and focal length - self._data.intrinsic_matrices[env_ids] = matrices.to(self._device) - self._focal_length = focal_length - # recompute ray directions - 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 - super().reset(env_ids, env_mask) - # resolve to indices for torch indexing - if env_ids is None and env_mask is not 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. - indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) if env_ids is not None else None - pos_w, quat_w = self._view.get_world_poses(indices) - pos_w, quat_w = math_utils.combine_frame_transforms( - pos_w.torch.clone(), quat_w.torch.clone(), self._offset_pos[env_ids], self._offset_quat[env_ids] - ) - self._data.pos_w[env_ids] = pos_w - self._data.quat_w_world[env_ids] = quat_w - # Reset the frame count - self._frame[env_ids] = 0 - - def set_world_poses( - self, - positions: torch.Tensor | None = None, - orientations: torch.Tensor | None = None, - env_ids: Sequence[int] | None = None, - convention: Literal["opengl", "ros", "world"] = "ros", - ): - """Set the pose of the camera w.r.t. the world frame using specified convention. - - Since different fields use different conventions for camera orientations, the method allows users to - set the camera poses in the specified convention. Possible conventions are: - - - :obj:`"opengl"` - forward axis: -Z - up axis +Y - Offset is applied in the OpenGL (Usd.Camera) convention - - :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.math.convert_camera_frame_orientation_convention` for more details - on the conventions. - - Args: - positions: The cartesian coordinates (in meters). Shape is (N, 3). - Defaults to None, in which case the camera position in not changed. - orientations: The quaternion orientation in (x, y, z, w). Shape is (N, 4). - Defaults to None, in which case the camera orientation in not changed. - env_ids: A sensor ids to manipulate. Defaults to None, which means all sensor indices. - convention: The convention in which the poses are fed. Defaults to "ros". - - Raises: - RuntimeError: If the camera prim is not set. Need to call :meth:`initialize` method first. - """ - # resolve env_ids - if env_ids is None or isinstance(env_ids, slice): - env_ids = self._ALL_INDICES - - # get current positions - indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) if env_ids is not None else None - pos_w, quat_w = self._view.get_world_poses(indices) - pos_w_torch = pos_w.torch - quat_w_torch = quat_w.torch - if positions is not None: - # transform to camera frame - pos_offset_world_frame = positions - pos_w_torch - self._offset_pos[env_ids] = math_utils.quat_apply(math_utils.quat_inv(quat_w_torch), pos_offset_world_frame) - if orientations is not None: - # convert rotation matrix from input convention to world - quat_w_set = math_utils.convert_camera_frame_orientation_convention( - orientations, origin=convention, target="world" - ) - self._offset_quat[env_ids] = math_utils.quat_mul(math_utils.quat_inv(quat_w_torch), quat_w_set) - - # update the data - pos_w2, quat_w2 = self._view.get_world_poses(indices) - pos_w_out, quat_w_out = math_utils.combine_frame_transforms( - pos_w2.torch.clone(), quat_w2.torch.clone(), self._offset_pos[env_ids], self._offset_quat[env_ids] - ) - self._data.pos_w[env_ids] = pos_w_out - self._data.quat_w_world[env_ids] = quat_w_out - - def set_world_poses_from_view( - self, eyes: torch.Tensor, targets: torch.Tensor, env_ids: Sequence[int] | None = None - ): - """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). - 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. - - Raises: - RuntimeError: If the camera prim is not set. Need to call :meth:`initialize` method first. - NotImplementedError: If the stage up-axis is not "Y" or "Z". - ValueError: If every eye position equals its target (look-at direction undefined for the - whole batch). When only some rows are degenerate, those rows are skipped and the - remaining poses are still applied; a warning is logged. - """ - # resolve env_ids to a tensor up front so we can index it during partial-failure filtering - if env_ids is None: - env_ids = self._ALL_INDICES - if not isinstance(env_ids, torch.Tensor): - env_ids = torch.tensor(env_ids, dtype=torch.long, device=self._device) - # get up axis of current stage - up_axis = UsdGeom.GetStageUpAxis(self.stage) - # camera position and rotation in opengl convention; degenerate rows (eye == target) come back as NaN - rotation_matrix = math_utils.create_rotation_matrix_from_view( - eyes, targets, up_axis=up_axis, device=self._device - ) - valid_indices = (~torch.isnan(rotation_matrix).any(dim=(-2, -1))).nonzero(as_tuple=True)[0] - n_valid = valid_indices.numel() - n_total = rotation_matrix.shape[0] - if n_valid == 0: - raise ValueError("look-at is undefined: every eye position equals its target") - if n_valid < n_total: - logger.warning( - "set_world_poses_from_view: skipping %d pose(s) where eye equals target", - n_total - n_valid, - ) - rotation_matrix = rotation_matrix.index_select(0, valid_indices) - eyes = eyes.index_select(0, valid_indices) - env_ids = env_ids.index_select(0, valid_indices) - orientations = math_utils.quat_from_matrix(rotation_matrix) - self.set_world_poses(eyes, orientations, env_ids, convention="opengl") - - """ - Implementation. - """ - - def _initialize_rays_impl(self): - # Create all indices buffer - 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 - self._create_buffers() - # compute intrinsic matrices - self._compute_intrinsic_matrices() - # 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] - - # 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_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 - - # 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, - ) - - # 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, - ) - - # 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: - 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: - # 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] = 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 - # 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 - # reason: we cannot obtain this data from simplified warp-based ray caster - common_elements = set(cfg.data_types) & RayCasterCamera.UNSUPPORTED_TYPES - if common_elements: - raise ValueError( - f"RayCasterCamera class does not support the following sensor types: {common_elements}." - "\n\tThis is because these sensor types cannot be obtained in a fast way using ''warp''." - "\n\tHint: If you need to work with these sensor types, we recommend using the USD camera" - " interface from the isaaclab.sensors.camera module." - ) - - def _create_buffers(self): - """Create buffers for storing data.""" - # 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 - # -- pose and intrinsics as warp-backed ProxyArrays - device_str = self._device if isinstance(self._device, str) else str(self._device) - self._data.create_buffers(self._view.count, device_str) - self._data.intrinsic_matrices.torch[:, 2, 2] = 1.0 - self._data.image_shape = self.image_shape - # -- output data as warp-backed ProxyArrays - output = {} - self._data.info = {name: None for name in self.cfg.data_types} - for name in self.cfg.data_types: - if name in ["distance_to_image_plane", "distance_to_camera"]: - shape = (self.cfg.pattern_cfg.height, self.cfg.pattern_cfg.width, 1) - elif name in ["normals"]: - shape = (self.cfg.pattern_cfg.height, self.cfg.pattern_cfg.width, 3) - else: - raise ValueError(f"Received unknown data type: {name}. Please check the configuration.") - wp_arr = wp.zeros((self._view.count, *shape), dtype=wp.float32, device=device_str) - output[name] = ProxyArray(wp_arr) - self._data._output = output - - def _compute_intrinsic_matrices(self): - """Computes the intrinsic matrices for the camera based on the config provided.""" - # get the sensor properties - pattern_cfg = self.cfg.pattern_cfg - - # check if vertical aperture is provided - # if not then it is auto-computed based on the aspect ratio to preserve squared pixels - if pattern_cfg.vertical_aperture is None: - pattern_cfg.vertical_aperture = pattern_cfg.horizontal_aperture * pattern_cfg.height / pattern_cfg.width - - # compute the intrinsic matrix - f_x = pattern_cfg.width * pattern_cfg.focal_length / pattern_cfg.horizontal_aperture - f_y = pattern_cfg.height * pattern_cfg.focal_length / pattern_cfg.vertical_aperture - c_x = pattern_cfg.horizontal_aperture_offset * f_x + pattern_cfg.width / 2 - c_y = pattern_cfg.vertical_aperture_offset * f_y + pattern_cfg.height / 2 - # allocate the intrinsic matrices - self._data.intrinsic_matrices[:, 0, 0] = f_x - self._data.intrinsic_matrices[:, 0, 2] = c_x - self._data.intrinsic_matrices[:, 1, 1] = f_y - self._data.intrinsic_matrices[:, 1, 2] = c_y - - # save focal length - self._focal_length = pattern_cfg.focal_length - - def _compute_view_world_poses(self, env_ids: Sequence[int]) -> tuple[torch.Tensor, torch.Tensor]: - """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. Call - ``self._view.get_world_poses(indices)`` directly instead. The returned - ProxyArray pair exposes ``.warp`` and ``.torch`` accessors. - - Returns: - A tuple of the position (in meters) and quaternion (x, y, z, w). - - - """ - logger.warning( - "The function '_compute_view_world_poses' is deprecated." - " Call 'self._view.get_world_poses(indices)' directly instead." - ) - - indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) if env_ids is not None else None - pos_w, quat_w = self._view.get_world_poses(indices) - return pos_w.torch.clone(), quat_w.torch.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. - - This function applies the offset pose to the pose of the view the camera is attached to. - - .. deprecated v2.3.1: - This function will be removed in a future release. Instead, use the code block below: - - .. code-block:: python - - indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) - pos_w, quat_w = self._view.get_world_poses(indices) - # The returned ProxyArray pair exposes .warp and .torch accessors - pos_w, quat_w = pos_w.torch.clone(), quat_w.torch.clone() - pos_w, quat_w = math_utils.combine_frame_transforms( - pos_w, quat_w, self._offset_pos[env_ids], self._offset_quat[env_ids] - ) - - Returns: - A tuple of the position (in meters) and quaternion (x, y, z, w) in "world" convention. - """ - logger.warning( - "The function '_compute_camera_world_poses' is deprecated." - " Call 'self._view.get_world_poses(indices)' and 'math_utils.combine_frame_transforms' directly instead." - ) - - indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) if env_ids is not None else None - pos_w, quat_w = self._view.get_world_poses(indices) - return math_utils.combine_frame_transforms( - pos_w.torch.clone(), quat_w.torch.clone(), self._offset_pos[env_ids], self._offset_quat[env_ids] - ) + _backend_class_names = {"physx": "RayCasterCamera", "newton": "RayCasterCamera"} 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 eed776431279..5823e6c79f82 100644 --- a/source/isaaclab/test/sensors/check_multi_mesh_ray_caster.py +++ b/source/isaaclab/test/sensors/check_multi_mesh_ray_caster.py @@ -96,16 +96,36 @@ def design_scene(sim: SimulationContext, num_envs: int = 2048): f"/World/envs/env_0/object_{i}", object, translation=(0.0 + random.random(), 0.0 + random.random(), 1.0), - orientation=quat_from_euler_xyz(torch.Tensor(0), torch.Tensor(0), torch.rand(1) * torch.pi).numpy(), + orientation=quat_from_euler_xyz(torch.zeros(1), torch.zeros(1), torch.rand(1) * torch.pi)[0].numpy(), ) # Clone the scene envs_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)] lab_cloner.usd_replicate(sim.stage, [env_fmt.format(0)], [env_fmt], env_ids, positions=env_origins) - physics_scene_path = sim.get_physics_context().prim_path - lab_cloner.filter_collisions( - sim.stage, physics_scene_path, "/World/collisions", prim_paths=envs_prim_paths, global_paths=["/World/ground"] + # Publish a trivial homogeneous ClonePlan so consumers (e.g. multi-mesh ray-caster's + # target tracker) can drive per-env work via clone_mask. Mirrors InteractiveScene's + # synthesis path for hand-authored scenes that bypass it. + sim.set_clone_plan( + lab_cloner.ClonePlan( + sources=(env_fmt.format(0),), + destinations=(env_fmt,), + clone_mask=torch.ones((1, num_envs), dtype=torch.bool, device=sim.device), + ) + ) + # PhysX-only optimization: filter collisions across env clones. Skip on Newton — + # PhysxSceneAPI isn't applied there and the cloner helper is PhysX-specific. + physics_scene_path = next( + (prim.GetPrimPath().pathString for prim in sim.stage.Traverse() if "PhysxSceneAPI" in prim.GetAppliedSchemas()), + None, ) + if physics_scene_path is not None: + lab_cloner.filter_collisions( + sim.stage, + physics_scene_path, + "/World/collisions", + prim_paths=envs_prim_paths, + global_paths=["/World/ground"], + ) def main(): @@ -127,6 +147,7 @@ def main(): usd_path=f"{ISAAC_NUCLEUS_DIR}/Environments/Terrains/rough_plane.usd", max_init_terrain_level=0, num_envs=1, + env_spacing=10.0, ) _ = TerrainImporter(terrain_importer_cfg) diff --git a/source/isaaclab/test/sensors/check_ray_caster.py b/source/isaaclab/test/sensors/check_ray_caster.py index 821fc247743e..87d8a9594ad5 100644 --- a/source/isaaclab/test/sensors/check_ray_caster.py +++ b/source/isaaclab/test/sensors/check_ray_caster.py @@ -78,10 +78,21 @@ def design_scene(sim: SimulationContext, num_envs: int = 2048): # Clone the scene envs_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)] lab_cloner.usd_replicate(sim.stage, [env_fmt.format(0)], [env_fmt], env_ids, positions=env_origins) - physics_scene_path = sim.get_physics_context().prim_path - lab_cloner.filter_collisions( - sim.stage, physics_scene_path, "/World/collisions", prim_paths=envs_prim_paths, global_paths=["/World/ground"] - ) + # PhysX-only optimization: filter collisions across env clones. Skip on Newton — + # PhysxSceneAPI isn't applied there and the cloner helper is PhysX-specific. + physics_scene_path = None + for prim in sim.stage.Traverse(): + if "PhysxSceneAPI" in prim.GetAppliedSchemas(): + physics_scene_path = prim.GetPrimPath().pathString + break + if physics_scene_path is not None: + lab_cloner.filter_collisions( + sim.stage, + physics_scene_path, + "/World/collisions", + prim_paths=envs_prim_paths, + global_paths=["/World/ground"], + ) def main(): @@ -103,6 +114,7 @@ def main(): usd_path=f"{ISAAC_NUCLEUS_DIR}/Environments/Terrains/rough_plane.usd", max_init_terrain_level=None, num_envs=1, + env_spacing=10.0, ) _ = TerrainImporter(terrain_importer_cfg) 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 0319bf80ef1a..28501f1a733f 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 @@ -16,6 +16,7 @@ """Rest everything follows.""" import copy +from collections.abc import Callable import numpy as np import pytest @@ -24,18 +25,26 @@ import omni.replicator.core as rep from pxr import Gf +import isaaclab.cloner as lab_cloner import isaaclab.sim as sim_utils +from isaaclab.cloner import ClonePlan from isaaclab.sensors.camera import Camera, CameraCfg from isaaclab.sensors.ray_caster import MultiMeshRayCasterCamera, MultiMeshRayCasterCameraCfg, patterns from isaaclab.sim import PinholeCameraCfg from isaaclab.terrains.trimesh.utils import make_plane from isaaclab.terrains.utils import create_prim_from_mesh +from isaaclab_assets.robots.anymal import ANYMAL_C_CFG +from isaaclab_assets.robots.spot import SPOT_CFG + # sample camera poses (quaternions in xyzw format) POSITION = [2.5, 2.5, 2.5] QUAT_ROS = [0.33985114, 0.82047325, -0.42470819, -0.17591989] QUAT_OPENGL = [0.17591988, 0.42470818, 0.82047324, 0.33985113] QUAT_WORLD = [-0.27984815, -0.1159169, 0.88047623, -0.3647052] +MESH_ID_GROUND = 0 +MESH_ID_OBJECT = 1 +MESH_ID_ROBOT_MIN = 2 def _assert_quat_close(actual, expected, **kwargs): @@ -127,7 +136,7 @@ def test_camera_init_offset(setup_simulation, convention, quat): camera.update(dt) # check that transform is set correctly - np.testing.assert_allclose(camera.data.pos_w[0].cpu().numpy(), cam_cfg_offset.offset.pos) + np.testing.assert_allclose(camera.data.pos_w.torch[0].cpu().numpy(), cam_cfg_offset.offset.pos) del camera @@ -190,7 +199,7 @@ def test_camera_init_intrinsic_matrix(setup_simulation): camera_1 = MultiMeshRayCasterCamera(cfg=camera_cfg) # get intrinsic matrix sim.reset() - intrinsic_matrix = camera_1.data.intrinsic_matrices[0].cpu().flatten().tolist() + intrinsic_matrix = camera_1.data.intrinsic_matrices.torch[0].cpu().flatten().tolist() # initialize from intrinsic matrix intrinsic_camera_cfg = MultiMeshRayCasterCameraCfg( @@ -219,13 +228,13 @@ def test_camera_init_intrinsic_matrix(setup_simulation): # check image data torch.testing.assert_close( - camera_1.data.output["distance_to_image_plane"], - camera_2.data.output["distance_to_image_plane"], + camera_1.data.output["distance_to_image_plane"].torch, + camera_2.data.output["distance_to_image_plane"].torch, ) # check that both intrinsic matrices are the same torch.testing.assert_close( - camera_1.data.intrinsic_matrices[0], - camera_2.data.intrinsic_matrices[0], + camera_1.data.intrinsic_matrices.torch[0], + camera_2.data.intrinsic_matrices.torch[0], ) del camera_1, camera_2 @@ -408,8 +417,8 @@ def test_output_equal_to_usdcamera(setup_simulation, data_types): # check the intrinsic matrices torch.testing.assert_close( - camera_usd.data.intrinsic_matrices, - camera_warp.data.intrinsic_matrices, + camera_usd.data.intrinsic_matrices.torch, + camera_warp.data.intrinsic_matrices.torch, ) # check the apertures @@ -423,8 +432,8 @@ def test_output_equal_to_usdcamera(setup_simulation, data_types): if data_type in camera_usd.data.output and data_type in camera_warp.data.output: if data_type == "distance_to_camera" or data_type == "distance_to_image_plane": torch.testing.assert_close( - camera_usd.data.output[data_type], - camera_warp.data.output[data_type], + camera_usd.data.output[data_type].torch, + camera_warp.data.output[data_type].torch, atol=5e-5, rtol=5e-6, ) @@ -438,13 +447,187 @@ def test_output_equal_to_usdcamera(setup_simulation, data_types): ) else: torch.testing.assert_close( - camera_usd.data.output[data_type], - camera_warp.data.output[data_type], + camera_usd.data.output[data_type].torch, + camera_warp.data.output[data_type].torch, ) del camera_usd, camera_warp +def _create_heterogeneous_clone_scene(sim: sim_utils.SimulationContext, num_envs: int) -> torch.Tensor: + """Create alternating Spot/ANYmal and cube/sphere cloned environments.""" + stage = sim_utils.get_current_stage() + env_fmt = "/World/envs/env_{}" + env_ids = torch.arange(num_envs, dtype=torch.long, device=sim.device) + env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=4.0, device=sim.device) + + sim_utils.create_prim("/World/envs", "Xform", stage=stage) + for env_id, origin in enumerate(env_origins.cpu().tolist()): + sim_utils.create_prim(env_fmt.format(env_id), "Xform", translation=tuple(origin), stage=stage) + + robot_mask = torch.zeros((2, num_envs), dtype=torch.bool, device=sim.device) + robot_mask[0, 0::2] = True + robot_mask[1, 1::2] = True + object_mask = robot_mask.clone() + + spot_spawn = copy.deepcopy(SPOT_CFG.spawn) + anymal_spawn = copy.deepcopy(ANYMAL_C_CFG.spawn) + spot_spawn.func(env_fmt.format(0) + "/Robot", spot_spawn, translation=SPOT_CFG.init_state.pos) + anymal_spawn.func(env_fmt.format(1) + "/Robot", anymal_spawn, translation=ANYMAL_C_CFG.init_state.pos) + + cube_cfg = sim_utils.CuboidCfg( + size=(0.35, 0.25, 0.25), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.7, 0.2, 0.2)), + ) + sphere_cfg = sim_utils.SphereCfg( + radius=0.18, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.2, 0.7)), + ) + cube_spawn = cube_cfg.func + sphere_spawn = sphere_cfg.func + assert isinstance(cube_spawn, Callable) + assert isinstance(sphere_spawn, Callable) + cube_spawn(env_fmt.format(0) + "/Object", cube_cfg, translation=(0.45, 0.0, 0.25)) + sphere_spawn(env_fmt.format(1) + "/Object", sphere_cfg, translation=(0.45, 0.0, 0.25)) + + lab_cloner.usd_replicate( + stage, + [env_fmt.format(i) + f"/{asset_name}" for asset_name in ("Robot", "Object") for i in range(2)], + [env_fmt + "/Robot", env_fmt + "/Robot", env_fmt + "/Object", env_fmt + "/Object"], + env_ids, + mask=torch.cat([robot_mask, object_mask], dim=0), + ) + + sim.set_clone_plan( + ClonePlan( + sources=( + env_fmt.format(0) + "/Robot", + env_fmt.format(1) + "/Robot", + env_fmt.format(0) + "/Object", + env_fmt.format(1) + "/Object", + ), + destinations=( + env_fmt + "/Robot", + env_fmt + "/Robot", + env_fmt + "/Object", + env_fmt + "/Object", + ), + clone_mask=torch.cat([robot_mask, object_mask], dim=0), + ) + ) + sim_utils.update_stage() + return env_origins + + +@pytest.mark.isaacsim_ci +def test_depth_output_equal_to_usd_camera_heterogeneous_scene(setup_simulation): + """Compare ray-caster and USD depth cameras in a heterogeneous cloned scene. + + The scene contains 16 environments with alternating Spot / ANYmal-C robot + prototypes and alternating cube / sphere objects. The ray-caster consumes + the same clone plan used to build the USD scene and should match the batched + USD camera's stable ``distance_to_image_plane`` pixels for every environment. + """ + sim, dt, _ = setup_simulation + num_envs = 16 + env_origins = _create_heterogeneous_clone_scene(sim, num_envs) + + height, width = 96, 128 + camera_pattern_cfg = patterns.PinholeCameraPatternCfg( + focal_length=24.0, + horizontal_aperture=20.955, + height=height, + width=width, + ) + mesh_prim_paths = [ + "/World/defaultGroundPlane", + MultiMeshRayCasterCameraCfg.RaycastTargetCfg( + prim_expr="/World/envs/env_.*/Object", + track_mesh_transforms=False, + ), + MultiMeshRayCasterCameraCfg.RaycastTargetCfg( + prim_expr="/World/envs/env_.*/Robot/.+", + track_mesh_transforms=True, + ), + ] + camera_cfg_warp = MultiMeshRayCasterCameraCfg( + prim_path="/World/envs/env_.*/RayCasterCamera", + mesh_prim_paths=mesh_prim_paths, + update_period=0, + debug_vis=False, + pattern_cfg=camera_pattern_cfg, + max_distance=25.0, + data_types=["distance_to_image_plane"], + depth_clipping_behavior="max", + update_mesh_ids=True, + ) + camera_warp = MultiMeshRayCasterCamera(camera_cfg_warp) + + camera_cfg_usd = CameraCfg( + height=height, + width=width, + prim_path="/World/envs/env_.*/UsdCamera", + update_period=0, + data_types=["distance_to_image_plane"], + spawn=PinholeCameraCfg( + focal_length=24.0, + focus_distance=400.0, + horizontal_aperture=20.955, + clipping_range=(0.01, 25.0), + ), + ) + camera_usd = Camera(camera_cfg_usd) + + sim.reset() + sim.play() + + eyes = env_origins + torch.tensor((1.8, -2.5, 2.5), dtype=torch.float32, device=sim.device) + targets = env_origins + torch.tensor((0.0, 0.0, 0.0), dtype=torch.float32, device=sim.device) + camera_warp.set_world_poses_from_view(eyes=eyes, targets=targets) + camera_usd.set_world_poses_from_view(eyes=eyes, targets=targets) + + for _ in range(5): + sim.render() + + camera_usd.update(dt) + camera_warp.update(dt) + + ray_depth = camera_warp.data.output["distance_to_image_plane"].torch + usd_depth = camera_usd.data.output["distance_to_image_plane"].torch + assert ray_depth.shape == (num_envs, height, width, 1) + assert usd_depth.shape == ray_depth.shape + depth_diff = (ray_depth - usd_depth).abs() + mesh_ids_proxy = getattr(camera_warp.data, "image_mesh_ids", None) + assert mesh_ids_proxy is not None + mesh_ids = mesh_ids_proxy.torch + assert torch.any(mesh_ids == MESH_ID_OBJECT), "Expected object pixels in the heterogeneous scene" + assert torch.any(mesh_ids >= MESH_ID_ROBOT_MIN), "Expected robot pixels in the heterogeneous scene" + + # The RTX and ray-cast backends can disagree by a pixel along complex robot + # silhouettes. Compare the stable ground pixels after dilating object/robot + # edges and depth discontinuities. + target_mask = mesh_ids[..., 0] != 0 + discontinuity_mask = torch.zeros_like(target_mask) + for depth in (ray_depth, usd_depth): + depth_image = depth[..., 0] + discontinuity_mask[:, 1:, :] |= (depth_image[:, 1:, :] - depth_image[:, :-1, :]).abs() > 0.3 + discontinuity_mask[:, :, 1:] |= (depth_image[:, :, 1:] - depth_image[:, :, :-1]).abs() > 0.3 + edge_mask = target_mask | discontinuity_mask + silhouette_mask = torch.nn.functional.max_pool2d( + edge_mask[:, None, :, :].float(), kernel_size=21, stride=1, padding=10 + ).to(dtype=torch.bool) + stable_mask = ~silhouette_mask[:, 0, :, :, None] + assert stable_mask.float().mean() > 0.7 + stable_ray_depth = ray_depth[stable_mask] + stable_usd_depth = usd_depth[stable_mask] + stable_depth_diff = depth_diff[stable_mask] + stable_close = torch.isclose(stable_ray_depth, stable_usd_depth, atol=5e-5, rtol=5e-6) + assert stable_close.float().mean() > 0.999 + assert torch.quantile(stable_depth_diff, 0.999) < 5.0e-5 + + del camera_usd, camera_warp + + @pytest.mark.isaacsim_ci def test_output_equal_to_usdcamera_offset(setup_simulation): """Test that ray caster camera output equals USD camera output with offset.""" @@ -497,14 +680,14 @@ def test_output_equal_to_usdcamera_offset(setup_simulation): # check image data torch.testing.assert_close( - camera_usd.data.output["distance_to_image_plane"], - camera_warp.data.output["distance_to_image_plane"], + camera_usd.data.output["distance_to_image_plane"].torch, + camera_warp.data.output["distance_to_image_plane"].torch, atol=5e-5, rtol=5e-6, ) torch.testing.assert_close( - camera_usd.data.output["distance_to_camera"], - camera_warp.data.output["distance_to_camera"], + camera_usd.data.output["distance_to_camera"].torch, + camera_warp.data.output["distance_to_camera"].torch, atol=5e-5, rtol=5e-6, ) @@ -593,14 +776,14 @@ def test_output_equal_to_usdcamera_prim_offset(setup_simulation): # check image data torch.testing.assert_close( - camera_usd.data.output["distance_to_image_plane"], - camera_warp.data.output["distance_to_image_plane"], + camera_usd.data.output["distance_to_image_plane"].torch, + camera_warp.data.output["distance_to_image_plane"].torch, atol=5e-5, rtol=5e-6, ) torch.testing.assert_close( - camera_usd.data.output["distance_to_camera"], - camera_warp.data.output["distance_to_camera"], + camera_usd.data.output["distance_to_camera"].torch, + camera_warp.data.output["distance_to_camera"].torch, rtol=4e-6, atol=2e-5, ) @@ -681,15 +864,17 @@ def test_output_equal_to_usd_camera_intrinsics(setup_simulation, height, width): camera_warp.update(dt) # filter nan and inf from output - cam_warp_output = camera_warp.data.output["distance_to_image_plane"].clone() - cam_usd_output = camera_usd.data.output["distance_to_image_plane"].clone() + cam_warp_output = camera_warp.data.output["distance_to_image_plane"].torch.clone() + cam_usd_output = camera_usd.data.output["distance_to_image_plane"].torch.clone() cam_warp_output[torch.isnan(cam_warp_output)] = 0 cam_warp_output[torch.isinf(cam_warp_output)] = 0 cam_usd_output[torch.isnan(cam_usd_output)] = 0 cam_usd_output[torch.isinf(cam_usd_output)] = 0 # check that both have the same intrinsic matrices - torch.testing.assert_close(camera_warp.data.intrinsic_matrices[0], camera_usd.data.intrinsic_matrices[0]) + torch.testing.assert_close( + camera_warp.data.intrinsic_matrices.torch[0], camera_usd.data.intrinsic_matrices.torch[0] + ) # check the apertures torch.testing.assert_close( @@ -782,8 +967,8 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_simulation): # check image data torch.testing.assert_close( - camera_usd.data.output["distance_to_camera"], - camera_warp.data.output["distance_to_camera"], + camera_usd.data.output["distance_to_camera"].torch, + camera_warp.data.output["distance_to_camera"].torch, rtol=5e-3, atol=1e-4, ) @@ -804,8 +989,8 @@ def test_image_mesh_ids_identifies_hit_mesh(setup_simulation): 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 camera.data.image_mesh_ids is not None, "image_mesh_ids should not be None when update_mesh_ids=True" + mesh_ids = camera.data.image_mesh_ids.torch # shape (N, H, W, 1), dtype torch.int16 assert mesh_ids.shape[-1] == 1 assert mesh_ids.dtype == torch.int16 @@ -813,11 +998,11 @@ def test_image_mesh_ids_identifies_hit_mesh(setup_simulation): # (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") + hit_mask = camera.data.output["distance_to_camera"].torch[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). + # All hits against the single registered mesh must carry the ground mesh id. 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()}" + assert torch.all(hit_mesh_ids == MESH_ID_GROUND), ( + f"All hits against the single ground mesh must have mesh_id={MESH_ID_GROUND}, got: {hit_mesh_ids.unique()}" ) diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index d8ac47e95a60..db252f9456c8 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -211,45 +211,52 @@ def test_depth_clipping(setup_sim): camera_max.update(dt) # none clipping should contain inf values - assert torch.isinf(camera_none.data.output["distance_to_camera"]).any() - assert torch.isnan(camera_none.data.output["distance_to_image_plane"]).any() + assert torch.isinf(camera_none.data.output["distance_to_camera"].torch).any() + assert torch.isnan(camera_none.data.output["distance_to_image_plane"].torch).any() assert ( - camera_none.data.output["distance_to_camera"][~torch.isinf(camera_none.data.output["distance_to_camera"])].max() + camera_none.data.output["distance_to_camera"] + .torch[~torch.isinf(camera_none.data.output["distance_to_camera"].torch)] + .max() > camera_cfg_zero.max_distance ) assert ( - camera_none.data.output["distance_to_image_plane"][ - ~torch.isnan(camera_none.data.output["distance_to_image_plane"]) - ].max() + camera_none.data.output["distance_to_image_plane"] + .torch[~torch.isnan(camera_none.data.output["distance_to_image_plane"].torch)] + .max() > camera_cfg_zero.max_distance ) # zero clipping should result in zero values assert torch.all( - camera_zero.data.output["distance_to_camera"][torch.isinf(camera_none.data.output["distance_to_camera"])] == 0.0 + camera_zero.data.output["distance_to_camera"].torch[ + torch.isinf(camera_none.data.output["distance_to_camera"].torch) + ] + == 0.0 ) assert torch.all( - camera_zero.data.output["distance_to_image_plane"][ - torch.isnan(camera_none.data.output["distance_to_image_plane"]) + camera_zero.data.output["distance_to_image_plane"].torch[ + torch.isnan(camera_none.data.output["distance_to_image_plane"].torch) ] == 0.0 ) - assert camera_zero.data.output["distance_to_camera"].max() <= camera_cfg_zero.max_distance - assert camera_zero.data.output["distance_to_image_plane"].max() <= camera_cfg_zero.max_distance + assert camera_zero.data.output["distance_to_camera"].torch.max() <= camera_cfg_zero.max_distance + assert camera_zero.data.output["distance_to_image_plane"].torch.max() <= camera_cfg_zero.max_distance # max clipping should result in max values assert torch.all( - camera_max.data.output["distance_to_camera"][torch.isinf(camera_none.data.output["distance_to_camera"])] + camera_max.data.output["distance_to_camera"].torch[ + torch.isinf(camera_none.data.output["distance_to_camera"].torch) + ] == camera_cfg_zero.max_distance ) assert torch.all( - camera_max.data.output["distance_to_image_plane"][ - torch.isnan(camera_none.data.output["distance_to_image_plane"]) + camera_max.data.output["distance_to_image_plane"].torch[ + torch.isnan(camera_none.data.output["distance_to_image_plane"].torch) ] == camera_cfg_zero.max_distance ) - assert camera_max.data.output["distance_to_camera"].max() <= camera_cfg_zero.max_distance - assert camera_max.data.output["distance_to_image_plane"].max() <= camera_cfg_zero.max_distance + assert camera_max.data.output["distance_to_camera"].torch.max() <= camera_cfg_zero.max_distance + assert camera_max.data.output["distance_to_image_plane"].torch.max() <= camera_cfg_zero.max_distance @pytest.mark.isaacsim_ci @@ -297,9 +304,9 @@ def test_camera_init_offset(setup_sim): camera_ros.update(dt) # check that all transforms are set correctly - np.testing.assert_allclose(camera_ros.data.pos_w[0].cpu().numpy(), cam_cfg_offset_ros.offset.pos) - np.testing.assert_allclose(camera_opengl.data.pos_w[0].cpu().numpy(), cam_cfg_offset_opengl.offset.pos) - np.testing.assert_allclose(camera_world.data.pos_w[0].cpu().numpy(), cam_cfg_offset_world.offset.pos) + np.testing.assert_allclose(camera_ros.data.pos_w.torch[0].cpu().numpy(), cam_cfg_offset_ros.offset.pos) + np.testing.assert_allclose(camera_opengl.data.pos_w.torch[0].cpu().numpy(), cam_cfg_offset_opengl.offset.pos) + np.testing.assert_allclose(camera_world.data.pos_w.torch[0].cpu().numpy(), cam_cfg_offset_world.offset.pos) # check if transform correctly set in output np.testing.assert_allclose(camera_ros.data.pos_w[0].cpu().numpy(), cam_cfg_offset_ros.offset.pos, rtol=1e-5) @@ -316,7 +323,7 @@ def test_camera_init_intrinsic_matrix(setup_sim): camera_1 = RayCasterCamera(cfg=camera_cfg) # get intrinsic matrix sim.reset() - intrinsic_matrix = camera_1.data.intrinsic_matrices[0].cpu().flatten().tolist() + intrinsic_matrix = camera_1.data.intrinsic_matrices.torch[0].cpu().flatten().tolist() teardown(sim) # reinit the first camera sim, camera_cfg, dt = setup() @@ -350,13 +357,13 @@ def test_camera_init_intrinsic_matrix(setup_sim): # check image data torch.testing.assert_close( - camera_1.data.output["distance_to_image_plane"], - camera_2.data.output["distance_to_image_plane"], + camera_1.data.output["distance_to_image_plane"].torch, + camera_2.data.output["distance_to_image_plane"].torch, ) # check that both intrinsic matrices are the same torch.testing.assert_close( - camera_1.data.intrinsic_matrices[0], - camera_2.data.intrinsic_matrices[0], + camera_1.data.intrinsic_matrices.torch[0], + camera_2.data.intrinsic_matrices.torch[0], ) @@ -520,8 +527,8 @@ def test_output_equal_to_usdcamera(setup_sim): # check the intrinsic matrices torch.testing.assert_close( - camera_usd.data.intrinsic_matrices, - camera_warp.data.intrinsic_matrices, + camera_usd.data.intrinsic_matrices.torch, + camera_warp.data.intrinsic_matrices.torch, ) # check the apertures @@ -540,14 +547,14 @@ def test_output_equal_to_usdcamera(setup_sim): # check image data torch.testing.assert_close( - camera_usd.data.output["distance_to_image_plane"], - camera_warp.data.output["distance_to_image_plane"], + camera_usd.data.output["distance_to_image_plane"].torch, + camera_warp.data.output["distance_to_image_plane"].torch, rtol=1e-5, atol=1e-4, ) torch.testing.assert_close( - camera_usd.data.output["distance_to_camera"], - camera_warp.data.output["distance_to_camera"], + camera_usd.data.output["distance_to_camera"].torch, + camera_warp.data.output["distance_to_camera"].torch, atol=5e-5, rtol=5e-6, ) @@ -616,14 +623,14 @@ def test_output_equal_to_usdcamera_offset(setup_sim): # check image data torch.testing.assert_close( - camera_usd.data.output["distance_to_image_plane"], - camera_warp.data.output["distance_to_image_plane"], + camera_usd.data.output["distance_to_image_plane"].torch, + camera_warp.data.output["distance_to_image_plane"].torch, rtol=1e-3, atol=1e-5, ) torch.testing.assert_close( - camera_usd.data.output["distance_to_camera"], - camera_warp.data.output["distance_to_camera"], + camera_usd.data.output["distance_to_camera"].torch, + camera_warp.data.output["distance_to_camera"].torch, rtol=1e-3, atol=1e-5, ) @@ -710,14 +717,14 @@ def test_output_equal_to_usdcamera_prim_offset(setup_sim): # check image data torch.testing.assert_close( - camera_usd.data.output["distance_to_image_plane"], - camera_warp.data.output["distance_to_image_plane"], + camera_usd.data.output["distance_to_image_plane"].torch, + camera_warp.data.output["distance_to_image_plane"].torch, rtol=1e-3, atol=1e-5, ) torch.testing.assert_close( - camera_usd.data.output["distance_to_camera"], - camera_warp.data.output["distance_to_camera"], + camera_usd.data.output["distance_to_camera"].torch, + camera_warp.data.output["distance_to_camera"].torch, rtol=4e-6, atol=2e-5, ) @@ -800,15 +807,17 @@ def test_output_equal_to_usd_camera_intrinsics(setup_sim, focal_length): camera_warp.update(dt) # filter nan and inf from output - cam_warp_output = camera_warp.data.output["distance_to_image_plane"].clone() - cam_usd_output = camera_usd.data.output["distance_to_image_plane"].clone() + cam_warp_output = camera_warp.data.output["distance_to_image_plane"].torch.clone() + cam_usd_output = camera_usd.data.output["distance_to_image_plane"].torch.clone() cam_warp_output[torch.isnan(cam_warp_output)] = 0 cam_warp_output[torch.isinf(cam_warp_output)] = 0 cam_usd_output[torch.isnan(cam_usd_output)] = 0 cam_usd_output[torch.isinf(cam_usd_output)] = 0 # check that both have the same intrinsic matrices - torch.testing.assert_close(camera_warp.data.intrinsic_matrices[0], camera_usd.data.intrinsic_matrices[0]) + torch.testing.assert_close( + camera_warp.data.intrinsic_matrices.torch[0], camera_usd.data.intrinsic_matrices.torch[0] + ) # check the apertures torch.testing.assert_close( @@ -932,14 +941,16 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_sim, focal_length_ import matplotlib.pyplot as plt fig, axs = plt.subplots(1, 3, figsize=(15, 5)) - usd_plt = axs[0].imshow(camera_usd.data.output["distance_to_camera"][0].cpu().numpy()) + usd_plt = axs[0].imshow(camera_usd.data.output["distance_to_camera"].torch[0].cpu().numpy()) fig.colorbar(usd_plt, ax=axs[0]) axs[0].set_title("USD") - warp_plt = axs[1].imshow(camera_warp.data.output["distance_to_camera"][0].cpu().numpy()) + warp_plt = axs[1].imshow(camera_warp.data.output["distance_to_camera"].torch[0].cpu().numpy()) fig.colorbar(warp_plt, ax=axs[1]) axs[1].set_title("WARP") diff_plt = axs[2].imshow( - torch.abs(camera_usd.data.output["distance_to_camera"] - camera_warp.data.output["distance_to_camera"])[0] + torch.abs( + camera_usd.data.output["distance_to_camera"].torch - camera_warp.data.output["distance_to_camera"].torch + )[0] .cpu() .numpy() ) @@ -956,8 +967,8 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_sim, focal_length_ if focal_length != 0.193: # FIXME: 0.193 is not working on the IsaacSim/ UsdGeom side, add back once fixed torch.testing.assert_close( - camera_usd.data.output["distance_to_camera"], - camera_warp.data.output["distance_to_camera"], + camera_usd.data.output["distance_to_camera"].torch, + camera_warp.data.output["distance_to_camera"].torch, rtol=5e-3, atol=1e-4, ) @@ -1030,10 +1041,10 @@ def test_depth_clipping_d2ip_and_d2c_are_independent(setup_sim): 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"] + d2ip_joint = cam_joint.data.output["distance_to_image_plane"].torch + d2c_joint = cam_joint.data.output["distance_to_camera"].torch + d2ip_solo = cam_d2ip.data.output["distance_to_image_plane"].torch + d2c_solo = cam_d2c.data.output["distance_to_camera"].torch # 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) @@ -1091,7 +1102,7 @@ def test_set_intrinsic_matrices_updates_output(setup_sim): for _ in range(3): sim.step() camera.update(dt) - output_before = camera.data.output["distance_to_camera"].clone() + output_before = camera.data.output["distance_to_camera"].torch.clone() # Change to a very different focal length (longer → tighter FOV → depth values differ at edges) new_matrix = torch.tensor( @@ -1103,7 +1114,7 @@ def test_set_intrinsic_matrices_updates_output(setup_sim): for _ in range(3): sim.step() camera.update(dt) - output_after = camera.data.output["distance_to_camera"].clone() + output_after = camera.data.output["distance_to_camera"].torch.clone() # Outputs must differ after intrinsics change (different ray angles → different depths) assert not torch.allclose(output_before, output_after, atol=1e-3), ( diff --git a/source/isaaclab/test/sensors/test_ray_caster_integration.py b/source/isaaclab/test/sensors/test_ray_caster_integration.py index 36b6d8ff4692..91fa9c12fedc 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_integration.py +++ b/source/isaaclab/test/sensors/test_ray_caster_integration.py @@ -10,10 +10,10 @@ 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 +- ``_get_sensor_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) +- ``_update_mesh_transforms`` non-identity orientation offset - Depth clipping ordering for ``MultiMeshRayCasterCamera`` """ @@ -22,6 +22,7 @@ simulation_app = AppLauncher(headless=True, enable_cameras=True).app import copy +from typing import Any, cast import numpy as np import pytest @@ -31,6 +32,7 @@ from pxr import UsdGeom, UsdPhysics import isaaclab.sim as sim_utils +from isaaclab.cloner.clone_plan import ClonePlan from isaaclab.sensors.ray_caster import ( MultiMeshRayCaster, MultiMeshRayCasterCamera, @@ -75,6 +77,14 @@ def _single_downward_ray_cfg(prim_path: str) -> RayCasterCfg: ) +def _spawn_cube_part(part_path: str, translation: tuple[float, float, float]) -> None: + """Create a small mesh-bearing part under an Xform target.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim(part_path, "Xform", translation=translation, stage=stage) + cube = cast(Any, UsdGeom.Cube.Define(stage, f"{part_path}/Mesh")) + cube.CreateSizeAttr().Set(0.35) + + @pytest.fixture def sim_ground(): sim = _make_sim_and_ground() @@ -84,7 +94,7 @@ def sim_ground(): # --------------------------------------------------------------------------- -# _get_view_transforms_wp: ArticulationView path +# _get_sensor_transforms_wp: ArticulationView path # --------------------------------------------------------------------------- @@ -95,7 +105,7 @@ def test_articulation_view_path(sim_ground): 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`. + :meth:`_get_sensor_transforms_wp`. """ sim = sim_ground expected_pos = (3.0, 4.0, 5.0) @@ -107,11 +117,11 @@ def test_articulation_view_path(sim_ground): 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 = cast(Any, 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 = cast(Any, UsdGeom.Cube.Define(stage, cube_path)) cube_geom.CreateSizeAttr().Set(0.1) UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(cube_path)) sim_utils.update_stage() @@ -133,7 +143,7 @@ def test_articulation_view_path(sim_ground): # --------------------------------------------------------------------------- -# _get_view_transforms_wp: RigidBodyView path +# _get_sensor_transforms_wp: RigidBodyView path # --------------------------------------------------------------------------- @@ -142,7 +152,7 @@ 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`. + :meth:`_get_sensor_transforms_wp`. """ sim = sim_ground expected_pos = (1.0, 2.0, 6.0) @@ -152,10 +162,10 @@ def test_rigid_body_view_path(sim_ground): stage = sim_utils.get_current_stage() prim = stage.GetPrimAtPath(prim_path) UsdPhysics.RigidBodyAPI.Apply(prim) - mass_api = UsdPhysics.MassAPI.Apply(prim) + mass_api = cast(Any, 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 = cast(Any, UsdGeom.Cube.Define(stage, cube_path)) cube_geom.CreateSizeAttr().Set(0.1) UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(cube_path)) sim_utils.update_stage() @@ -226,7 +236,8 @@ def test_multi_mesh_camera_set_intrinsic_matrices(sim_ground_camera): for _ in range(3): sim.step() camera.update(_DT) - output_before = camera.data.output["distance_to_camera"].clone() + camera_output = cast(dict[str, Any], camera.data.output) + output_before = camera_output["distance_to_camera"].torch.clone() # Change to a very different intrinsic matrix (different FOV) new_matrix = torch.tensor( @@ -238,7 +249,8 @@ def test_multi_mesh_camera_set_intrinsic_matrices(sim_ground_camera): for _ in range(3): sim.step() camera.update(_DT) - output_after = camera.data.output["distance_to_camera"].clone() + camera_output = cast(dict[str, Any], camera.data.output) + output_after = camera_output["distance_to_camera"].torch.clone() assert not torch.allclose(output_before, output_after, atol=1e-3), ( "MultiMeshRayCasterCamera: depth output must change after set_intrinsic_matrices; " @@ -256,9 +268,8 @@ def test_multi_mesh_camera_set_intrinsic_matrices(sim_ground_camera): 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. + The two outputs are clipped independently from the same raw ray-distance buffer. + This test verifies the data types do not interfere with each other. """ sim, base_cfg = sim_ground_camera @@ -292,10 +303,13 @@ def test_multi_mesh_camera_d2ip_and_d2c_independent(sim_ground_camera): 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_output = cast(dict[str, Any], cam_joint.data.output) + d2ip_output = cast(dict[str, Any], cam_d2ip.data.output) + d2c_output = cast(dict[str, Any], cam_d2c.data.output) + d2ip_joint = joint_output["distance_to_image_plane"].torch + d2c_joint = joint_output["distance_to_camera"].torch + d2ip_solo = d2ip_output["distance_to_image_plane"].torch + d2c_solo = d2c_output["distance_to_camera"].torch # 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) @@ -307,6 +321,85 @@ def test_multi_mesh_camera_d2ip_and_d2c_independent(sim_ground_camera): # --------------------------------------------------------------------------- +@pytest.mark.isaacsim_ci +def test_multi_mesh_uses_clone_plan_geometry_and_backend_object_pose(sim_ground): + """ClonePlan supplies source geometry while PhysX supplies per-env object poses.""" + sim = sim_ground + num_envs = 3 + stage = sim_utils.get_current_stage() + + def _create_object_body(path: str) -> None: + sim_utils.create_prim(path, "Xform", stage=stage) + body_prim = cast(Any, stage.GetPrimAtPath(path)) + UsdPhysics.RigidBodyAPI.Apply(body_prim) + mass_api = cast(Any, UsdPhysics.MassAPI.Apply(body_prim)) + mass_api.CreateMassAttr().Set(1.0) + body_prim.GetAttribute("physics:kinematicEnabled").Set(True) + collision = cast(Any, UsdGeom.Cube.Define(stage, f"{path}/Collision")) + collision.CreateSizeAttr().Set(0.1) + UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(f"{path}/Collision")) + + sim_utils.create_prim("/World/envs", "Xform", stage=stage) + for env_id in range(num_envs): + sim_utils.create_prim(f"/World/envs/env_{env_id}", "Xform", translation=(3.0 * env_id, 0.0, 0.0), stage=stage) + sim_utils.create_prim(f"/World/envs/env_{env_id}/Sensor", "Xform", translation=(0.0, 0.0, 3.0), stage=stage) + _create_object_body(f"/World/envs/env_{env_id}/Object") + + # Representative source assets live in the first concrete cloned instances, + # matching the scene convention used by the cloner. Env 2 has an object + # body for backend pose tracking, but intentionally has no destination mesh. + _spawn_cube_part("/World/envs/env_0/Object/part_0", (0.0, 0.0, 0.0)) + _spawn_cube_part("/World/envs/env_1/Object/part_0", (0.0, 0.0, 0.0)) + + # This test intentionally does not author /env_2/Object/part_0. ClonePlan + # selects source geometry; the object body view supplies env_2's live pose. + sim.set_clone_plan( + ClonePlan( + sources=("/World/envs/env_0/Object", "/World/envs/env_1/Object"), + destinations=("/World/envs/env_{}/Object", "/World/envs/env_{}/Object"), + clone_mask=torch.tensor([[True, False, True], [False, True, False]], dtype=torch.bool, device=sim.device), + ) + ) + sim_utils.update_stage() + + cfg = MultiMeshRayCasterCfg( + prim_path="/World/envs/env_.*/Sensor", + mesh_prim_paths=[ + MultiMeshRayCasterCfg.RaycastTargetCfg( + prim_expr="/World/envs/env_.*/Object/part_.*", + 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=0.5, size=(1.0, 0.0), direction=(0.0, 0.0, -1.0)), + ray_alignment="world", + ) + sensor = MultiMeshRayCaster(cfg) + sim.reset() + sensor.update(_DT, force_recompute=True) + + env0_object = stage.GetPrimAtPath("/World/envs/env_0/Object") + env1_object = stage.GetPrimAtPath("/World/envs/env_1/Object") + assert env0_object is not None and env0_object.IsValid() + assert env1_object is not None and env1_object.IsValid() + env2_part = stage.GetPrimAtPath("/World/envs/env_2/Object/part_0") + assert env2_part is None or not env2_part.IsValid() + + # Geometry is selected from ClonePlan rows, but poses come from the batched object view. + mesh_ids = wp.to_torch(sensor._mesh_ids_wp).cpu() + mesh_positions = wp.to_torch(sensor._mesh_positions_w).cpu() + assert sensor._mesh_ids_wp.shape == (num_envs, 1) + assert mesh_ids[2, 0] == mesh_ids[0, 0] + torch.testing.assert_close(mesh_positions[:, 0, 0], torch.tensor([0.0, 3.0, 6.0]), atol=0.15, rtol=0.0) + + hits = sensor.data.ray_hits_w.torch + assert torch.isfinite(hits[0]).any(), "env_0 should hit the env_0 source geometry" + assert torch.isfinite(hits[1]).any(), "env_1 should hit the env_1 source geometry" + assert torch.isfinite(hits[2]).any(), "env_2 should hit env_0 geometry at env_2's backend object pose" + + @pytest.mark.isaacsim_ci def test_multi_mesh_env_mask_preserves_masked_buffers(sim_ground): """Masked environments must retain their pre-update buffer values. @@ -357,17 +450,18 @@ def test_multi_mesh_env_mask_preserves_masked_buffers(sim_ground): @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. + """Tracked mesh geometry 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) + Correct world position of the baked geometry = 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). + The mesh pose table stores the owner-body pose; the offset is baked into the + Warp mesh vertices so backend body poses remain the single runtime pose source. """ sim = sim_ground @@ -381,29 +475,29 @@ def test_update_mesh_transforms_non_identity_offset(sim_ground): 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) + body_prim = cast(Any, stage.GetPrimAtPath(body_path)) UsdPhysics.RigidBodyAPI.Apply(body_prim) - mass_api = UsdPhysics.MassAPI.Apply(body_prim) + mass_api = cast(Any, 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. + # then place mesh geometry under it. The tracked target view resolves this + # local offset and updates from the backend body pose. 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 = cast(Any, 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)) + sim_utils.create_prim(sensor_path, "Xform", translation=(0.0, 1.0, 5.0)) # Configure MultiMeshRayCaster to track the child mesh cfg = MultiMeshRayCasterCfg( @@ -422,18 +516,19 @@ def test_update_mesh_transforms_non_identity_offset(sim_ground): ) sensor = MultiMeshRayCaster(cfg) sim.reset() - sensor.update(_DT) + sensor.update(_DT, force_recompute=True) - # 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() + # The runtime pose buffer stores the body pose; the child offset is in the mesh vertices. + mesh_pos = wp.to_torch(sensor._mesh_positions_w).clone() np.testing.assert_allclose( mesh_pos[0, 0].cpu().numpy(), + [0.0, 0.0, 2.0], + atol=0.15, + err_msg="Tracked mesh pose should be the owner body pose.", + ) + np.testing.assert_allclose( + sensor.data.ray_hits_w.torch[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." - ), + err_msg="Ray hit should include the child mesh offset baked into the Warp geometry.", ) diff --git a/source/isaaclab/test/sensors/test_ray_caster_kernels.py b/source/isaaclab/test/sensors/test_ray_caster_kernels.py index cc57e4f1eec5..b0853c2a3bcb 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_kernels.py +++ b/source/isaaclab/test/sensors/test_ray_caster_kernels.py @@ -54,9 +54,12 @@ _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 +compute_distance_to_image_plane_to_image_masked_kernel = ( + _sensor_mod.compute_distance_to_image_plane_to_image_masked_kernel +) apply_z_drift_kernel = _sensor_mod.apply_z_drift_kernel +copy_float2d_to_image1_depth_clipped_masked_kernel = _sensor_mod.copy_float2d_to_image1_depth_clipped_masked_kernel +fill_ray_hits_distance_inf_kernel = _sensor_mod.fill_ray_hits_distance_inf_kernel quat_yaw_only = _sensor_mod.quat_yaw_only raycast_dynamic_meshes_kernel = _warp_mod.raycast_dynamic_meshes_kernel @@ -120,6 +123,66 @@ def _to_numpy(a: wp.array) -> np.ndarray: return a.numpy() +# --------------------------------------------------------------------------- +# Tests: fill_ray_hits_distance_inf_kernel +# --------------------------------------------------------------------------- + + +class TestFillRayHitsDistanceInfKernel: + """Tests for :func:`fill_ray_hits_distance_inf_kernel`.""" + + def test_active_envs_are_filled_and_masked_envs_are_preserved(self): + """Active environments are filled with infinity while masked environments retain prior values.""" + env_mask = wp.array(np.array([False, True], dtype=np.bool_), dtype=wp.bool, device=DEVICE) + ray_hits = wp.array( + np.array( + [ + [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + [[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]], + ], + dtype=np.float32, + ), + dtype=wp.vec3f, + device=DEVICE, + ) + ray_distance = wp.array( + np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32), + dtype=wp.float32, + device=DEVICE, + ) + ray_normals = wp.array( + np.array( + [ + [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], + ], + dtype=np.float32, + ), + dtype=wp.vec3f, + device=DEVICE, + ) + + wp.launch( + fill_ray_hits_distance_inf_kernel, + dim=(2, 2), + inputs=[env_mask, True], + outputs=[ray_hits, ray_distance, ray_normals], + device=DEVICE, + ) + wp.synchronize_device(DEVICE) + + hits_np = _to_numpy(ray_hits) + distance_np = _to_numpy(ray_distance) + normals_np = _to_numpy(ray_normals) + + np.testing.assert_allclose(hits_np[0], [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], atol=ATOL) + np.testing.assert_allclose(distance_np[0], [1.0, 2.0], atol=ATOL) + np.testing.assert_allclose(normals_np[0], [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0]], atol=ATOL) + assert np.isinf(hits_np[1]).all() + assert np.isinf(distance_np[1]).all() + assert np.isinf(normals_np[1]).all() + + # --------------------------------------------------------------------------- # Tests: raycast_dynamic_meshes_kernel # --------------------------------------------------------------------------- @@ -320,168 +383,101 @@ def test_equidistant_meshes(self): 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) +class TestComputeDistanceToImagePlaneToImageMaskedKernel: + """Tests for :func:`compute_distance_to_image_plane_to_image_masked_kernel`.""" + + def test_compute_clip_and_copy_to_image(self): + """Distance-to-image-plane is computed, clipped, reshaped, and masked in one kernel.""" + env_mask = wp.array(np.array([False, True], dtype=np.bool_), dtype=wp.bool, device=DEVICE) + quat_w = wp.array(np.array([[0, 0, 0, 1], [0, 0, 0, 1]], dtype=np.float32), dtype=wp.quatf, device=DEVICE) + ray_distance = wp.array(np.array([[1.0, 2.0], [3.0, 7.0]], dtype=np.float32), dtype=wp.float32, device=DEVICE) + ray_directions_w = wp.array( + np.array( + [ + [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + ], + dtype=np.float32, + ), + dtype=wp.vec3f, + device=DEVICE, + ) + dst = wp.array(np.full((2, 1, 2, 1), -1.0, dtype=np.float32), 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], + compute_distance_to_image_plane_to_image_masked_kernel, + dim=(2, 2), + inputs=[env_mask, quat_w, ray_distance, ray_directions_w, 2, True, 5.0, 0.0], + outputs=[dst], 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) + dst_np = _to_numpy(dst) + np.testing.assert_allclose(dst_np[0, :, :, 0], [[-1.0, -1.0]], atol=ATOL) + np.testing.assert_allclose(dst_np[1, :, :, 0], [[3.0, 0.0]], atol=ATOL) - def test_off_axis_camera(self): - """Camera pitched 45 deg around Y, ray going world -Z. + def test_off_axis_camera_without_clipping(self): + """Camera pitched 45 deg around Y, ray going world -Z.""" + env_mask = wp.array(np.array([True], dtype=np.bool_), dtype=wp.bool, device=DEVICE) + quat_w = wp.array( + np.array([_euler_to_quat_xyzw(0, math.pi / 4, 0)], dtype=np.float32), dtype=wp.quatf, device=DEVICE + ) + ray_distance = wp.array(np.array([[10.0]], dtype=np.float32), dtype=wp.float32, device=DEVICE) + ray_directions_w = wp.array(np.array([[[0.0, 0.0, -1.0]]], dtype=np.float32), dtype=wp.vec3f, device=DEVICE) + dst = wp.array(np.full((1, 1, 1, 1), -1.0, dtype=np.float32), dtype=wp.float32, device=DEVICE) - 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]]], + wp.launch( + compute_distance_to_image_plane_to_image_masked_kernel, + dim=(1, 1), + inputs=[env_mask, quat_w, ray_distance, ray_directions_w, 1, False, 5.0, 0.0], + outputs=[dst], + device=DEVICE, ) + wp.synchronize_device(DEVICE) + expected = 10.0 * math.sin(math.pi / 4) - assert result[0, 0] == pytest.approx(expected, abs=ATOL) + assert _to_numpy(dst)[0, 0, 0, 0] == pytest.approx(expected, abs=ATOL) - def test_inf_distance(self): - """Inf distance produces NaN through the projection (inf * 0 = NaN). + def test_inf_distance_is_clipped(self): + """Inf distance produces NaN through projection and is replaced by fill value.""" + env_mask = wp.array(np.array([True], dtype=np.bool_), dtype=wp.bool, device=DEVICE) + quat_w = wp.array(np.array([[0, 0, 0, 1]], dtype=np.float32), dtype=wp.quatf, device=DEVICE) + ray_distance = wp.array(np.array([[float("inf")]], dtype=np.float32), dtype=wp.float32, device=DEVICE) + ray_directions_w = wp.array(np.array([[[1.0, 0.0, 0.0]]], dtype=np.float32), dtype=wp.vec3f, device=DEVICE) + dst = wp.array(np.full((1, 1, 1, 1), -1.0, dtype=np.float32), dtype=wp.float32, device=DEVICE) - 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]]], + wp.launch( + compute_distance_to_image_plane_to_image_masked_kernel, + dim=(1, 1), + inputs=[env_mask, quat_w, ray_distance, ray_directions_w, 1, True, 5.0, 0.0], + outputs=[dst], + device=DEVICE, ) - assert np.isnan(result[0, 0]), f"Expected NaN from inf*0 contamination, got {result[0, 0]}" - - -# --------------------------------------------------------------------------- -# Tests: apply_depth_clipping_masked_kernel -# --------------------------------------------------------------------------- + wp.synchronize_device(DEVICE) + assert _to_numpy(dst)[0, 0, 0, 0] == pytest.approx(0.0, abs=ATOL) -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 +class TestCopyFloat2dToImage1DepthClippedMaskedKernel: + """Tests for :func:`copy_float2d_to_image1_depth_clipped_masked_kernel`.""" - 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) + def test_clip_and_copy_to_image(self): + """Flat distances are optionally clipped while copying to image layout.""" + env_mask = wp.array(np.array([True], dtype=np.bool_), dtype=wp.bool, device=DEVICE) + src = wp.array(np.array([[1.0, 7.0, np.nan]], dtype=np.float32), dtype=wp.float32, device=DEVICE) + dst = wp.array(np.full((1, 1, 3, 1), -1.0, 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], + copy_float2d_to_image1_depth_clipped_masked_kernel, + dim=(1, 3), + inputs=[env_mask, src, 3, True, 5.0, 0.0], + outputs=[dst], 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) + np.testing.assert_allclose(_to_numpy(dst)[0, :, :, 0], [[1.0, 0.0, 0.0]], atol=ATOL) # --------------------------------------------------------------------------- diff --git a/source/isaaclab/test/sensors/test_ray_caster_sensor.py b/source/isaaclab/test/sensors/test_ray_caster_sensor.py index 3326c524f958..a3571fc222dd 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_sensor.py +++ b/source/isaaclab/test/sensors/test_ray_caster_sensor.py @@ -7,6 +7,8 @@ """Tests for RayCaster sensor behavior: alignment modes and reset.""" +from typing import Literal + from isaaclab.app import AppLauncher simulation_app = AppLauncher(headless=True).app @@ -40,7 +42,7 @@ def _make_sim_and_ground(): return sim -def _ray_caster_cfg(prim_path: str, alignment: str) -> RayCasterCfg: +def _ray_caster_cfg(prim_path: str, alignment: Literal["base", "yaw", "world"]) -> RayCasterCfg: """Single downward ray, no offset from prim.""" return RayCasterCfg( prim_path=prim_path, @@ -257,6 +259,7 @@ def test_ray_caster_reset_resamples_drift(sim_ground): # 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). + drift_after: torch.Tensor = drift_before.clone() for _ in range(5): sensor.reset() drift_after = sensor.drift.clone() @@ -269,3 +272,49 @@ def test_ray_caster_reset_resamples_drift(sim_ground): assert not torch.allclose(drift_after, drift_before), ( "reset() must resample drift; values must change from initial sample" ) + + +@pytest.mark.isaacsim_ci +def test_ray_caster_tracks_physics_body_parent_motion(sim_ground): + """RayCaster pose must follow its physics-body parent after simulation steps.""" + from pxr import UsdGeom, UsdPhysics # noqa: PLC0415 + + sim = sim_ground + dt = 0.01 + parent_path = "/World/PhysicsParent" + + stage = sim_utils.get_current_stage() + sim_utils.create_prim(parent_path, "Xform", translation=(0.0, 0.0, 5.0), stage=stage) + parent_prim = stage.GetPrimAtPath(parent_path) + UsdPhysics.RigidBodyAPI.Apply(parent_prim) + UsdPhysics.ArticulationRootAPI.Apply(parent_prim) + mass_api = UsdPhysics.MassAPI.Apply(parent_prim) + if mass_api is None: + raise RuntimeError(f"Failed to apply MassAPI to {parent_path}.") + mass_api.CreateMassAttr().Set(1.0) + + cube_path = f"{parent_path}/CollisionCube" + cube = UsdGeom.Cube.Define(stage, cube_path) + if cube is None: + raise RuntimeError(f"Failed to create collision cube at {cube_path}.") + cube.CreateSizeAttr().Set(0.1) + UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(cube_path)) + sim_utils.update_stage() + + sensor = RayCaster(_ray_caster_cfg(parent_path, "world")) + sim.reset() + sensor.update(dt, force_recompute=True) + pos_before = sensor.data.pos_w.torch[0].clone() + + for _ in range(100): + sim.step(render=False) + sensor.update(dt) + + sensor.update(dt, force_recompute=True) + pos_after = sensor.data.pos_w.torch[0] + drift_z = (pos_before[2] - pos_after[2]).item() + + assert drift_z > 0.5, ( + f"RayCaster pose did not follow its physics body parent. " + f"z before={pos_before[2].item():.4f} z after={pos_after[2].item():.4f} drift={drift_z:.4f}m." + ) diff --git a/source/isaaclab_newton/changelog.d/octi-raycaster-backend-split.minor.rst b/source/isaaclab_newton/changelog.d/octi-raycaster-backend-split.minor.rst new file mode 100644 index 000000000000..6dedaf9cbd7d --- /dev/null +++ b/source/isaaclab_newton/changelog.d/octi-raycaster-backend-split.minor.rst @@ -0,0 +1,21 @@ +Added +^^^^^ + +* Added Newton backend for :class:`~isaaclab.sensors.ray_caster.RayCaster` / + :class:`~isaaclab.sensors.ray_caster.RayCasterCamera` / + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster` / + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`. Site-based, + matching :class:`~isaaclab_newton.sensors.pva.Pva` and + :class:`~isaaclab_newton.sensors.frame_transformer.FrameTransformer`: + registers body-attached sites via + :meth:`~isaaclab_newton.physics.NewtonManager.cl_register_site` for both + the sensor frame and any tracked target meshes, and reads per-step + transforms off :class:`~newton.sensors.SensorFrameTransform` against a + world-origin reference. Static parents/targets bypass the site + machinery and serve cached per-env ``wp.transformf`` arrays. + +Changed +^^^^^^^ + +* Changed Newton tracked target mesh updates to copy site poses directly into + Warp mesh pose tables instead of staging through torch views. diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sensors/__init__.pyi index e536b281952b..3cc07b029af0 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sensors/__init__.pyi @@ -15,6 +15,10 @@ __all__ = [ "JointWrenchSensorData", "Pva", "PvaData", + "MultiMeshRayCaster", + "MultiMeshRayCasterCamera", + "RayCaster", + "RayCasterCamera", ] from .contact_sensor import ContactSensor, ContactSensorData, ContactSensorCfg @@ -22,3 +26,4 @@ from .frame_transformer import FrameTransformer, FrameTransformerData from .imu import Imu, ImuData from .joint_wrench import JointWrenchSensor, JointWrenchSensorData from .pva import Pva, PvaData +from .ray_caster import MultiMeshRayCaster, MultiMeshRayCasterCamera, RayCaster, RayCasterCamera diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/__init__.py b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/__init__.py new file mode 100644 index 000000000000..52c309ac9386 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/__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 + +"""Sub-module for Newton ray-caster sensor.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/__init__.pyi new file mode 100644 index 000000000000..7562409a7e94 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/__init__.pyi @@ -0,0 +1,16 @@ +# 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__ = [ + "MultiMeshRayCaster", + "MultiMeshRayCasterCamera", + "RayCaster", + "RayCasterCamera", +] + +from .multi_mesh_ray_caster import MultiMeshRayCaster +from .multi_mesh_ray_caster_camera import MultiMeshRayCasterCamera +from .ray_caster import RayCaster +from .ray_caster_camera import RayCasterCamera diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/multi_mesh_ray_caster.py b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/multi_mesh_ray_caster.py new file mode 100644 index 000000000000..74d18c56c435 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/multi_mesh_ray_caster.py @@ -0,0 +1,14 @@ +# 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 + +from isaaclab.sensors.ray_caster.base_multi_mesh_ray_caster import BaseMultiMeshRayCaster + +from .ray_caster import _NewtonRayCasterMixin + + +class MultiMeshRayCaster(_NewtonRayCasterMixin, BaseMultiMeshRayCaster): + """Newton MultiMeshRayCaster implementation.""" diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/multi_mesh_ray_caster_camera.py b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/multi_mesh_ray_caster_camera.py new file mode 100644 index 000000000000..3be4ca93fd6c --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/multi_mesh_ray_caster_camera.py @@ -0,0 +1,14 @@ +# 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 + +from isaaclab.sensors.ray_caster.base_multi_mesh_ray_caster_camera import BaseMultiMeshRayCasterCamera + +from .ray_caster import _NewtonRayCasterMixin + + +class MultiMeshRayCasterCamera(_NewtonRayCasterMixin, BaseMultiMeshRayCasterCamera): + """Newton MultiMeshRayCasterCamera implementation.""" diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/ray_caster.py b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/ray_caster.py new file mode 100644 index 000000000000..e10076d884c8 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/ray_caster.py @@ -0,0 +1,312 @@ +# 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 + +# pyright: reportInvalidTypeForm=none, reportPrivateUsage=none +import re +from typing import Any + +import numpy as np +import warp as wp + +from pxr import UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sensors.ray_caster.base_ray_caster import BaseRayCaster +from isaaclab.sensors.ray_caster.kernels import ( + ALIGNMENT_BASE, + copy_mesh_poses_to_table_kernel, + update_ray_caster_kernel, +) +from isaaclab.utils.warp import ProxyArray + +from isaaclab_newton.physics import NewtonManager + + +@wp.kernel +def _newton_site_world_poses_kernel( + site_indices: wp.array(dtype=wp.int32), + shape_body: wp.array(dtype=wp.int32), + shape_transform: wp.array(dtype=wp.transform), + body_q: wp.array(dtype=wp.transform), + out_pose: wp.array(dtype=wp.transformf), + out_pos: wp.array(dtype=wp.vec3f), + out_quat: wp.array(dtype=wp.quatf), +): + i = wp.tid() + site_idx = site_indices[i] + body_idx = shape_body[site_idx] + site_xform = shape_transform[site_idx] + if body_idx == -1: + world_xform = site_xform + else: + world_xform = wp.transform_multiply(body_q[body_idx], site_xform) + out_pose[i] = world_xform + out_pos[i] = wp.transform_get_translation(world_xform) + out_quat[i] = wp.transform_get_rotation(world_xform) + + +@wp.kernel +def _gather_pose_by_index_kernel( + indices: wp.array(dtype=wp.int32), + pos_src: wp.array(dtype=wp.vec3f), + quat_src: wp.array(dtype=wp.quatf), + pos_dst: wp.array(dtype=wp.vec3f), + quat_dst: wp.array(dtype=wp.quatf), +): + i = wp.tid() + src_idx = indices[i] + pos_dst[i] = pos_src[src_idx] + quat_dst[i] = quat_src[src_idx] + + +def _find_physics_ancestor(prim): + """Return the nearest rigid-body ancestor for a sensor or target prim.""" + ancestor = prim + while ancestor and ancestor.IsValid() and ancestor.GetPath().pathString != "/": + if ancestor.HasAPI(UsdPhysics.RigidBodyAPI): + return ancestor + ancestor = ancestor.GetParent() + return None + + +def _newton_body_pattern(body_path: str) -> str: + """Convert a concrete env index to a regex wildcard for prototype body matching.""" + body_path = body_path.replace("{}", ".*") + return re.sub(r"^(/World/envs/)env_\d+/", r"\1env_.*/", body_path) + + +def _identity_offsets(count: int, device: str) -> tuple[wp.array, wp.array]: + """Create identity sensor offsets for site poses that already include the offset.""" + offset_pos_wp = wp.zeros(count, dtype=wp.vec3f, device=device) + identity_quat = np.zeros((count, 4), dtype=np.float32) + identity_quat[:, 3] = 1.0 + return offset_pos_wp, wp.array(identity_quat, dtype=wp.quatf, device=device) + + +class _NewtonRayCasterMixin: + """Newton site registration and pose tracking for ray-caster sensors. + + Sites must be registered during construction so Newton can inject them into + prototype builders before cloning. Once physics is ready, the mixin resolves + those labels to concrete site indices and updates the sensor-owned buffers + directly from Newton model/state arrays. + """ + + @property + def count(self: Any) -> int: + """Number of resolved Newton sites tracked as sensor frames.""" + return self._view_count + + def __init__(self: Any, cfg): + """Register sensor and dynamic target sites before cloning occurs.""" + super().__init__(cfg) # pyright: ignore[reportCallIssue] + self._sensor_site_labels = self._register_sites_for_expr(self.cfg.prim_path) + self._tracked_site_labels_by_expr: dict[str | tuple[str, ...], list[str]] = {} + for target_cfg in getattr(self, "_raycast_targets_cfg", []): + if target_cfg.track_mesh_transforms: + owner_exprs = self._resolve_target_owner_exprs(target_cfg.prim_expr) + labels = self._register_target_sites_for_exprs(owner_exprs) + self._tracked_site_labels_by_expr[target_cfg.prim_expr] = labels + self._tracked_site_labels_by_expr[tuple(owner_exprs)] = labels + + def _register_sites_for_expr(self, prim_expr: str) -> list[str]: + """Register Newton sites for a prim expression and return site labels.""" + prims = sim_utils.find_matching_prims(prim_expr) + labels: list[str] = [] + if len(prims) == 0: + identity = wp.transform(wp.vec3(0.0, 0.0, 0.0), wp.quat(0.0, 0.0, 0.0, 1.0)) + return [NewtonManager.cl_register_site(_newton_body_pattern(prim_expr), identity)] + + for prim in prims: + body = _find_physics_ancestor(prim) + if body is None: + pos, quat = sim_utils.resolve_prim_pose(prim) + xform = wp.transform(wp.vec3(*[float(v) for v in pos]), wp.quat(*[float(v) for v in quat])) + labels.append(NewtonManager.cl_register_site(None, xform)) + else: + pos, quat = sim_utils.resolve_prim_pose(prim, body) + xform = wp.transform(wp.vec3(*[float(v) for v in pos]), wp.quat(*[float(v) for v in quat])) + labels.append(NewtonManager.cl_register_site(_newton_body_pattern(str(body.GetPath())), xform)) + # Keep the first copy of each label; cloned envs can report the same prototype site more than once. + return list(dict.fromkeys(labels)) + + def _resolve_target_owner_exprs(self, prim_expr: str) -> list[str]: + """Resolve mesh target expressions to owning rigid-body expressions.""" + prims = sim_utils.find_matching_prims(prim_expr) + if len(prims) == 0: + return [_newton_body_pattern(prim_expr)] + + owner_exprs: list[str] = [] + for prim in prims: + body = _find_physics_ancestor(prim) + if body is None: + raise RuntimeError( + f"Cannot track non-physics ray-cast target '{prim_expr}' with Newton. " + "Set track_mesh_transforms=False for static targets, or apply RigidBodyAPI to dynamic targets." + ) + owner_exprs.append(_newton_body_pattern(str(body.GetPath()))) + return list(dict.fromkeys(owner_exprs)) + + def _register_target_sites_for_exprs(self, owner_exprs: list[str]) -> list[str]: + """Register identity-pose Newton sites on target owner bodies.""" + identity = wp.transform(wp.vec3(0.0, 0.0, 0.0), wp.quat(0.0, 0.0, 0.0, 1.0)) + labels = [NewtonManager.cl_register_site(owner_expr, identity) for owner_expr in owner_exprs] + return list(dict.fromkeys(labels)) + + def _initialize_pose_tracking(self: Any) -> None: + """Resolve registered site labels and allocate sensor-owned pose buffers.""" + site_indices = self._resolve_site_indices(self._sensor_site_labels, self.cfg.prim_path, self._num_envs) + # The base classes still use ``self._view.count`` in a few generic + # places. Point it at the sensor instead of constructing an adapter. + self._view = self + self._view_count = len(site_indices) + self._sensor_site_indices = wp.array(site_indices, dtype=wp.int32, device=self._device) + self._newton_pose_w = wp.empty(self._view_count, dtype=wp.transformf, device=self._device) + self._newton_pos_w = ProxyArray(wp.empty(self._view_count, dtype=wp.vec3f, device=self._device)) + self._newton_quat_w = ProxyArray(wp.empty(self._view_count, dtype=wp.quatf, device=self._device)) + self._offset_pos_wp, self._offset_quat_wp = _identity_offsets(self._view_count, self._device) + + def _update_ray_infos(self: Any, env_mask: wp.array): + """Update Newton site poses and transform local rays in a single ray-caster kernel.""" + self._update_newton_site_transforms( + self._sensor_site_indices, self._newton_pose_w, self._newton_pos_w.warp, self._newton_quat_w.warp + ) + pos_w = self._data.pos_w.warp + quat_w = self._data.quat_w_world.warp if hasattr(self._data, "quat_w_world") else self._data.quat_w.warp + ray_starts = self.ray_starts.warp if hasattr(self.ray_starts, "warp") else self._ray_starts_local + ray_directions = ( + self.ray_directions.warp if hasattr(self.ray_directions, "warp") else self._ray_directions_local + ) + alignment_mode = int(ALIGNMENT_BASE) if hasattr(self._data, "quat_w_world") else self._alignment_mode + wp.launch( + update_ray_caster_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[ + self._newton_pose_w, + env_mask, + self._offset_pos_wp, + self._offset_quat_wp, + self.drift.warp, + self.ray_cast_drift.warp, + ray_starts, + ray_directions, + alignment_mode, + ], + outputs=[ + pos_w, + quat_w, + self._ray_starts_w, + self._ray_directions_w, + ], + device=self._device, + ) + + def get_world_poses(self: Any, indices=None): + """Return world poses for camera helpers that still use pose tuples.""" + self._update_newton_site_transforms( + self._sensor_site_indices, self._newton_pose_w, self._newton_pos_w.warp, self._newton_quat_w.warp + ) + if indices is None: + return self._newton_pos_w, self._newton_quat_w + if not isinstance(indices, wp.array): + indices = wp.array(indices, dtype=wp.int32, device=self._device) + pos_w = wp.empty(indices.shape[0], dtype=wp.vec3f, device=self._device) + quat_w = wp.empty(indices.shape[0], dtype=wp.quatf, device=self._device) + wp.launch( + _gather_pose_by_index_kernel, + dim=indices.shape[0], + inputs=[indices, self._newton_pos_w.warp, self._newton_quat_w.warp], + outputs=[pos_w, quat_w], + device=self._device, + ) + return ProxyArray(pos_w), ProxyArray(quat_w) + + def _create_tracked_target_view(self: Any, target_prim_path: str | list[str]): + """Resolve dynamic multi-mesh target sites to raw Newton site indices.""" + target_key = tuple(target_prim_path) if isinstance(target_prim_path, list) else target_prim_path + labels = self._tracked_site_labels_by_expr.get(target_key) + if labels is None: + target_exprs = target_prim_path if isinstance(target_prim_path, list) else [target_prim_path] + labels = self._register_target_sites_for_exprs([_newton_body_pattern(expr) for expr in target_exprs]) + self._tracked_site_labels_by_expr[target_key] = labels + site_indices = self._resolve_site_indices(labels, str(target_prim_path), self._num_envs) + return wp.array(site_indices, dtype=wp.int32, device=self._device) + + def _update_mesh_transforms(self: Any) -> None: + """Refresh dynamic multi-mesh targets directly from Newton sites.""" + if not hasattr(self, "_mesh_views"): + return + mesh_idx = 0 + for site_indices, 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 + + site_count = site_indices.shape[0] + pos_buf = wp.empty(site_count, dtype=wp.vec3f, device=self._device) + quat_buf = wp.empty(site_count, dtype=wp.quatf, device=self._device) + pose_buf = wp.empty(site_count, dtype=wp.transformf, device=self._device) + self._update_newton_site_transforms(site_indices, pose_buf, pos_buf, quat_buf) + meshes_per_env = site_count + if site_count != 1: + # Newton sites arrive as a flat list across envs; the mesh table is indexed per env. + meshes_per_env = site_count // self._num_envs + + wp.launch( + copy_mesh_poses_to_table_kernel, + dim=(self._num_envs, meshes_per_env), + inputs=[ + pos_buf, + quat_buf, + int(meshes_per_env), + int(mesh_idx), + bool(site_count == 1), + self._mesh_positions_w, + self._mesh_orientations_w, + ], + device=self._device, + ) + mesh_idx += self._num_meshes_per_env[target_cfg.prim_expr] + + def _update_newton_site_transforms( + self: Any, + site_indices: wp.array, + pose_buf: wp.array, + pos_buf: wp.array, + quat_buf: wp.array, + ) -> None: + """Launch the Newton site pose kernel into caller-provided buffers.""" + model = NewtonManager._model + state = NewtonManager._state_0 + if model is None or state is None: + raise RuntimeError("Newton simulation state is not initialized.") + wp.launch( + _newton_site_world_poses_kernel, + dim=site_indices.shape[0], + inputs=[site_indices, model.shape_body, model.shape_transform, state.body_q], + outputs=[pose_buf, pos_buf, quat_buf], + device=self._device, + ) + + @staticmethod + def _resolve_site_indices(labels: list[str], prim_expr: str, num_envs: int) -> list[int]: + """Expand registered site labels into per-environment Newton site indices.""" + site_map = NewtonManager._cl_site_index_map + site_indices: list[int] = [] + for env_idx in range(num_envs): + for label in labels: + error_prefix = f"RayCaster target '{prim_expr}' site label '{label}'" + if label not in site_map: + raise ValueError(f"{error_prefix} was not found in NewtonManager._cl_site_index_map.") + global_idx, per_world = site_map[label] + env_site_indices = [global_idx] if per_world is None else per_world[env_idx] + site_indices.extend(env_site_indices) + return site_indices + + +class RayCaster(_NewtonRayCasterMixin, BaseRayCaster): + """Newton ray-caster implementation.""" diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/ray_caster_camera.py b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/ray_caster_camera.py new file mode 100644 index 000000000000..6e8f56fd20c0 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/ray_caster_camera.py @@ -0,0 +1,14 @@ +# 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 + +from isaaclab.sensors.ray_caster.base_ray_caster_camera import BaseRayCasterCamera + +from .ray_caster import _NewtonRayCasterMixin + + +class RayCasterCamera(_NewtonRayCasterMixin, BaseRayCasterCamera): + """Newton RayCasterCamera implementation.""" diff --git a/source/isaaclab_physx/changelog.d/octi-raycaster-backend-split.minor.rst b/source/isaaclab_physx/changelog.d/octi-raycaster-backend-split.minor.rst new file mode 100644 index 000000000000..f31307551274 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/octi-raycaster-backend-split.minor.rst @@ -0,0 +1,26 @@ +Added +^^^^^ + +* Added PhysX backend for :class:`~isaaclab.sensors.ray_caster.RayCaster` / + :class:`~isaaclab.sensors.ray_caster.RayCasterCamera` / + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster` / + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`. Sensor + body and tracked target meshes both run off ``RigidObjectView`` — + per-step compose via small warp kernels, no + :class:`~isaaclab_physx.sim.views.FabricFrameView` path. Static + parents/targets serve cached per-env ``wp.transformf`` arrays. + +Fixed +^^^^^ + +* Fixed all four ray-caster sensors (:class:`~isaaclab.sensors.ray_caster.RayCaster`, + :class:`~isaaclab.sensors.ray_caster.RayCasterCamera`, + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster`, + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`) returning + their spawn-time pose forever when parented under a rigid body. Previous + path went through :class:`~isaaclab_physx.sim.views.FabricFrameView` + which regressed in #5179; the new backend reads body pose directly from + PhysX. The same fix applies to tracked target meshes + (``track_mesh_transforms=True``) parented under rigid bodies. +* Fixed PhysX tracked target mesh updates to write directly into Warp mesh + pose tables instead of staging through torch views. diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sensors/__init__.pyi index e536b281952b..3cc07b029af0 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sensors/__init__.pyi @@ -15,6 +15,10 @@ __all__ = [ "JointWrenchSensorData", "Pva", "PvaData", + "MultiMeshRayCaster", + "MultiMeshRayCasterCamera", + "RayCaster", + "RayCasterCamera", ] from .contact_sensor import ContactSensor, ContactSensorData, ContactSensorCfg @@ -22,3 +26,4 @@ from .frame_transformer import FrameTransformer, FrameTransformerData from .imu import Imu, ImuData from .joint_wrench import JointWrenchSensor, JointWrenchSensorData from .pva import Pva, PvaData +from .ray_caster import MultiMeshRayCaster, MultiMeshRayCasterCamera, RayCaster, RayCasterCamera diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/__init__.py b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/__init__.py new file mode 100644 index 000000000000..60e772b29dc1 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/__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 + +"""Sub-module for PhysX ray-caster sensor.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/__init__.pyi new file mode 100644 index 000000000000..7562409a7e94 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/__init__.pyi @@ -0,0 +1,16 @@ +# 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__ = [ + "MultiMeshRayCaster", + "MultiMeshRayCasterCamera", + "RayCaster", + "RayCasterCamera", +] + +from .multi_mesh_ray_caster import MultiMeshRayCaster +from .multi_mesh_ray_caster_camera import MultiMeshRayCasterCamera +from .ray_caster import RayCaster +from .ray_caster_camera import RayCasterCamera diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/multi_mesh_ray_caster.py b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/multi_mesh_ray_caster.py new file mode 100644 index 000000000000..554bcb41351e --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/multi_mesh_ray_caster.py @@ -0,0 +1,14 @@ +# 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 + +from isaaclab.sensors.ray_caster.base_multi_mesh_ray_caster import BaseMultiMeshRayCaster + +from .ray_caster import _PhysXRayCasterMixin + + +class MultiMeshRayCaster(_PhysXRayCasterMixin, BaseMultiMeshRayCaster): + """PhysX MultiMeshRayCaster implementation.""" diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/multi_mesh_ray_caster_camera.py b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/multi_mesh_ray_caster_camera.py new file mode 100644 index 000000000000..afd5436fa0db --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/multi_mesh_ray_caster_camera.py @@ -0,0 +1,14 @@ +# 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 + +from isaaclab.sensors.ray_caster.base_multi_mesh_ray_caster_camera import BaseMultiMeshRayCasterCamera + +from .ray_caster import _PhysXRayCasterMixin + + +class MultiMeshRayCasterCamera(_PhysXRayCasterMixin, BaseMultiMeshRayCasterCamera): + """PhysX MultiMeshRayCasterCamera implementation.""" diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster.py b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster.py new file mode 100644 index 000000000000..9597abd785d6 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster.py @@ -0,0 +1,210 @@ +# 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 + +from types import SimpleNamespace +from typing import Any + +import torch +import warp as wp + +from pxr import UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sensors.ray_caster.base_ray_caster import BaseRayCaster +from isaaclab.sensors.ray_caster.kernels import copy_mesh_transforms_to_table_kernel + +from isaaclab_physx.physics import PhysxManager + + +def _find_physics_ancestor(prim): + """Return the nearest rigid-body ancestor for a sensor or target prim.""" + ancestor = prim + while ancestor and ancestor.IsValid() and ancestor.GetPath().pathString != "/": + if ancestor.HasAPI(UsdPhysics.RigidBodyAPI): + return ancestor + ancestor = ancestor.GetParent() + return None + + +def _body_expr_from_sensor_expr(sensor_expr: str, first_sensor_prim, first_body_prim) -> str: + """Convert a sensor/target expression to the matching rigid-body expression.""" + sensor_path = first_sensor_prim.GetPath().pathString + body_path = first_body_prim.GetPath().pathString + if sensor_path == body_path: + return sensor_expr + # Example: ``.../Robot/base/sensor`` target -> ``.../Robot/base`` body view. + suffix = sensor_path[len(body_path) :] + if suffix and sensor_expr.endswith(suffix): + return sensor_expr[: -len(suffix)] + return body_path + + +def _physx_body_glob(body_expr: str) -> str: + """Convert internal env regex/template expressions to PhysX glob syntax.""" + return body_expr.replace("{}", "*").replace(".*", "*") + + +class _PhysXRayCasterMixin: + """PhysX pose tracking for ray-caster sensors. + + PhysX can provide live rigid-body transforms after physics is ready. Static + non-physics prims are cached once at initialization; they are intentionally + not polled through USD during sensor updates. + """ + + @property + def count(self: Any) -> int: + """Number of tracked sensor frames.""" + return self._view_count + + def _initialize_pose_tracking(self: Any) -> None: + """Initialize direct PhysX body tracking or a cached static pose table.""" + prims = sim_utils.find_matching_prims(self.cfg.prim_path) + if len(prims) == 0: + raise RuntimeError(f"No sensor prims matched: {self.cfg.prim_path}") + + # The base classes still use ``self._view.count`` in a few generic + # places. Point it at the sensor instead of constructing an adapter. + self._view = self + body = _find_physics_ancestor(prims[0]) + if body is None: + self._initialize_static_pose_tracking(prims) + return + + requested_prim_path = getattr(self, "_requested_prim_path", self.cfg.prim_path) + # When the public prim path pointed at a rigid body, BaseRayCaster + # spawned a child sensor prim and preserved the original body path. + body_expr = ( + requested_prim_path + if self.cfg.prim_path != requested_prim_path + else _body_expr_from_sensor_expr(self.cfg.prim_path, prims[0], body) + ) + physics_sim_view = PhysxManager.get_physics_sim_view() + if physics_sim_view is None: + raise RuntimeError("PhysX simulation view is not initialized.") + self._physx_body_view = physics_sim_view.create_rigid_body_view(body_expr.replace(".*", "*")) + self._view_count = self._physx_body_view.count + + offset_pos = [] + offset_quat = [] + for prim in prims: + body_prim = _find_physics_ancestor(prim) + p, q = sim_utils.resolve_prim_pose(prim, body_prim) + offset_pos.append(p) + offset_quat.append(q) + if len(offset_pos) == 1 and self._view_count > 1: + offset_pos = offset_pos * self._view_count + offset_quat = offset_quat * self._view_count + self._offset_pos_wp = wp.array(offset_pos[: self._view_count], dtype=wp.vec3f, device=self._device) + self._offset_quat_contiguous = torch.tensor( + offset_quat[: self._view_count], dtype=torch.float32, device=self._device + ) + self._offset_quat_wp = wp.from_torch(self._offset_quat_contiguous, dtype=wp.quatf) + + def _initialize_static_pose_tracking(self: Any, prims) -> None: + """Cache authored poses for non-physics sensor frames.""" + poses = [] + for prim in prims: + pos, quat = sim_utils.resolve_prim_pose(prim) + poses.append((*pos, *quat)) + self._static_view_transforms_torch = torch.tensor(poses, dtype=torch.float32, device=self._device).contiguous() + self._static_view_transforms_wp = wp.from_torch(self._static_view_transforms_torch).view(wp.transformf) + self._physx_body_view = None + self._view_count = len(prims) + 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) + + def _get_view_transforms_wp(self: Any) -> wp.array: + """Return tracked sensor-frame transforms as ``wp.transformf``.""" + if self._physx_body_view is None: + return self._static_view_transforms_wp + transforms = self._physx_body_view.get_transforms() + if isinstance(transforms, wp.array): + return transforms.view(wp.transformf) + return wp.from_torch(transforms.contiguous()).view(wp.transformf) + + def get_world_poses(self: Any, indices=None): + """Return world poses for camera helpers that still use pose tuples.""" + transforms = self._get_view_transforms_wp() + transforms_t = wp.to_torch(transforms).reshape(-1, 7) + if indices is not None: + idx = wp.to_torch(indices).to(dtype=torch.long) if isinstance(indices, wp.array) else indices + transforms_t = transforms_t[idx] + return SimpleNamespace(torch=transforms_t[:, 0:3]), SimpleNamespace(torch=transforms_t[:, 3:7]) + + def _create_tracked_target_view(self: Any, target_prim_paths: str | list[str]): + """Create a PhysX rigid-body view for dynamic multi-mesh targets.""" + if isinstance(target_prim_paths, str): + target_prim_paths = [target_prim_paths] + body_paths = [] + for target_prim_path in target_prim_paths: + prims = sim_utils.find_matching_prims(target_prim_path) + if len(prims) == 0: + # ClonePlan-backed targets may not have destination mesh prims. + # In that case BaseMultiMeshRayCaster passes the destination owner-body expression. + body_paths.append(target_prim_path) + continue + for prim in prims: + body = _find_physics_ancestor(prim) + if body is None: + raise RuntimeError( + f"Cannot track non-physics ray-cast target '{target_prim_path}' with PhysX. " + "Set track_mesh_transforms=False for static targets, or apply RigidBodyAPI to dynamic targets." + ) + body_paths.append(body.GetPath().pathString) + + if len(body_paths) == 0: + raise RuntimeError(f"No tracked target bodies resolved from: {target_prim_paths}") + physics_sim_view = PhysxManager.get_physics_sim_view() + if physics_sim_view is None: + raise RuntimeError("PhysX simulation view is not initialized.") + return physics_sim_view.create_rigid_body_view([_physx_body_glob(path) for path in body_paths]) + + def _update_mesh_transforms(self: Any) -> None: + """Refresh dynamic multi-mesh targets directly from PhysX views.""" + if not hasattr(self, "_mesh_views"): + return + 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 + + transforms = view.get_transforms() + transforms_wp = ( + transforms.view(wp.transformf) + if isinstance(transforms, wp.array) + else wp.from_torch(transforms.contiguous()).view(wp.transformf) + ) + + view_count = view.count + meshes_per_env = view_count + if view_count != 1: + # PhysX views return a flat list across envs; the mesh table is indexed per env. + meshes_per_env = view_count // self._num_envs + + wp.launch( + copy_mesh_transforms_to_table_kernel, + dim=(self._num_envs, meshes_per_env), + inputs=[ + transforms_wp, + int(meshes_per_env), + int(mesh_idx), + bool(view_count == 1), + self._mesh_positions_w, + self._mesh_orientations_w, + ], + device=self._device, + ) + mesh_idx += self._num_meshes_per_env[target_cfg.prim_expr] + + +class RayCaster(_PhysXRayCasterMixin, BaseRayCaster): + """PhysX ray-caster implementation.""" diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster_camera.py b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster_camera.py new file mode 100644 index 000000000000..ff84a3cd037e --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sensors/ray_caster/ray_caster_camera.py @@ -0,0 +1,14 @@ +# 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 + +from isaaclab.sensors.ray_caster.base_ray_caster_camera import BaseRayCasterCamera + +from .ray_caster import _PhysXRayCasterMixin + + +class RayCasterCamera(_PhysXRayCasterMixin, BaseRayCasterCamera): + """PhysX RayCasterCamera implementation.""" diff --git a/source/isaaclab_tasks/changelog.d/octi-raycaster-backend-split.minor.rst b/source/isaaclab_tasks/changelog.d/octi-raycaster-backend-split.minor.rst new file mode 100644 index 000000000000..aecca907c486 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/octi-raycaster-backend-split.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added raycaster-camera depth presets (``raycaster_depth64``, ``raycaster_depth128``, + ``raycaster_depth256``) for both base and wrist views in the Dexsuite Kuka-Allegro + manipulation task, backed by + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`. Targets the table, + ground plane, manipulated object, and robot visuals. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/camera_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/camera_cfg.py index 3ad4be97b0f2..83e4564553e7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/camera_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/camera_cfg.py @@ -9,7 +9,7 @@ from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg -from isaaclab.sensors import CameraCfg +from isaaclab.sensors import CameraCfg, MultiMeshRayCasterCameraCfg, patterns from isaaclab.utils.configclass import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise @@ -50,6 +50,54 @@ renderer_cfg=MultiBackendRendererCfg(), ) +RAY_PATTERN = patterns.PinholeCameraPatternCfg(focal_length=24.0, horizontal_aperture=20.955) + +RAYCASTER_CAMERA_MESH_PRIM_PATHS = [ + MultiMeshRayCasterCameraCfg.RaycastTargetCfg( + prim_expr="/World/envs/env_.*/table", + track_mesh_transforms=False, + ), + MultiMeshRayCasterCameraCfg.RaycastTargetCfg( + prim_expr="/World/GroundPlane", + track_mesh_transforms=False, + ), + MultiMeshRayCasterCameraCfg.RaycastTargetCfg( + prim_expr="/World/envs/env_.*/Object", + track_mesh_transforms=True, + ), + MultiMeshRayCasterCameraCfg.RaycastTargetCfg( + prim_expr="/World/envs/env_.*/Robot/.*/visuals", + track_mesh_transforms=True, + ), +] + +BASE_RAYCASTER_CAMERA_CFG = MultiMeshRayCasterCameraCfg( + prim_path="/World/envs/env_.*/Camera", + offset=MultiMeshRayCasterCameraCfg.OffsetCfg( + pos=(0.57, -0.8, 0.5), + rot=(0.6124, 0.3536, 0.3536, 0.6124), + convention="opengl", + ), + mesh_prim_paths=RAYCASTER_CAMERA_MESH_PRIM_PATHS, + max_distance=2.5, + data_types=["distance_to_image_plane"], + pattern_cfg=MISSING, +) + +WRIST_RAYCASTER_CAMERA_CFG = MultiMeshRayCasterCameraCfg( + prim_path="/World/envs/env_.*/Robot/ee_link/palm_link/Camera", + offset=MultiMeshRayCasterCameraCfg.OffsetCfg( + pos=(0.038, -0.38, -0.18), + rot=(0.641, 0.641, -0.299, 0.299), + convention="opengl", + ), + mesh_prim_paths=RAYCASTER_CAMERA_MESH_PRIM_PATHS, + max_distance=2.5, + data_types=["distance_to_image_plane"], + pattern_cfg=MISSING, + debug_vis=False, +) + @configclass class BaseTiledCameraCfg(PresetCfg): @@ -88,6 +136,10 @@ class BaseTiledCameraCfg(PresetCfg): semantic_segmentation64 = BASE_CAMERA_CFG.replace(data_types=["semantic_segmentation"], width=64, height=64) semantic_segmentation128 = BASE_CAMERA_CFG.replace(data_types=["semantic_segmentation"], width=128, height=128) semantic_segmentation256 = BASE_CAMERA_CFG.replace(data_types=["semantic_segmentation"], width=256, height=256) + # raycaster camera presets + raycaster_depth64 = BASE_RAYCASTER_CAMERA_CFG.replace(pattern_cfg=RAY_PATTERN.replace(width=64, height=64)) + raycaster_depth128 = BASE_RAYCASTER_CAMERA_CFG.replace(pattern_cfg=RAY_PATTERN.replace(width=128, height=128)) + raycaster_depth256 = BASE_RAYCASTER_CAMERA_CFG.replace(pattern_cfg=RAY_PATTERN.replace(width=256, height=256)) default = rgb64 @@ -128,6 +180,10 @@ class WristTiledCameraCfg(PresetCfg): semantic_segmentation64 = WRIST_CAMERA_CFG.replace(data_types=["semantic_segmentation"], width=64, height=64) semantic_segmentation128 = WRIST_CAMERA_CFG.replace(data_types=["semantic_segmentation"], width=128, height=128) semantic_segmentation256 = WRIST_CAMERA_CFG.replace(data_types=["semantic_segmentation"], width=256, height=256) + # raycaster camera presets + raycaster_depth64 = WRIST_RAYCASTER_CAMERA_CFG.replace(pattern_cfg=RAY_PATTERN.replace(width=64, height=64)) + raycaster_depth128 = WRIST_RAYCASTER_CAMERA_CFG.replace(pattern_cfg=RAY_PATTERN.replace(width=128, height=128)) + raycaster_depth256 = WRIST_RAYCASTER_CAMERA_CFG.replace(pattern_cfg=RAY_PATTERN.replace(width=256, height=256)) default = rgb64 diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py index 06faa0c25985..eeea53b48415 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/dexsuite_env_cfg.py @@ -476,7 +476,7 @@ def validate_config(self): cam = getattr(self.scene, cam_attr, None) if cam is None: continue - renderer_type = getattr(cam.renderer_cfg, "renderer_type", None) + renderer_type = getattr(getattr(cam, "renderer_cfg", None), "renderer_type", None) if renderer_type == "newton_warp": unsupported = set(cam.data_types) - warp_supported if unsupported: From 4f0e8c8f81bcb3579f78865797b4662bf60d17f4 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Sat, 16 May 2026 21:54:58 -0700 Subject: [PATCH 091/133] Updates ecosystem docs (#5581) # Description Update ecosystem documentation to reflect the latest multi-backend setup. ## 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 --- .github/workflows/wheel.yml | 100 ++++++- docs/index.rst | 1 + docs/source/_static/setup/ecosystem-dark.jpg | Bin 182687 -> 0 bytes docs/source/_static/setup/ecosystem-dark.svg | 93 ++++++ docs/source/_static/setup/ecosystem-light.jpg | Bin 176206 -> 0 bytes docs/source/_static/setup/ecosystem-light.svg | 94 ++++++ .../visuo_tactile_sensor.rst | 8 +- .../overview/core-concepts/sensors/index.rst | 1 - .../developer-guide/repo_structure.rst | 131 +++++--- docs/source/setup/ecosystem.rst | 281 +++++++++++------- 10 files changed, 547 insertions(+), 162 deletions(-) delete mode 100644 docs/source/_static/setup/ecosystem-dark.jpg create mode 100644 docs/source/_static/setup/ecosystem-dark.svg delete mode 100644 docs/source/_static/setup/ecosystem-light.jpg create mode 100644 docs/source/_static/setup/ecosystem-light.svg rename docs/source/{overview/core-concepts/sensors => experimental-features}/visuo_tactile_sensor.rst (97%) diff --git a/.github/workflows/wheel.yml b/.github/workflows/wheel.yml index 0cad2933e949..841d395afa32 100644 --- a/.github/workflows/wheel.yml +++ b/.github/workflows/wheel.yml @@ -8,12 +8,6 @@ name: Build PIP Wheel on: pull_request: types: [opened, synchronize, reopened] - paths: - - 'apps/**' - - 'VERSION' - - 'source/**' - - 'tools/wheel_builder/**' - - '.github/workflows/wheel.yml' push: branches: - main @@ -26,6 +20,7 @@ concurrency: permissions: contents: read + pull-requests: read jobs: build-wheel: @@ -34,13 +29,103 @@ jobs: timeout-minutes: 5 steps: + - name: Detect wheel-relevant changes + id: changes + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # Keep this workflow unconditionally triggered on PRs so required + # branch-protection checks are always reported. The build steps below + # run only when inputs that can affect the wheel have changed. + patterns=( + $'^apps/\tStandalone apps packaged in the wheel' + $'^VERSION$\tPackage version' + $'^source/\tPython packages' + $'^tools/wheel_builder/\tWheel build tooling' + $'^\.github/workflows/wheel\.yml$\tThis workflow file' + ) + + render_table() { + local files="$1" entry regex desc count sample + echo "| Pattern | What it covers | Matched files |" + echo "|---|---|---|" + for entry in "${patterns[@]}"; do + IFS=$'\t' read -r regex desc <<< "$entry" + count=$(printf '%s\n' "$files" | grep -cE "$regex" || true) + if [ "$count" -gt 0 ]; then + sample=$(printf '%s\n' "$files" | grep -E "$regex" | head -3 | paste -sd ', ' -) + [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" + echo "| \`$regex\` | $desc | $sample |" + else + echo "| \`$regex\` | $desc | - |" + fi + done + } + + any_match() { + local files="$1" entry regex + for entry in "${patterns[@]}"; do + IFS=$'\t' read -r regex _ <<< "$entry" + if printf '%s\n' "$files" | grep -qE "$regex"; then + return 0 + fi + done + return 1 + } + + decide() { + local decision="$1" reason="$2" files="${3:-}" + echo "run_build=$decision" >> "$GITHUB_OUTPUT" + { + echo "## Wheel build gating" + echo "" + if [ "$decision" = "true" ]; then + echo "The wheel build will **run**: $reason." + else + echo "The wheel build will be **skipped**: $reason." + fi + if [ -n "$files" ]; then + echo "" + render_table "$files" + fi + } >> "$GITHUB_STEP_SUMMARY" + } + + if [ "$EVENT_NAME" != "pull_request" ]; then + decide true "non-PR event ($EVENT_NAME)" + exit 0 + fi + + if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then + echo "::warning::Could not list changed files; defaulting to building the wheel" + decide true "fail-safe (could not list changed files)" + exit 0 + fi + + if any_match "$changed_files"; then + decide true "wheel-relevant paths changed" "$changed_files" + else + decide false "no wheel-relevant paths changed" "$changed_files" + fi + + - name: Skip wheel build + if: steps.changes.outputs.run_build == 'false' + run: echo "Skipping wheel build because this PR does not change wheel inputs." + - name: Checkout code + if: steps.changes.outputs.run_build == 'true' uses: actions/checkout@v6 with: fetch-depth: 1 lfs: true - name: Setup Python + if: steps.changes.outputs.run_build == 'true' uses: actions/setup-python@v5 with: python-version: "3.12" @@ -51,6 +136,7 @@ jobs: # isaaclab--build-. The wheel inside follows # PEP 440 (VERSION+buildN.SHA7) since pip requires that format. - name: Compute wheel metadata + if: steps.changes.outputs.run_build == 'true' id: meta run: | set -euo pipefail @@ -59,12 +145,14 @@ jobs: echo "artifact_name=isaaclab-${version}-build${{ github.run_number }}-${sha_slug}" >> "$GITHUB_OUTPUT" - name: Build wheel + if: steps.changes.outputs.run_build == 'true' env: WHEEL_BUILD_NUMBER: ${{ github.run_number }} WHEEL_SHA: ${{ github.sha }} run: bash tools/wheel_builder/build.sh - name: Upload wheel artifact + if: steps.changes.outputs.run_build == 'true' uses: actions/upload-artifact@v7 with: name: ${{ steps.meta.outputs.artifact_name }} diff --git a/docs/index.rst b/docs/index.rst index 6c8e6ba9ac41..d3218514afb7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -143,6 +143,7 @@ Table of Contents source/experimental-features/bleeding-edge source/experimental-features/newton-physics-integration/index + source/experimental-features/visuo_tactile_sensor .. toctree:: :maxdepth: 1 diff --git a/docs/source/_static/setup/ecosystem-dark.jpg b/docs/source/_static/setup/ecosystem-dark.jpg deleted file mode 100644 index 9057aeb48b298936775fa6e6e3b38412ce871aa3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 182687 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

-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 Q_AmdV^bM(@CHVg)0A2d>p8x;= diff --git a/docs/source/_static/setup/ecosystem-dark.svg b/docs/source/_static/setup/ecosystem-dark.svg new file mode 100644 index 000000000000..a09f1d0c5036 --- /dev/null +++ b/docs/source/_static/setup/ecosystem-dark.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + Learning Libraries + RSL-RL · skrl · Stable Baselines 3 · RL Games + + + + Isaac Lab Extensions + + isaaclab_tasks + pre-built environments + + isaaclab_assets + robot & sensor configs + + isaaclab_rl + RL library wrappers + + isaaclab_mimic + imitation learning + + isaaclab_teleop + teleoperation & XR + + + + Isaac Lab Core (isaaclab) + sim · scene · assets · sensors · envs · managers · actuators + controllers · terrains · devices · mdp · utils · markers · renderers + ManagerBasedRLEnv · DirectRLEnv · DirectMARLEnv · InteractiveScene + factory pattern: uniform API dispatches to the active physics backend at runtime + + + + + + isaaclab_physx · isaaclab_ovphysx · isaaclab_ov + Articulation · Rigid Body · Rigid Object Collection · Deformable + Fabric Views · Camera · USD spawners + Isaac RTX Renderer · OvRTX Renderer + + + + isaaclab_newton + Articulation · Rigid Body · Rigid Object Collection + Camera · USD spawners · Warp Renderer + MJWarp Solver · VBD Solver + + + + + + Isaac Sim (optional) + PhysX · RTX Rendering · USD / Omniverse + ROS / ROS 2 · URDF & MJCF importers + required for Isaac Sim features + + + + Newton Physics + Warp + GPU-parallel simulation · Warp kernels + MJWarp (MuJoCo-Warp) solver + kit-less · no Isaac Sim required + + + + NVIDIA GPU Platform + CUDA · Warp (required by all backends) + diff --git a/docs/source/_static/setup/ecosystem-light.jpg b/docs/source/_static/setup/ecosystem-light.jpg deleted file mode 100644 index 5c2bbf5fe151ee7d701d8e98209311d64ba2f54d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176206 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 O$MnZGkL7K?_5Lqf@f30Z diff --git a/docs/source/_static/setup/ecosystem-light.svg b/docs/source/_static/setup/ecosystem-light.svg new file mode 100644 index 000000000000..cd9b1b98fb37 --- /dev/null +++ b/docs/source/_static/setup/ecosystem-light.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + Learning Libraries + RSL-RL · skrl · Stable Baselines 3 · RL Games + + + + Isaac Lab Extensions + + isaaclab_tasks + pre-built environments + + isaaclab_assets + robot & sensor configs + + isaaclab_rl + RL library wrappers + + isaaclab_mimic + imitation learning + + isaaclab_teleop + teleoperation & XR + + + + Isaac Lab Core (isaaclab) + sim · scene · assets · sensors · envs · managers · actuators + controllers · terrains · devices · mdp · utils · markers · renderers + ManagerBasedRLEnv · DirectRLEnv · DirectMARLEnv · InteractiveScene + factory pattern: uniform API dispatches to the active physics backend at runtime + + + + + + isaaclab_physx · isaaclab_ovphysx · isaaclab_ov + Articulation · Rigid Body · Rigid Object Collection · Deformable + Fabric Views · Camera · USD spawners + Isaac RTX Renderer · OvRTX Renderer + + + + isaaclab_newton + Articulation · Rigid Body · Rigid Object Collection + Camera · USD spawners · Warp Renderer + MJWarp Solver · VBD Solver + + + + + + Isaac Sim (optional) + PhysX · RTX Rendering · USD / Omniverse + ROS / ROS 2 · URDF & MJCF importers + required for Isaac Sim features + + + + Newton Physics + Warp + GPU-parallel simulation · Warp kernels + MJWarp (MuJoCo-Warp) solver + kit-less · no Isaac Sim required + + + + NVIDIA GPU Platform + CUDA · Warp (required by all backends) + diff --git a/docs/source/overview/core-concepts/sensors/visuo_tactile_sensor.rst b/docs/source/experimental-features/visuo_tactile_sensor.rst similarity index 97% rename from docs/source/overview/core-concepts/sensors/visuo_tactile_sensor.rst rename to docs/source/experimental-features/visuo_tactile_sensor.rst index e1b389e45c1c..4e3d3897c6ab 100644 --- a/docs/source/overview/core-concepts/sensors/visuo_tactile_sensor.rst +++ b/docs/source/experimental-features/visuo_tactile_sensor.rst @@ -9,7 +9,7 @@ Visuo-Tactile Sensor The visuo-tactile sensor in Isaac Lab provides realistic tactile feedback through integration with TacSL (Tactile Sensor Learning) [Akinola2025]_. It is designed to simulate high-fidelity tactile interactions, generating both visual and force-based data that mirror real-world tactile sensors like GelSight devices. The sensor can provide tactile RGB images, force field distributions, and other intermediate tactile measurements essential for robotic manipulation tasks requiring fine tactile feedback. -.. figure:: ../../../_static/overview/sensors/tacsl_diagram.jpg +.. figure:: ../_static/overview/sensors/tacsl_diagram.jpg :align: center :figwidth: 100% :alt: Tactile sensor with RGB visualization and force fields @@ -142,7 +142,7 @@ For a complete list of available options: .. note:: The demo examples are based on the Gelsight R1.5, which is a prototype sensor that is now discontinued. The same procedure can be adapted for other visuotactile sensors. -.. figure:: ../../../_static/overview/sensors/tacsl_demo.jpg +.. figure:: ../_static/overview/sensors/tacsl_demo.jpg :align: center :figwidth: 100% :alt: TacSL tactile sensor demo showing RGB tactile images and force field visualizations @@ -163,12 +163,12 @@ Output Tactile Data :widths: 50 50 :class: borderless - * - .. figure:: ../../../_static/overview/sensors/tacsl_taxim_example.jpg + * - .. figure:: ../_static/overview/sensors/tacsl_taxim_example.jpg :align: center :figwidth: 80% :alt: Tactile output with RGB visualization - - .. figure:: ../../../_static/overview/sensors/tacsl_force_field_example.jpg + - .. figure:: ../_static/overview/sensors/tacsl_force_field_example.jpg :align: center :figwidth: 80% :alt: Tactile output with force field visualization diff --git a/docs/source/overview/core-concepts/sensors/index.rst b/docs/source/overview/core-concepts/sensors/index.rst index f32923ab9c30..1ecae8e3ccdf 100644 --- a/docs/source/overview/core-concepts/sensors/index.rst +++ b/docs/source/overview/core-concepts/sensors/index.rst @@ -21,4 +21,3 @@ The following pages describe the available sensors in more detail: pva joint_wrench_sensor ray_caster - visuo_tactile_sensor diff --git a/docs/source/overview/developer-guide/repo_structure.rst b/docs/source/overview/developer-guide/repo_structure.rst index d8a5a1b200a5..92fd0f90dcdd 100644 --- a/docs/source/overview/developer-guide/repo_structure.rst +++ b/docs/source/overview/developer-guide/repo_structure.rst @@ -15,56 +15,113 @@ Repository organization ├── docs ├── docker ├── source - │   ├── isaaclab - │   ├── isaaclab_assets - │   ├── isaaclab_mimic - │   ├── isaaclab_rl - │   └── isaaclab_tasks + │ ├── isaaclab # core framework + │ ├── isaaclab_physx # PhysX backend (requires Isaac Sim) + │ ├── isaaclab_ovphysx # standalone PhysX backend (requires ovphysx) + │ ├── isaaclab_ov # OVRTX renderer backend (requires ovrtx) + │ ├── isaaclab_newton # Newton backend (kit-less) + │ ├── isaaclab_assets # pre-configured robot & sensor assets + │ ├── isaaclab_tasks # pre-built RL/IL environments + │ ├── isaaclab_tasks_experimental # Warp-accelerated environments + │ ├── isaaclab_rl # RL library wrappers + │ ├── isaaclab_mimic # imitation learning & data generation + │ ├── isaaclab_teleop # teleoperation & XR + │ ├── isaaclab_visualizers # external visualizer backends + │ ├── isaaclab_contrib # community-contributed extensions + │ ├── isaaclab_experimental # Warp-accelerated manager and environment variants + │ ├── extensions # legacy Omniverse extension wrappers + │ └── standalone # standalone tutorials & workflows ├── scripts - │   ├── benchmarks - │   ├── demos - │   ├── environments - │   ├── imitation_learning - │   ├── reinforcement_learning - │   ├── tools - │   ├── tutorials + │ ├── benchmarks + │ ├── demos + │ ├── environments + │ ├── imitation_learning + │ ├── reinforcement_learning + │ ├── sim2sim_transfer + │ ├── tools + │ └── tutorials ├── tools └── VERSION -Isaac Lab is built on the same back end as Isaac Sim. As such, it exists as a collection of **extensions** that can be assembled into **applications**. -The ``source`` directory contains the majority of the code in the repository and the specific extensions that compose Isaac lab, while ``scripts`` containing python scripts for launching customized standalone apps (Like our workflows). -These are the two primary ways of interacting with the simulation and Isaac lab supports both! -Checkout this `Isaac Sim introduction to workflows `__ for more details. +Isaac Lab supports the **PhysX** and **Newton** physics engines through backend packages. The +default PhysX path runs through Isaac Sim, while ``ovphysx`` supports standalone PhysX workflows +without launching Isaac Sim and Newton provides a Warp-native kit-less backend. The ``source`` +directory contains all packages that compose Isaac Lab, while ``scripts`` contains standalone +Python applications for training, evaluation, and tooling. +See :doc:`/source/overview/core-concepts/multi_backend_architecture` for details on the backend +system, and :doc:`/source/setup/ecosystem` for a full package-layer overview. -Extensions +Submodules ~~~~~~~~~~ -The extensions that compose Isaac Lab are kept in the ``source`` directory. To simplify the build process, Isaac Lab directly use `setuptools `__. It is strongly recommend that you adhere to this process if you create your own extensions using Isaac Lab. +The packages under ``source/`` are installed as Python packages using +`setuptools `__. They are organized into three +groups: -The extensions are organized as follows: +**Core, physics backends, and renderers** -* **isaaclab**: Contains the core interface extension for Isaac Lab. This provides the main modules for actuators, - objects, robots and sensors. -* **isaaclab_assets**: Contains the extension with pre-configured assets for Isaac Lab. -* **isaaclab_tasks**: Contains the extension with pre-configured environments for Isaac Lab. -* **isaaclab_mimic**: Contains APIs and pre-configured environments for data generation for imitation learning. -* **isaaclab_rl**: Contains wrappers for using the above environments with different reinforcement learning agents. +* **isaaclab**: The core framework. Provides :mod:`~isaaclab.sim` (simulation context and + configuration), :class:`~isaaclab.scene.InteractiveScene`, asset and sensor base classes and + factory interfaces (:mod:`~isaaclab.assets`, :mod:`~isaaclab.sensors`), environment base + classes (:mod:`~isaaclab.envs`), the manager system (:mod:`~isaaclab.managers`), composable + MDP term library (:mod:`~isaaclab.envs.mdp`), actuator models (:mod:`~isaaclab.actuators`), + low-level controllers (:mod:`~isaaclab.controllers`), procedural terrain generation + (:mod:`~isaaclab.terrains`), and human-input device support (:mod:`~isaaclab.devices`). +* **isaaclab_physx**: PhysX-backed implementations of articulations, rigid bodies, deformable + objects, Fabric views, the Isaac RTX renderer, and USD spawners. Requires Isaac Sim. +* **isaaclab_ovphysx**: Standalone PhysX backend variant using ``ovphysx`` and the + TensorBindingsAPI. Requires the ``ovphysx`` package and can run without launching Isaac Sim. +* **isaaclab_ov**: OVRTX renderer backend for RTX-based tiled camera rendering. Requires the + ``ovrtx`` package and can run without Isaac Sim. +* **isaaclab_newton**: Newton-backed implementations of articulations, rigid bodies, rigid + object collections, cameras, USD spawners, and the Warp renderer. Supports + :ref:`kit-less installation ` without Isaac Sim. + +**Tasks and assets** + +* **isaaclab_assets**: Pre-configured :class:`~isaaclab.utils.configclass` dataclasses for a + wide range of robots (Franka, Unitree, ANYmal, Spot, Allegro, humanoids, quadcopters, and + more) and sensors (Velodyne, GelSight). +* **isaaclab_tasks**: Registered `gymnasium `__ environments for + reinforcement and imitation learning, organized as *manager-based* and *direct* tasks across + locomotion, manipulation, navigation, and classic control domains. +* **isaaclab_tasks_experimental**: Experimental task implementations under active development, + not yet part of the stable task suite. + +**Optional Additions** + +* **isaaclab_rl**: Thin wrappers that adapt Isaac Lab environments to the interfaces expected + by `RSL-RL `__, + `skrl `__, + `Stable Baselines 3 `__, and + `RL Games `__. +* **isaaclab_mimic**: APIs and pre-configured environments for data generation and imitation + learning, including cuRobo-based motion planners and a dataset-generation pipeline. +* **isaaclab_teleop**: Teleoperation session orchestration with OpenXR / CloudXR support, + device retargeters for manipulators and humanoids, and gamepad / spacemouse / keyboard input. +* **isaaclab_visualizers**: Supplementary visualizer backends (Isaac Sim Kit, Newton, Rerun, Viser) that + work with any physics backend. +* **isaaclab_contrib**: Community-contributed features: multirotor assets, TacSL + visuo-tactile sensors, drone thrust controllers, and more. +* **isaaclab_experimental**: Pre-production core experiments including Warp-accelerated manager + and environment variants. Standalone ~~~~~~~~~~ -The ``scripts`` directory contains various standalone applications written in python. +The ``scripts`` directory contains standalone Python applications. They are structured as follows: -* **benchmarks**: Contains scripts for benchmarking different framework components. -* **demos**: Contains various demo applications that showcase the core framework :mod:`isaaclab`. -* **environments**: Contains applications for running environments defined in :mod:`isaaclab_tasks` with - different agents. These include a random policy, zero-action policy, teleoperation or scripted state machines. -* **imitation_learning**: Contains applications for training and evaluating policies with various - imitation learning libraries (e.g. robomimic). -* **reinforcement_learning**: Contains applications for training and evaluating policies with various - reinforcement learning libraries (e.g. rsl_rl, rl_games, sb3, skrl). -* **tools**: Contains applications for using the tools provided by the framework. These include converting assets, - generating datasets, etc. -* **tutorials**: Contains step-by-step tutorials for using the APIs provided by the framework. +* **benchmarks**: Scripts for benchmarking different framework components. +* **demos**: Demo applications that showcase the core framework :mod:`isaaclab`. +* **environments**: Scripts for running environments defined in :mod:`isaaclab_tasks` with + different agents (random policy, zero-action policy, teleoperation, scripted state machines). +* **imitation_learning**: Applications for training and evaluating policies with imitation + learning libraries (e.g. robomimic). +* **reinforcement_learning**: Applications for training and evaluating policies with RL + libraries (e.g. rsl_rl, rl_games, sb3, skrl). +* **sim2sim_transfer**: Scripts for transferring policies trained in one simulator to another. +* **tools**: Applications for using framework tools such as converting assets and generating + datasets. +* **tutorials**: Step-by-step tutorials for using the APIs provided by the framework. diff --git a/docs/source/setup/ecosystem.rst b/docs/source/setup/ecosystem.rst index bea7ab613235..0b532af7db03 100644 --- a/docs/source/setup/ecosystem.rst +++ b/docs/source/setup/ecosystem.rst @@ -3,156 +3,209 @@ Isaac Lab Ecosystem =================== -Isaac Lab is built on top of Isaac Sim and Newton to provide a unified and flexible framework -for robot learning that exploits the latest simulation technologies. It is designed to be modular and extensible, -and aims to simplify common workflows in robotics research (such as RL, learning from demonstrations, and -motion planning). While it includes some pre-built environments, sensors, and tasks, its main goal is to -provide an open-sourced, unified, and easy-to-use interface for developing and testing custom environments -and robot learning algorithms. - -Working with Isaac Lab requires the installation of Isaac Sim for full functionality, which is packaged with -core robotics tools including URDF and MJCF importers, and ROS features. Isaac Sim also builds on top of the NVIDIA -Omniverse platform, leveraging advanced physics simulation from **PhysX**, photorealistic **RTX** rendering -technologies, and Universal Scene Description (USD) for scene creation. Without Isaac Sim, users can still use -the **Newton** physics backend as well as the new **OVRTX** renderers. +Isaac Lab is a modular, extensible framework for robot learning built on top of `Isaac Sim`_ and +`Newton`_. It provides a unified interface for the most common workflows in robotics research — +reinforcement learning, learning from demonstrations, and motion planning — while staying easy to +use and easy to extend. + +Isaac Lab supports two physics engines through multiple backend packages: + +* **PhysX** — the default backend through `Isaac Sim`_, with access to GPU-accelerated + rigid-body simulation, deformable objects, Fabric views, tiled RTX rendering, ROS/ROS2, + URDF/MJCF importers, and the full Omniverse toolchain. PhysX can also be used through the + standalone ``ovphysx`` runtime for kit-less workflows that do not launch Isaac Sim. +* **Newton** — a Warp-native backend that can run in kit-less mode, enabling lightweight + deployments and GPU-parallel simulation using `Warp`_. .. note:: - Isaac Lab 3.0 supports a **kit-less installation** mode: you can install Isaac Lab and use the Newton - physics backend without installing Isaac Sim at all. See :ref:`isaaclab-installation-root` for details. + Isaac Lab 3.0 supports a **kit-less installation** mode: you can install Isaac Lab and use the + Newton physics backend without installing Isaac Sim at all. + See :ref:`isaaclab-installation-root` for details. -Isaac Lab not only inherits the capabilities of Isaac Sim, but also adds a number -of new features that pertain to robot learning research. For example, including actuator dynamics in the -simulation, procedural terrain generation, and support to collect data from human demonstrations. +A factory pattern dispatches every asset and sensor instantiation to the correct backend at +runtime, so user code stays unchanged regardless of which backend is active. See +:doc:`/source/overview/core-concepts/multi_backend_architecture` for details. -.. image:: ../_static/setup/ecosystem-light.jpg +.. image:: ../_static/setup/ecosystem-light.svg :class: only-light :align: center - :alt: The Isaac Lab, Isaac Sim, and NVIDIA Omniverse ecosystem + :alt: The Isaac Lab package ecosystem layered on the NVIDIA GPU platform -.. image:: ../_static/setup/ecosystem-dark.jpg +.. image:: ../_static/setup/ecosystem-dark.svg :class: only-dark :align: center - :alt: The Isaac Lab, Isaac Sim, and NVIDIA Omniverse ecosystem + :alt: The Isaac Lab package ecosystem layered on the NVIDIA GPU platform + + +Package structure +----------------- + +Isaac Lab is organized into a set of focused packages that can be used independently or together. + +**Core** + +* ``isaaclab`` — the core library. Contains simulation context and configuration + (:mod:`~isaaclab.sim`), the :class:`~isaaclab.scene.InteractiveScene` that aggregates all + assets, sensors, and terrain for a vectorized set of environments, asset interfaces + (:mod:`~isaaclab.assets`), sensor interfaces (:mod:`~isaaclab.sensors`), environment base + classes (:mod:`~isaaclab.envs`), the manager system (:mod:`~isaaclab.managers`), + composable MDP term functions (:mod:`~isaaclab.envs.mdp`), actuator models + (:mod:`~isaaclab.actuators`), low-level controllers (:mod:`~isaaclab.controllers`), + procedural terrain generation (:mod:`~isaaclab.terrains`), and human-input device support + (:mod:`~isaaclab.devices`). + +**Physics and renderer backends** + +* ``isaaclab_physx`` — PhysX-backed implementations of articulations, + rigid bodies, deformable objects, Fabric views, the Isaac RTX renderer, and USD spawners. + Requires Isaac Sim. +* ``isaaclab_ovphysx`` — standalone PhysX-backed implementations built on ``ovphysx`` and + TensorBindingsAPI. Requires the ``ovphysx`` package and can run without launching Isaac Sim. +* ``isaaclab_ov`` — Omniverse renderer package that provides the OVRTX renderer for + RTX-based tiled camera rendering. Requires the ``ovrtx`` package and can be used in + kit-less workflows without Isaac Sim. +* ``isaaclab_newton`` — Newton-backed implementations of articulations, rigid bodies, and the + Warp renderer. Supports kit-less installation without Isaac Sim. + +**Extensions** + +* ``isaaclab_assets`` — pre-configured robot and sensor :class:`~isaaclab.utils.configclass` + dataclasses for a wide range of robots (Franka, Unitree, ANYmal, Spot, Allegro, humanoids, + quadcopters, and more) and sensors (Velodyne, GelSight). +* ``isaaclab_tasks`` — registered `gymnasium`_ environments organized into two authoring patterns: + + * *Manager-based* — behavior is fully specified through composable manager configurations + (observations, rewards, terminations, events, commands, actions). Well-suited for research + that requires clean separation between task specification and environment logic. + * *Direct* — a single Python class implements the full step/reset/obs/reward loop, similar + in style to Isaac Gym. Convenient for rapid prototyping and tasks with complex custom logic. + +* ``isaaclab_rl`` — thin wrappers that adapt Isaac Lab environments to the vectorized + environment interfaces expected by `RSL-RL`_, `skrl`_, `Stable Baselines 3`_, and + `RL Games`_. +* ``isaaclab_mimic`` — APIs and pre-configured environments for data generation and imitation + learning, including cuRobo-based motion planners and a full dataset-generation pipeline. +* ``isaaclab_teleop`` — teleoperation session orchestration with XR (OpenXR / CloudXR) support, + device retargeters for manipulators and humanoids, and gamepad/spacemouse/keyboard input. +* ``isaaclab_visualizers`` — supplementary visualizer backends (Isaac Kit, Rerun, Viser) that + work with any physics backend. +* ``isaaclab_contrib`` — community-contributed features: multirotor assets, TacSL visuo-tactile + sensors, drone thrust controllers, and more. +* ``isaaclab_experimental`` — pre-production experiments, including Warp-accelerated manager and + environment variants. Where does Isaac Lab fit in the Isaac ecosystem? ------------------------------------------------ Over the years, NVIDIA has developed a number of tools for robotics and AI. These tools leverage -the power of GPUs to accelerate the simulation both in terms of speed and realism. They show great -promise in the field of simulation technology and are being used by many researchers and companies -worldwide. - -`Isaac Gym`_ :cite:`makoviychuk2021isaac` provides a high performance GPU-based physics simulation -for robot learning. It is built on top of `PhysX`_ which supports GPU-accelerated simulation of rigid bodies -and a Python API to directly access physics simulation data. Isaac Lab extends this foundation with -additional support for the **Newton** physics backend, enabling broader simulation options. Through an end-to-end GPU pipeline, it is possible -to achieve high frame rates compared to CPU-based physics engines. The tool has been used successfully in a -number of research projects, including legged locomotion :cite:`rudin2022learning` :cite:`rudin2022advanced`, -in-hand manipulation :cite:`handa2022dextreme` :cite:`allshire2022transferring`, and industrial assembly -:cite:`narang2022factory`. - -Despite the success of Isaac Gym, it is not designed to be a general purpose simulator for -robotics. For example, it does not include interaction between deformable and rigid objects, high-fidelity -rendering, and support for ROS. The tool has been primarily designed as a preview release to showcase the -capabilities of the underlying physics engine. With the release of `Isaac Sim`_, NVIDIA is building -a general purpose simulator for robotics and has integrated the functionalities of Isaac Gym into -Isaac Sim. - -`Isaac Sim`_ is a robot simulation toolkit built on top of Omniverse, which is a general purpose platform -that aims to unite complex 3D workflows. Isaac Sim leverages the latest advances in graphics and -physics simulation to provide a high-fidelity simulation environment for robotics. It supports -ROS/ROS2, various sensor simulation, tools for domain randomization and synthetic data creation. -Tiled rendering support in Isaac Sim allows for vectorized rendering across environments, along with -support for running in the cloud using `Isaac Automator`_. -Overall, it is a powerful tool for roboticists and is a huge step forward in the field of robotics -simulation. - -With the release of above two tools, NVIDIA also released an open-sourced set of environments called -`IsaacGymEnvs`_ and `OmniIsaacGymEnvs`_, that have been built on top of Isaac Gym and Isaac Sim respectively. -These environments have been designed to display the capabilities of the underlying simulators and provide -a starting point to understand what is possible with the simulators for robot learning. These environments -can be used for benchmarking but are not designed for developing and testing custom environments and algorithms. -This is where Isaac Lab comes in. - -Isaac Lab is built on top of Isaac Sim to provide a unified and flexible framework -for robot learning that exploits latest simulation technologies. It is designed to be modular and extensible, -and aims to simplify common workflows in robotics research (such as RL, learning from demonstrations, and -motion planning). While it includes some pre-built environments, sensors, and tasks, its main goal is to -provide an open-sourced, unified, and easy-to-use interface for developing and testing custom environments -and robot learning algorithms. It not only inherits the capabilities of Isaac Sim, but also adds a number -of new features that pertain to robot learning research. For example, including actuator dynamics in the -simulation, procedural terrain generation, and support to collect data from human demonstrations. - -Isaac Lab replaces the previous `IsaacGymEnvs`_, `OmniIsaacGymEnvs`_ and `Orbit`_ frameworks and will -be the single robot learning framework for Isaac Sim. Previously released frameworks are deprecated -and we encourage users to follow our migration guides to transition over to Isaac Lab. +the power of GPUs to accelerate simulation both in terms of speed and realism. + +`Isaac Gym`_ :cite:`makoviychuk2021isaac` provided a high-performance GPU-based physics simulation +for robot learning built on top of `PhysX`_. Its end-to-end GPU pipeline enabled frame rates +far beyond what CPU-based physics engines could achieve. The tool proved successful across a +number of research projects, including legged locomotion :cite:`rudin2022learning` +:cite:`rudin2022advanced`, in-hand manipulation :cite:`handa2022dextreme` +:cite:`allshire2022transferring`, and industrial assembly :cite:`narang2022factory`. + +`Isaac Sim`_ is a general-purpose robot simulation toolkit built on top of `Omniverse`_. It +integrates the capabilities of Isaac Gym while adding high-fidelity rendering, ROS/ROS2, +deformable-object simulation, synthetic data generation, domain randomization, tiled rendering +for vectorized observations, and cloud support via `Isaac Automator`_. With the Isaac Gym legacy +API absorbed into Isaac Sim, NVIDIA also released open-sourced environment collections +`IsaacGymEnvs`_ and `OmniIsaacGymEnvs`_ to showcase the capabilities of these simulators. +Those environment collections are now deprecated in favor of Isaac Lab. + +Isaac Lab supersedes `IsaacGymEnvs`_, `OmniIsaacGymEnvs`_, and `Orbit`_ as the single robot +learning framework for Isaac Sim. It retains full access to the PhysX/Isaac Sim stack while +adding the Newton physics backend for kit-less deployments, an expanded sensor suite, imitation +learning tooling, XR teleoperation, and a rich set of pre-built tasks. Is Isaac Lab a simulator? ------------------------- -Often, when people think of simulators, they think of various commonly available engines, such as -`MuJoCo`_, `Bullet`_, and `Flex`_. These engines are powerful and have been used in a number of -research projects. However, they are not designed to be a general purpose simulator for robotics. -Rather they are primarily physics engines that are used to simulate the dynamics of rigid and -deformable bodies. They are shipped with some basic rendering capabilities to visualize the -simulation and provide parsing capabilities of different scene description formats. - -Various recent works combine these physics engines with different rendering engines to provide -a more complete simulation environment. They include APIs that allow reading and writing to the -physics and rendering engines. In some cases, they support ROS and hardware-in-the-loop simulation -for more robotic-specific applications. An example of these include `AirSim`_, `DoorGym`_, `ManiSkill`_, -`ThreeDWorld`_ and lastly, `Isaac Sim`_. - -At its core, Isaac Lab is **not** a robotics simulator, but a framework for building robot learning -applications on top of `Isaac Sim`_ and `Newton`_. An equivalent example of such a framework is `RoboSuite`_, which -is built on top of `MuJoCo`_ and is specific to fixed-base robots. Other examples include -`MuJoCo Playground`_ and `Isaac Gym`_ which use `MJX`_ and `PhysX`_ respectively. Isaac Lab supports -both `PhysX`_ and `Newton`_ as physics backends. They -include a number of pre-built tasks with separated out stand-alone implementations for individual -tasks. While this is a good starting point (and often convenient), a lot of code -repetition occurs across different task implementations, which can reduce code-reuse for larger -projects and teams. - -The main goal of Isaac Lab is to provide a unified framework for robot learning that includes -a variety of tooling and features that are required for robot learning, while being easy to -use and extend. It includes design patterns that simplify many of the common requirements for -robotics research. These include simulating sensors at different frequencies, connecting to different -teleoperation interfaces for data collection, switching action spaces for policy learning, -using Hydra for configuration management, supporting different learning libraries and more. -Isaac Lab supports designing tasks using *manager-based (modularized)* and *direct (single-script -similar to Isaac Gym)* patterns, leaving it up to the user to choose the best approach for their -use-case. For each of these patterns, Isaac Lab includes a number of pre-built tasks that can be -used for benchmarking and research. +At its core, Isaac Lab is **not** a robotics simulator; it is a framework for building robot +learning applications on top of a simulator. An analogous example is `RoboSuite`_, which is +built on top of `MuJoCo`_ for fixed-base manipulation. Other examples include +`MuJoCo Playground`_ (built on `MJX`_) and Isaac Gym (built on `PhysX`_). + +Isaac Lab supports both `PhysX`_ and `Newton`_ as physics backends and is deliberately +agnostic to the underlying engine — user environments and tasks do not import backend-specific +modules directly. + +The framework addresses a recurring problem with standalone task implementations: because each +task reimplements the observation, reward, termination, and randomization logic from scratch, +large projects accumulate significant code duplication. Isaac Lab solves this with two +complementary patterns: + +* **Manager-based** environments defer every behavioral concern to typed, composable + *manager* objects (``ObservationManager``, ``RewardManager``, ``TerminationManager``, + ``EventManager``, ``CurriculumManager``, ``CommandManager``, ``ActionManager``, + ``RecorderManager``). Each manager is driven by small, reusable MDP term functions that live + in :mod:`isaaclab.envs.mdp`. This makes it easy to mix and match terms across tasks and to + test individual components in isolation. + +* **Direct** environments implement ``_get_observations``, ``_get_rewards``, + ``_get_dones``, and ``_reset_idx`` directly in a subclass, similar to the Isaac Gym style. + They sacrifice some modularity for simplicity and are a natural starting point for rapid + prototyping. + +Both patterns expose a standard `gymnasium`_ ``Env`` interface with vectorized semantics, +so the same environment works unmodified with any of the supported RL libraries. +Configuration management uses `Hydra`_ with a preset system that allows selecting physics +backends and hyperparameter sweeps from the command line. Why should I use Isaac Lab? --------------------------- -Isaac Lab provides an open-sourced platform for the community to drive progress with consolidated efforts -toward designing benchmarks and robot learning systems as a joint initiative. This allows us to reuse -existing components and algorithms, and to build on top of each other's work. Doing so not only saves -time and effort, but also allows us to focus on the more important aspects of research. Our hope with -Isaac Lab is that it becomes the de-facto platform for robot learning research and an environment *zoo* -that leverages Isaac Sim. As the framework matures, we foresee it benefitting hugely from the latest -simulation developments (as part of internal developments at NVIDIA and collaborating partners) -and research in robotics. - -We are already working with labs in universities and research institutions to integrate their work into Isaac Lab -and hope that others in the community will join us too in this effort. If you are interested in contributing -to Isaac Lab, please reach out to us. +Isaac Lab provides an open-sourced platform for the community to build benchmarks and robot +learning systems together. Sharing a common infrastructure lets teams reuse existing components, +compare results on the same tasks, and focus on the research problems that matter rather than +rebuilding simulation scaffolding from scratch. + +Concretely, Isaac Lab offers: + +* **Two authoring patterns** — manager-based for modular research and direct for rapid + prototyping — with a shared :class:`~isaaclab.scene.InteractiveScene` and sensor stack. +* **Multi-backend simulation** — switch between PhysX and Newton from the command line; the + same environment code runs on both. +* **Rich sensor suite** — cameras (tiled and standard), ray-casters, contact sensors, IMU, + frame transformers, joint-wrench sensors, and visuo-tactile sensors. +* **Imitation learning tooling** — ``isaaclab_mimic`` provides cuRobo-based planners and a + full dataset-generation pipeline for human demonstration collection. +* **Teleoperation and XR** — ``isaaclab_teleop`` supports OpenXR, CloudXR, gamepads, + spacemouses, and Haply devices with retargeters for manipulators and humanoids. +* **Hydra configuration management** — hierarchical configs with command-line overrides and a + preset system for multi-backend environment variants. +* **RL library integrations** — wrappers for RSL-RL, skrl, Stable Baselines 3, and RL Games + ship in ``isaaclab_rl``. +* **Kit-less deployment** — run policies and simulations using the Newton backend without a + full Isaac Sim installation. + +We are working with labs in universities and research institutions to integrate their work into +Isaac Lab and hope that others in the community will join us. If you are interested in +contributing, please reach out to us. .. _PhysX: https://developer.nvidia.com/physx-sdk .. _Newton: https://github.com/newton-physics/newton +.. _Warp: https://github.com/NVIDIA/warp .. _Isaac Sim: https://developer.nvidia.com/isaac-sim +.. _Omniverse: https://www.nvidia.com/en-us/omniverse/ .. _Isaac Gym: https://developer.nvidia.com/isaac-gym .. _IsaacGymEnvs: https://github.com/isaac-sim/IsaacGymEnvs .. _OmniIsaacGymEnvs: https://github.com/isaac-sim/OmniIsaacGymEnvs .. _Orbit: https://isaac-orbit.github.io/ .. _Isaac Automator: https://github.com/isaac-sim/IsaacAutomator +.. _gymnasium: https://gymnasium.farama.org/ +.. _Hydra: https://hydra.cc/ +.. _RSL-RL: https://github.com/leggedrobotics/rsl_rl +.. _skrl: https://skrl.readthedocs.io/ +.. _Stable Baselines 3: https://stable-baselines3.readthedocs.io/ +.. _RL Games: https://github.com/Denys88/rl_games .. _AirSim: https://microsoft.github.io/AirSim/ .. _DoorGym: https://github.com/PSVL/DoorGym/ .. _ManiSkill: https://github.com/haosulab/ManiSkill From 9da557ac0ced669e9fe95e529204a062e1ac52e1 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 06:08:17 +0000 Subject: [PATCH 092/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 5.3.0 → 5.4.0 - isaaclab_newton: 0.10.0 → 0.11.0 - isaaclab_ov: 0.2.0 → 0.2.1 - isaaclab_ovphysx: 1.0.0 → 2.0.0 - isaaclab_physx: 0.8.0 → 0.9.0 - isaaclab_tasks: 1.7.0 → 1.8.0 --- .../antoiner-feat-ovphysx_articulation.skip | 4 - .../octi-raycaster-backend-split.minor.rst | 26 ---- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../pbarejko-kitless-test-suites.skip | 0 source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 31 +++++ .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../pbarejko-kitless-test-suites.skip | 0 .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../octi-raycaster-backend-split.minor.rst | 21 ---- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 26 ++++ .../huidongc-avoid-ovrtx-disk-rw.rst | 17 --- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 22 ++++ ...toiner-feat-ovphysx_articulation.major.rst | 109 ----------------- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 114 ++++++++++++++++++ .../octi-raycaster-backend-split.minor.rst | 26 ---- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 31 +++++ .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../pbarejko-kitless-test-suites.skip | 0 .../antoiner-feat-ovphysx_articulation.rst | 10 -- .../esekkin-ci-rendering-correctness-job.skip | 3 - .../octi-raycaster-backend-split.minor.rst | 8 -- .../changelog.d/pbarejko-fix-lazy-import.skip | 0 source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 20 +++ .../changelog.d/pbarejko-fix-lazy-import.skip | 0 .../pbarejko-kitless-test-suites.skip | 0 36 files changed, 250 insertions(+), 230 deletions(-) delete mode 100644 source/isaaclab/changelog.d/antoiner-feat-ovphysx_articulation.skip delete mode 100644 source/isaaclab/changelog.d/octi-raycaster-backend-split.minor.rst delete mode 100644 source/isaaclab/changelog.d/pbarejko-fix-lazy-import.skip delete mode 100644 source/isaaclab/changelog.d/pbarejko-kitless-test-suites.skip delete mode 100644 source/isaaclab_contrib/changelog.d/pbarejko-fix-lazy-import.skip delete mode 100644 source/isaaclab_contrib/changelog.d/pbarejko-kitless-test-suites.skip delete mode 100644 source/isaaclab_experimental/changelog.d/pbarejko-fix-lazy-import.skip delete mode 100644 source/isaaclab_mimic/changelog.d/pbarejko-fix-lazy-import.skip delete mode 100644 source/isaaclab_newton/changelog.d/octi-raycaster-backend-split.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/pbarejko-fix-lazy-import.skip delete mode 100644 source/isaaclab_ov/changelog.d/huidongc-avoid-ovrtx-disk-rw.rst delete mode 100644 source/isaaclab_ov/changelog.d/pbarejko-fix-lazy-import.skip delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation.major.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/pbarejko-fix-lazy-import.skip delete mode 100644 source/isaaclab_physx/changelog.d/octi-raycaster-backend-split.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/pbarejko-fix-lazy-import.skip delete mode 100644 source/isaaclab_rl/changelog.d/pbarejko-fix-lazy-import.skip delete mode 100644 source/isaaclab_rl/changelog.d/pbarejko-kitless-test-suites.skip delete mode 100644 source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_articulation.rst delete mode 100644 source/isaaclab_tasks/changelog.d/esekkin-ci-rendering-correctness-job.skip delete mode 100644 source/isaaclab_tasks/changelog.d/octi-raycaster-backend-split.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/pbarejko-fix-lazy-import.skip delete mode 100644 source/isaaclab_teleop/changelog.d/pbarejko-fix-lazy-import.skip delete mode 100644 source/isaaclab_teleop/changelog.d/pbarejko-kitless-test-suites.skip diff --git a/source/isaaclab/changelog.d/antoiner-feat-ovphysx_articulation.skip b/source/isaaclab/changelog.d/antoiner-feat-ovphysx_articulation.skip deleted file mode 100644 index 358e54a6b3dc..000000000000 --- a/source/isaaclab/changelog.d/antoiner-feat-ovphysx_articulation.skip +++ /dev/null @@ -1,4 +0,0 @@ -Test-only: update the OVPhysX iface test factory in -``source/isaaclab/test/assets/test_articulation_iface.py`` to match the -simplified :class:`isaaclab_ovphysx.assets.ArticulationData` constructor -signature. No isaaclab core API change. diff --git a/source/isaaclab/changelog.d/octi-raycaster-backend-split.minor.rst b/source/isaaclab/changelog.d/octi-raycaster-backend-split.minor.rst deleted file mode 100644 index 96c6b1d58ec0..000000000000 --- a/source/isaaclab/changelog.d/octi-raycaster-backend-split.minor.rst +++ /dev/null @@ -1,26 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab.sensors.ray_caster.BaseRayCaster`, - :class:`~isaaclab.sensors.ray_caster.BaseRayCasterCamera`, - :class:`~isaaclab.sensors.ray_caster.BaseMultiMeshRayCaster`, and - :class:`~isaaclab.sensors.ray_caster.BaseMultiMeshRayCasterCamera` - carrying the backend-agnostic ray-caster logic. Backend subclasses - override only the body-tracker and target-mesh-tracker hooks. - -Changed -^^^^^^^ - -* **Breaking:** Changed :class:`~isaaclab.sensors.camera.CameraData` - camera-owned buffers to :class:`~isaaclab.utils.warp.ProxyArray`. - Access torch tensor operations through the explicit ``.torch`` view. -* :class:`~isaaclab.sensors.ray_caster.RayCaster`, - :class:`~isaaclab.sensors.ray_caster.RayCasterCamera`, - :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster`, and - :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera` are now - :class:`~isaaclab.utils.backend_utils.FactoryBase` shims dispatching - to PhysX / Newton implementations. Cfg surface and runtime semantics - unchanged. -* Changed ray-caster camera update paths to keep pose, ray, depth, normal, - and mesh-id buffers Warp-owned internally, while exposing public camera - outputs through :class:`~isaaclab.utils.warp.ProxyArray`. diff --git a/source/isaaclab/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab/changelog.d/pbarejko-fix-lazy-import.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab/changelog.d/pbarejko-kitless-test-suites.skip b/source/isaaclab/changelog.d/pbarejko-kitless-test-suites.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index e18554b4fec5..049c10eee348 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 = "5.3.0" +version = "5.4.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index f8453b05a663..8027eea55803 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,37 @@ Changelog --------- +5.4.0 (2026-05-17) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab.sensors.ray_caster.BaseRayCaster`, + :class:`~isaaclab.sensors.ray_caster.BaseRayCasterCamera`, + :class:`~isaaclab.sensors.ray_caster.BaseMultiMeshRayCaster`, and + :class:`~isaaclab.sensors.ray_caster.BaseMultiMeshRayCasterCamera` + carrying the backend-agnostic ray-caster logic. Backend subclasses + override only the body-tracker and target-mesh-tracker hooks. + +Changed +^^^^^^^ + +* **Breaking:** Changed :class:`~isaaclab.sensors.camera.CameraData` + camera-owned buffers to :class:`~isaaclab.utils.warp.ProxyArray`. + Access torch tensor operations through the explicit ``.torch`` view. +* :class:`~isaaclab.sensors.ray_caster.RayCaster`, + :class:`~isaaclab.sensors.ray_caster.RayCasterCamera`, + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster`, and + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera` are now + :class:`~isaaclab.utils.backend_utils.FactoryBase` shims dispatching + to PhysX / Newton implementations. Cfg surface and runtime semantics + unchanged. +* Changed ray-caster camera update paths to keep pose, ray, depth, normal, + and mesh-id buffers Warp-owned internally, while exposing public camera + outputs through :class:`~isaaclab.utils.warp.ProxyArray`. + + 5.3.0 (2026-05-16) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_contrib/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_contrib/changelog.d/pbarejko-fix-lazy-import.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_contrib/changelog.d/pbarejko-kitless-test-suites.skip b/source/isaaclab_contrib/changelog.d/pbarejko-kitless-test-suites.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_experimental/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_experimental/changelog.d/pbarejko-fix-lazy-import.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_mimic/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_mimic/changelog.d/pbarejko-fix-lazy-import.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_newton/changelog.d/octi-raycaster-backend-split.minor.rst b/source/isaaclab_newton/changelog.d/octi-raycaster-backend-split.minor.rst deleted file mode 100644 index 6dedaf9cbd7d..000000000000 --- a/source/isaaclab_newton/changelog.d/octi-raycaster-backend-split.minor.rst +++ /dev/null @@ -1,21 +0,0 @@ -Added -^^^^^ - -* Added Newton backend for :class:`~isaaclab.sensors.ray_caster.RayCaster` / - :class:`~isaaclab.sensors.ray_caster.RayCasterCamera` / - :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster` / - :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`. Site-based, - matching :class:`~isaaclab_newton.sensors.pva.Pva` and - :class:`~isaaclab_newton.sensors.frame_transformer.FrameTransformer`: - registers body-attached sites via - :meth:`~isaaclab_newton.physics.NewtonManager.cl_register_site` for both - the sensor frame and any tracked target meshes, and reads per-step - transforms off :class:`~newton.sensors.SensorFrameTransform` against a - world-origin reference. Static parents/targets bypass the site - machinery and serve cached per-env ``wp.transformf`` arrays. - -Changed -^^^^^^^ - -* Changed Newton tracked target mesh updates to copy site poses directly into - Warp mesh pose tables instead of staging through torch views. diff --git a/source/isaaclab_newton/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_newton/changelog.d/pbarejko-fix-lazy-import.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index ee048a6bd0f0..24859f02a598 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.10.0" +version = "0.11.0" # 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 2f30eb8aaf9c..836ba4a2c133 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,32 @@ Changelog --------- +0.11.0 (2026-05-17) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added Newton backend for :class:`~isaaclab.sensors.ray_caster.RayCaster` / + :class:`~isaaclab.sensors.ray_caster.RayCasterCamera` / + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster` / + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`. Site-based, + matching :class:`~isaaclab_newton.sensors.pva.Pva` and + :class:`~isaaclab_newton.sensors.frame_transformer.FrameTransformer`: + registers body-attached sites via + :meth:`~isaaclab_newton.physics.NewtonManager.cl_register_site` for both + the sensor frame and any tracked target meshes, and reads per-step + transforms off :class:`~newton.sensors.SensorFrameTransform` against a + world-origin reference. Static parents/targets bypass the site + machinery and serve cached per-env ``wp.transformf`` arrays. + +Changed +^^^^^^^ + +* Changed Newton tracked target mesh updates to copy site poses directly into + Warp mesh pose tables instead of staging through torch views. + + 0.10.0 (2026-05-16) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/changelog.d/huidongc-avoid-ovrtx-disk-rw.rst b/source/isaaclab_ov/changelog.d/huidongc-avoid-ovrtx-disk-rw.rst deleted file mode 100644 index 650a7b276acc..000000000000 --- a/source/isaaclab_ov/changelog.d/huidongc-avoid-ovrtx-disk-rw.rst +++ /dev/null @@ -1,17 +0,0 @@ -Changed -^^^^^^^ - -* Changed :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.temp_usd_dir` defaults to ``None``. Set it to a writable - directory when you want the combined stage written to disk for debugging. - -Removed -^^^^^^^ - -* Removed :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.temp_usd_suffix`. When a temp file is written, the renderer - uses ``ovrtx_renderer_stage.usda`` filename under the configured temp directory. - -Fixed -^^^^^ - -* Avoided OVRTX staging disk I/O by exporting the prepared USD to memory and loading it with ``open_usd_from_string`` - instead of always writing intermediate scene and combined USD files. diff --git a/source/isaaclab_ov/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_ov/changelog.d/pbarejko-fix-lazy-import.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 82b495924d3c..7eb14c8bb34d 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.2.0" +version = "0.2.1" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index 5f9a405381bf..6aa027b6eeec 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,28 @@ Changelog --------- +0.2.1 (2026-05-17) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.temp_usd_dir` defaults to ``None``. Set it to a writable + directory when you want the combined stage written to disk for debugging. + +Removed +^^^^^^^ + +* Removed :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.temp_usd_suffix`. When a temp file is written, the renderer + uses ``ovrtx_renderer_stage.usda`` filename under the configured temp directory. + +Fixed +^^^^^ + +* Avoided OVRTX staging disk I/O by exporting the prepared USD to memory and loading it with ``open_usd_from_string`` + instead of always writing intermediate scene and combined USD files. + + 0.2.0 (2026-05-16) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation.major.rst deleted file mode 100644 index 24913760aa9b..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation.major.rst +++ /dev/null @@ -1,109 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_ovphysx.assets.Articulation` and - :class:`~isaaclab_ovphysx.assets.ArticulationData` for multi-DOF articulated - robots against the OVPhysX backend, satisfying the - :class:`~isaaclab.assets.BaseArticulation` and - :class:`~isaaclab.assets.BaseArticulationData` contracts. Public surface - matches the PhysX/Newton conventions: kwarg-only ``write_*_to_sim_index`` / - ``write_*_to_sim_mask`` writers and ``set_*_index`` / ``set_*_mask`` setters - for root state, joint state, joint properties, body properties, joint - command targets, fixed/spatial tendon properties, and external wrenches via - :attr:`~isaaclab_ovphysx.assets.Articulation.instantaneous_wrench_composer` - / :attr:`~isaaclab_ovphysx.assets.Articulation.permanent_wrench_composer`. - The full IsaacLab actuator pipeline (``compute`` / - ``_apply_actuator_model`` / ``_process_actuators_cfg``) is implemented on - top of the wheel's ``DOF_ACTUATION_FORCE`` / - ``DOF_POSITION_TARGET`` / ``DOF_VELOCITY_TARGET`` bindings. -* Added articulation-specific Warp kernels in - :mod:`isaaclab_ovphysx.assets.articulation.kernels`: soft-limit refresh, - default-joint-pos clamp, friction-component scatter (index + mask - variants). Six articulation kernels were also folded into the shared - :mod:`isaaclab_ovphysx.assets.kernels` module for reuse with - :class:`~isaaclab_ovphysx.assets.RigidObject` and - :class:`~isaaclab_ovphysx.assets.RigidObjectCollection`. -* Added init-time validation in - :meth:`~isaaclab_ovphysx.assets.Articulation._initialize_impl` that raises - ``RuntimeError`` when ``cfg.prim_path`` resolves to no - ``UsdPhysics.ArticulationRootAPI`` prim or to multiple roots, and - ``ValueError`` (via :meth:`_validate_cfg`) when any default joint - position is outside ``[lower, upper]`` or any default joint velocity - exceeds the per-joint maximum. Mirrors the PhysX backend. -* Added support for ``cfg.articulation_root_prim_path`` in - :meth:`~isaaclab_ovphysx.assets.Articulation._initialize_impl`: when the - user supplies an explicit subpath the binding pattern is extended - directly instead of running the auto-discovery walk, and a - ``RuntimeError`` is raised when the resulting expression resolves to no - prim in the USD stage. - -Changed -^^^^^^^ - -* **Breaking:** Renamed ``Articulation`` write/set methods to the dual - ``*_index`` / ``*_mask`` form and dropped the legacy ``full_data`` - flag. Index methods accept partial data shaped - ``(len(env_ids), len(joint_or_body_ids), ...)``; mask methods accept - full-shape data and a ``wp.bool`` mask. All keyword-only arguments live - after ``*,``; no positional fall-through. Migration: replace - ``write_X_to_sim(..., from_mask=True)`` with ``write_X_to_sim_mask(..., mask=...)``. -* **Breaking:** Removed the ``_write_body_state`` plumbing layer. - Deprecated state-writer shims (``write_root_state_to_sim``, - ``write_root_com_state_to_sim``, ``write_root_link_state_to_sim``, - joint-state equivalents) now call the public ``write_*_to_sim_index`` - methods directly. Behaviour is preserved. -* Changed ``Articulation.root_view`` to return the per-tensor-type bindings - dict (``self._bindings``). The OVPhysX wheel does not expose a single - ``ArticulationView`` object; callers that previously walked - ``root_view.shared_metatype`` / ``root_view.max_dofs`` should read from - :attr:`~isaaclab_ovphysx.assets.Articulation.num_joints` / - :attr:`~isaaclab_ovphysx.assets.Articulation.num_bodies` / - :attr:`~isaaclab_ovphysx.assets.Articulation.body_names` / - :attr:`~isaaclab_ovphysx.assets.Articulation.joint_names` instead. -* Changed every ``ArticulationData`` public property to return a - :class:`~isaaclab.utils.ProxyArray` (warp + torch dual view); raw - ``wp.array`` is reserved for one-shot config buffers. Eager - ``TimestampedBufferWarp`` allocation in :meth:`_create_buffers` makes - every buffer a single source of truth — no - ``_invalidate_caches`` / ``_ensure_*_buffers`` machinery. -* Changed ``Articulation`` body and DOF property writers to honor the - wheel's actual binding device. Tensor-type membership in - :data:`isaaclab_ovphysx.tensor_types._CPU_ONLY_TYPES` now reflects what - the wheel exposes: ``BODY_MASS``, ``BODY_COM_POSE``, ``BODY_INERTIA``, - ``DOF_STIFFNESS``, ``DOF_DAMPING``, ``DOF_LIMIT``, ``DOF_MAX_VELOCITY``, - ``DOF_MAX_FORCE``, ``DOF_ARMATURE``, ``DOF_FRICTION_PROPERTIES`` are - CPU-only (write goes through pinned-host staging); fixed and spatial - tendon bindings write directly from sim-device buffers. -* Changed :meth:`~isaaclab_ovphysx.assets.Articulation.write_joint_friction_coefficient_to_sim_index` - / ``_mask`` to accept ``joint_dynamic_friction_coeff`` and - ``joint_viscous_friction_coeff`` keyword arguments (each - ``float | torch.Tensor | wp.array | None``). ``None`` preserves the - existing component on the wheel; matches the PhysX backend. -* Changed :meth:`~isaaclab_ovphysx.assets.Articulation.write_joint_position_limit_to_sim_index` - / ``_mask`` to clamp ``default_joint_pos`` and refresh - ``soft_joint_pos_limits`` when the new hard limits invalidate the - defaults, matching the PhysX backend (with a - ``warn_limit_violation`` log). -* Changed every fixed/spatial tendon ``set_*_index`` / ``set_*_mask`` setter - to accept a scalar :class:`float` for the value argument; broadcast is - materialized via :meth:`_broadcast_scalar_to_2d`. Mirrors PhysX. -* Implemented the previously stubbed - :meth:`~isaaclab_ovphysx.assets.Articulation.write_fixed_tendon_properties_to_sim_index` - / ``_mask`` and - :meth:`~isaaclab_ovphysx.assets.Articulation.write_spatial_tendon_properties_to_sim_index` - / ``_mask``: each iterates the per-tensor bindings since the OVPhysX - wheel has no batch ``set_*_tendon_properties`` setter. - -Removed -^^^^^^^ - -* **Breaking:** Removed the ``full_data`` keyword-argument from every - ``Articulation`` ``*_index`` writer/setter. Index methods now strictly - accept partial data; full-data callers should use the matching - ``*_mask`` overload. -* Removed the stop-gap :mod:`isaaclab_ovphysx.assets.kernels_old` module; - the six articulation kernels it housed - (``_compose_root_com_pose``, ``_compute_heading``, ``_copy_first_body``, - ``_projected_gravity``, ``_world_vel_to_body_ang``, - ``_world_vel_to_body_lin``) are now in - :mod:`isaaclab_ovphysx.assets.kernels`. diff --git a/source/isaaclab_ovphysx/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_ovphysx/changelog.d/pbarejko-fix-lazy-import.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index f280545b5e2e..87311dcc04c1 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 = "1.0.0" +version = "2.0.0" # 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 0858c693523f..19d5499b3823 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,120 @@ Changelog --------- +2.0.0 (2026-05-17) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.assets.Articulation` and + :class:`~isaaclab_ovphysx.assets.ArticulationData` for multi-DOF articulated + robots against the OVPhysX backend, satisfying the + :class:`~isaaclab.assets.BaseArticulation` and + :class:`~isaaclab.assets.BaseArticulationData` contracts. Public surface + matches the PhysX/Newton conventions: kwarg-only ``write_*_to_sim_index`` / + ``write_*_to_sim_mask`` writers and ``set_*_index`` / ``set_*_mask`` setters + for root state, joint state, joint properties, body properties, joint + command targets, fixed/spatial tendon properties, and external wrenches via + :attr:`~isaaclab_ovphysx.assets.Articulation.instantaneous_wrench_composer` + / :attr:`~isaaclab_ovphysx.assets.Articulation.permanent_wrench_composer`. + The full IsaacLab actuator pipeline (``compute`` / + ``_apply_actuator_model`` / ``_process_actuators_cfg``) is implemented on + top of the wheel's ``DOF_ACTUATION_FORCE`` / + ``DOF_POSITION_TARGET`` / ``DOF_VELOCITY_TARGET`` bindings. +* Added articulation-specific Warp kernels in + :mod:`isaaclab_ovphysx.assets.articulation.kernels`: soft-limit refresh, + default-joint-pos clamp, friction-component scatter (index + mask + variants). Six articulation kernels were also folded into the shared + :mod:`isaaclab_ovphysx.assets.kernels` module for reuse with + :class:`~isaaclab_ovphysx.assets.RigidObject` and + :class:`~isaaclab_ovphysx.assets.RigidObjectCollection`. +* Added init-time validation in + :meth:`~isaaclab_ovphysx.assets.Articulation._initialize_impl` that raises + ``RuntimeError`` when ``cfg.prim_path`` resolves to no + ``UsdPhysics.ArticulationRootAPI`` prim or to multiple roots, and + ``ValueError`` (via :meth:`_validate_cfg`) when any default joint + position is outside ``[lower, upper]`` or any default joint velocity + exceeds the per-joint maximum. Mirrors the PhysX backend. +* Added support for ``cfg.articulation_root_prim_path`` in + :meth:`~isaaclab_ovphysx.assets.Articulation._initialize_impl`: when the + user supplies an explicit subpath the binding pattern is extended + directly instead of running the auto-discovery walk, and a + ``RuntimeError`` is raised when the resulting expression resolves to no + prim in the USD stage. + +Changed +^^^^^^^ + +* **Breaking:** Renamed ``Articulation`` write/set methods to the dual + ``*_index`` / ``*_mask`` form and dropped the legacy ``full_data`` + flag. Index methods accept partial data shaped + ``(len(env_ids), len(joint_or_body_ids), ...)``; mask methods accept + full-shape data and a ``wp.bool`` mask. All keyword-only arguments live + after ``*,``; no positional fall-through. Migration: replace + ``write_X_to_sim(..., from_mask=True)`` with ``write_X_to_sim_mask(..., mask=...)``. +* **Breaking:** Removed the ``_write_body_state`` plumbing layer. + Deprecated state-writer shims (``write_root_state_to_sim``, + ``write_root_com_state_to_sim``, ``write_root_link_state_to_sim``, + joint-state equivalents) now call the public ``write_*_to_sim_index`` + methods directly. Behaviour is preserved. +* Changed ``Articulation.root_view`` to return the per-tensor-type bindings + dict (``self._bindings``). The OVPhysX wheel does not expose a single + ``ArticulationView`` object; callers that previously walked + ``root_view.shared_metatype`` / ``root_view.max_dofs`` should read from + :attr:`~isaaclab_ovphysx.assets.Articulation.num_joints` / + :attr:`~isaaclab_ovphysx.assets.Articulation.num_bodies` / + :attr:`~isaaclab_ovphysx.assets.Articulation.body_names` / + :attr:`~isaaclab_ovphysx.assets.Articulation.joint_names` instead. +* Changed every ``ArticulationData`` public property to return a + :class:`~isaaclab.utils.ProxyArray` (warp + torch dual view); raw + ``wp.array`` is reserved for one-shot config buffers. Eager + ``TimestampedBufferWarp`` allocation in :meth:`_create_buffers` makes + every buffer a single source of truth — no + ``_invalidate_caches`` / ``_ensure_*_buffers`` machinery. +* Changed ``Articulation`` body and DOF property writers to honor the + wheel's actual binding device. Tensor-type membership in + :data:`isaaclab_ovphysx.tensor_types._CPU_ONLY_TYPES` now reflects what + the wheel exposes: ``BODY_MASS``, ``BODY_COM_POSE``, ``BODY_INERTIA``, + ``DOF_STIFFNESS``, ``DOF_DAMPING``, ``DOF_LIMIT``, ``DOF_MAX_VELOCITY``, + ``DOF_MAX_FORCE``, ``DOF_ARMATURE``, ``DOF_FRICTION_PROPERTIES`` are + CPU-only (write goes through pinned-host staging); fixed and spatial + tendon bindings write directly from sim-device buffers. +* Changed :meth:`~isaaclab_ovphysx.assets.Articulation.write_joint_friction_coefficient_to_sim_index` + / ``_mask`` to accept ``joint_dynamic_friction_coeff`` and + ``joint_viscous_friction_coeff`` keyword arguments (each + ``float | torch.Tensor | wp.array | None``). ``None`` preserves the + existing component on the wheel; matches the PhysX backend. +* Changed :meth:`~isaaclab_ovphysx.assets.Articulation.write_joint_position_limit_to_sim_index` + / ``_mask`` to clamp ``default_joint_pos`` and refresh + ``soft_joint_pos_limits`` when the new hard limits invalidate the + defaults, matching the PhysX backend (with a + ``warn_limit_violation`` log). +* Changed every fixed/spatial tendon ``set_*_index`` / ``set_*_mask`` setter + to accept a scalar :class:`float` for the value argument; broadcast is + materialized via :meth:`_broadcast_scalar_to_2d`. Mirrors PhysX. +* Implemented the previously stubbed + :meth:`~isaaclab_ovphysx.assets.Articulation.write_fixed_tendon_properties_to_sim_index` + / ``_mask`` and + :meth:`~isaaclab_ovphysx.assets.Articulation.write_spatial_tendon_properties_to_sim_index` + / ``_mask``: each iterates the per-tensor bindings since the OVPhysX + wheel has no batch ``set_*_tendon_properties`` setter. + +Removed +^^^^^^^ + +* **Breaking:** Removed the ``full_data`` keyword-argument from every + ``Articulation`` ``*_index`` writer/setter. Index methods now strictly + accept partial data; full-data callers should use the matching + ``*_mask`` overload. +* Removed the stop-gap :mod:`isaaclab_ovphysx.assets.kernels_old` module; + the six articulation kernels it housed + (``_compose_root_com_pose``, ``_compute_heading``, ``_copy_first_body``, + ``_projected_gravity``, ``_world_vel_to_body_ang``, + ``_world_vel_to_body_lin``) are now in + :mod:`isaaclab_ovphysx.assets.kernels`. + + 1.0.0 (2026-05-14) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/changelog.d/octi-raycaster-backend-split.minor.rst b/source/isaaclab_physx/changelog.d/octi-raycaster-backend-split.minor.rst deleted file mode 100644 index f31307551274..000000000000 --- a/source/isaaclab_physx/changelog.d/octi-raycaster-backend-split.minor.rst +++ /dev/null @@ -1,26 +0,0 @@ -Added -^^^^^ - -* Added PhysX backend for :class:`~isaaclab.sensors.ray_caster.RayCaster` / - :class:`~isaaclab.sensors.ray_caster.RayCasterCamera` / - :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster` / - :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`. Sensor - body and tracked target meshes both run off ``RigidObjectView`` — - per-step compose via small warp kernels, no - :class:`~isaaclab_physx.sim.views.FabricFrameView` path. Static - parents/targets serve cached per-env ``wp.transformf`` arrays. - -Fixed -^^^^^ - -* Fixed all four ray-caster sensors (:class:`~isaaclab.sensors.ray_caster.RayCaster`, - :class:`~isaaclab.sensors.ray_caster.RayCasterCamera`, - :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster`, - :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`) returning - their spawn-time pose forever when parented under a rigid body. Previous - path went through :class:`~isaaclab_physx.sim.views.FabricFrameView` - which regressed in #5179; the new backend reads body pose directly from - PhysX. The same fix applies to tracked target meshes - (``track_mesh_transforms=True``) parented under rigid bodies. -* Fixed PhysX tracked target mesh updates to write directly into Warp mesh - pose tables instead of staging through torch views. diff --git a/source/isaaclab_physx/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_physx/changelog.d/pbarejko-fix-lazy-import.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index df613c5d9e9d..37cee764eed8 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.8.0" +version = "0.9.0" # 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 bda522208327..8cee80884415 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,37 @@ Changelog --------- +0.9.0 (2026-05-17) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added PhysX backend for :class:`~isaaclab.sensors.ray_caster.RayCaster` / + :class:`~isaaclab.sensors.ray_caster.RayCasterCamera` / + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster` / + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`. Sensor + body and tracked target meshes both run off ``RigidObjectView`` — + per-step compose via small warp kernels, no + :class:`~isaaclab_physx.sim.views.FabricFrameView` path. Static + parents/targets serve cached per-env ``wp.transformf`` arrays. + +Fixed +^^^^^ + +* Fixed all four ray-caster sensors (:class:`~isaaclab.sensors.ray_caster.RayCaster`, + :class:`~isaaclab.sensors.ray_caster.RayCasterCamera`, + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCaster`, + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`) returning + their spawn-time pose forever when parented under a rigid body. Previous + path went through :class:`~isaaclab_physx.sim.views.FabricFrameView` + which regressed in #5179; the new backend reads body pose directly from + PhysX. The same fix applies to tracked target meshes + (``track_mesh_transforms=True``) parented under rigid bodies. +* Fixed PhysX tracked target mesh updates to write directly into Warp mesh + pose tables instead of staging through torch views. + + 0.8.0 (2026-05-16) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_rl/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_rl/changelog.d/pbarejko-fix-lazy-import.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_rl/changelog.d/pbarejko-kitless-test-suites.skip b/source/isaaclab_rl/changelog.d/pbarejko-kitless-test-suites.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_articulation.rst b/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_articulation.rst deleted file mode 100644 index 26c7d5b8f9ca..000000000000 --- a/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_articulation.rst +++ /dev/null @@ -1,10 +0,0 @@ -Added -^^^^^ - -* Added the ``ovphysx`` preset to ``Isaac-Repose-Cube-Allegro-Direct-v0`` - (``ObjectCfg`` and ``PhysicsCfg`` in - :mod:`isaaclab_tasks.direct.allegro_hand.allegro_hand_env_cfg`), so the - task can be selected with ``presets=ovphysx`` against the OVPhysX - backend. Exercises the OVPhysX :class:`~isaaclab_ovphysx.assets.Articulation` - (Allegro hand) and :class:`~isaaclab_ovphysx.assets.RigidObject` (cube) - in the same scene. diff --git a/source/isaaclab_tasks/changelog.d/esekkin-ci-rendering-correctness-job.skip b/source/isaaclab_tasks/changelog.d/esekkin-ci-rendering-correctness-job.skip deleted file mode 100644 index 72c519063879..000000000000 --- a/source/isaaclab_tasks/changelog.d/esekkin-ci-rendering-correctness-job.skip +++ /dev/null @@ -1,3 +0,0 @@ -CI-only change. The rendering-correctness tests now run in two dedicated -``test-rendering-correctness`` and ``test-rendering-correctness-kitless`` -jobs instead of being mixed into the ``isaaclab_tasks [N/3]`` shards. diff --git a/source/isaaclab_tasks/changelog.d/octi-raycaster-backend-split.minor.rst b/source/isaaclab_tasks/changelog.d/octi-raycaster-backend-split.minor.rst deleted file mode 100644 index aecca907c486..000000000000 --- a/source/isaaclab_tasks/changelog.d/octi-raycaster-backend-split.minor.rst +++ /dev/null @@ -1,8 +0,0 @@ -Added -^^^^^ - -* Added raycaster-camera depth presets (``raycaster_depth64``, ``raycaster_depth128``, - ``raycaster_depth256``) for both base and wrist views in the Dexsuite Kuka-Allegro - manipulation task, backed by - :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`. Targets the table, - ground plane, manipulated object, and robot visuals. diff --git a/source/isaaclab_tasks/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_tasks/changelog.d/pbarejko-fix-lazy-import.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 302713bfed18..1bd9b6ff1309 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.7.0" +version = "1.8.0" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 5f420bea4610..acda085f98e3 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,26 @@ Changelog --------- +1.8.0 (2026-05-17) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the ``ovphysx`` preset to ``Isaac-Repose-Cube-Allegro-Direct-v0`` + (``ObjectCfg`` and ``PhysicsCfg`` in + :mod:`isaaclab_tasks.direct.allegro_hand.allegro_hand_env_cfg`), so the + task can be selected with ``presets=ovphysx`` against the OVPhysX + backend. Exercises the OVPhysX :class:`~isaaclab_ovphysx.assets.Articulation` + (Allegro hand) and :class:`~isaaclab_ovphysx.assets.RigidObject` (cube) + in the same scene. +* Added raycaster-camera depth presets (``raycaster_depth64``, ``raycaster_depth128``, + ``raycaster_depth256``) for both base and wrist views in the Dexsuite Kuka-Allegro + manipulation task, backed by + :class:`~isaaclab.sensors.ray_caster.MultiMeshRayCasterCamera`. Targets the table, + ground plane, manipulated object, and robot visuals. + + 1.7.0 (2026-05-16) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_teleop/changelog.d/pbarejko-fix-lazy-import.skip b/source/isaaclab_teleop/changelog.d/pbarejko-fix-lazy-import.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_teleop/changelog.d/pbarejko-kitless-test-suites.skip b/source/isaaclab_teleop/changelog.d/pbarejko-kitless-test-suites.skip deleted file mode 100644 index e69de29bb2d1..000000000000 From 99d509729f2ae38ea2bf397a320475d1713e6042 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Sat, 16 May 2026 23:17:27 -0700 Subject: [PATCH 093/133] Expose missing API docs (#5582) # Description Adds missing modules to the API docs. ## 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 --- docs/conf.py | 6 +++ docs/source/api/index.rst | 52 +++++++++++++++++++ .../changelog.d/api-docs.rst | 5 ++ .../isaaclab_experimental/__init__.py | 15 ++++++ .../envs/manager_based_env_warp.py | 2 + .../isaaclab_experimental/utils/__init__.py | 10 ++++ .../isaaclab_experimental/utils/__init__.pyi | 20 +++++++ .../utils/buffers/circular_buffer.py | 2 + .../utils/manager_call_switch.py | 4 +- .../isaaclab_experimental/utils/warp/utils.py | 1 - 10 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 source/isaaclab_experimental/changelog.d/api-docs.rst create mode 100644 source/isaaclab_experimental/isaaclab_experimental/utils/__init__.py create mode 100644 source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi diff --git a/docs/conf.py b/docs/conf.py index 941b8c844da8..66a441be9718 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,10 +24,16 @@ sys.path.insert(0, os.path.abspath("../source/isaaclab_assets/isaaclab_assets")) sys.path.insert(0, os.path.abspath("../source/isaaclab_tasks")) sys.path.insert(0, os.path.abspath("../source/isaaclab_tasks/isaaclab_tasks")) +sys.path.insert(0, os.path.abspath("../source/isaaclab_tasks_experimental")) +sys.path.insert(0, os.path.abspath("../source/isaaclab_tasks_experimental/isaaclab_tasks_experimental")) sys.path.insert(0, os.path.abspath("../source/isaaclab_physx")) sys.path.insert(0, os.path.abspath("../source/isaaclab_physx/isaaclab_physx")) +sys.path.insert(0, os.path.abspath("../source/isaaclab_ovphysx")) +sys.path.insert(0, os.path.abspath("../source/isaaclab_ovphysx/isaaclab_ovphysx")) sys.path.insert(0, os.path.abspath("../source/isaaclab_newton")) sys.path.insert(0, os.path.abspath("../source/isaaclab_newton/isaaclab_newton")) +sys.path.insert(0, os.path.abspath("../source/isaaclab_experimental")) +sys.path.insert(0, os.path.abspath("../source/isaaclab_experimental/isaaclab_experimental")) sys.path.insert(0, os.path.abspath("../source/isaaclab_rl")) sys.path.insert(0, os.path.abspath("../source/isaaclab_rl/isaaclab_rl")) sys.path.insert(0, os.path.abspath("../source/isaaclab_mimic")) diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index a378a2333e0f..43ee461e124a 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -228,3 +228,55 @@ The following modules are available in the ``isaaclab_visualizers`` extension: lab_visualizers/isaaclab_visualizers.newton lab_visualizers/isaaclab_visualizers.rerun lab_visualizers/isaaclab_visualizers.viser + + +isaaclab_ovphysx extension +--------------------------- + +The following modules are available in the ``isaaclab_ovphysx`` extension: + +.. currentmodule:: isaaclab_ovphysx + +.. autosummary:: + :toctree: lab_ovphysx + + assets + cloner + physics + +.. toctree:: + :hidden: + + lab_ovphysx/isaaclab_ovphysx.assets + lab_ovphysx/isaaclab_ovphysx.cloner + lab_ovphysx/isaaclab_ovphysx.physics + + +isaaclab_experimental extension +-------------------------------- + +The following modules are available in the ``isaaclab_experimental`` extension: + +.. currentmodule:: isaaclab_experimental + +.. autosummary:: + :toctree: lab_experimental + + envs + managers + utils + +.. toctree:: + :hidden: + + lab_experimental/isaaclab_experimental.envs + lab_experimental/isaaclab_experimental.managers + lab_experimental/isaaclab_experimental.utils + + +isaaclab_tasks_experimental extension +-------------------------------------- + +The package ``isaaclab_tasks_experimental`` contains experimental task implementations +under active development, not yet part of the stable task suite. +For the list of available environments, please refer to the :ref:`environments`. diff --git a/source/isaaclab_experimental/changelog.d/api-docs.rst b/source/isaaclab_experimental/changelog.d/api-docs.rst new file mode 100644 index 000000000000..9dfe5a38dc28 --- /dev/null +++ b/source/isaaclab_experimental/changelog.d/api-docs.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed :mod:`isaaclab_experimental.utils` package exports so its utility + modules appear in API documentation. diff --git a/source/isaaclab_experimental/isaaclab_experimental/__init__.py b/source/isaaclab_experimental/isaaclab_experimental/__init__.py index 18e97e2dd188..f92baf9ba762 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/__init__.py +++ b/source/isaaclab_experimental/isaaclab_experimental/__init__.py @@ -5,6 +5,7 @@ """Package containing the core framework.""" +import importlib import os import toml from enum import IntEnum @@ -18,3 +19,17 @@ # Configure the module-level variables __version__ = ISAACLAB_EXPERIMENTAL_METADATA["package"]["version"] + +_SUBMODULES = frozenset({"envs", "managers", "utils"}) + + +def __getattr__(name: str): + if name in _SUBMODULES: + module = importlib.import_module(f"{__name__}.{name}") + globals()[name] = module + return module + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(set(globals()) | _SUBMODULES) diff --git a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py index 5ace5168a9c8..028022c31d13 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py +++ b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py @@ -12,6 +12,8 @@ Behavior is intended to match the stable environment initially. """ +from __future__ import annotations + # import builtins import contextlib import importlib diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.py b/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.py new file mode 100644 index 000000000000..f2f89bffd5ef --- /dev/null +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/__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 + +"""Sub-package containing experimental utilities.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi b/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi new file mode 100644 index 000000000000..de2860732514 --- /dev/null +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi @@ -0,0 +1,20 @@ +# 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__ = [ + "ManagerCallMode", + "ManagerCallSwitch", + "WarpGraphCache", + "clone_obs_buffer", + "buffers", + "modifiers", + "noise", + "warp", +] + +from .manager_call_switch import ManagerCallMode, ManagerCallSwitch +from .torch_utils import clone_obs_buffer +from .warp_graph_cache import WarpGraphCache +from . import buffers, modifiers, noise, warp diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/buffers/circular_buffer.py b/source/isaaclab_experimental/isaaclab_experimental/utils/buffers/circular_buffer.py index b25f91becbca..f0dcf57b2a30 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/buffers/circular_buffer.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/buffers/circular_buffer.py @@ -4,6 +4,8 @@ # # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + from collections.abc import Sequence import torch diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/manager_call_switch.py b/source/isaaclab_experimental/isaaclab_experimental/utils/manager_call_switch.py index ad149900af2c..89924c789bc3 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/manager_call_switch.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/manager_call_switch.py @@ -13,10 +13,10 @@ from enum import IntEnum from typing import Any -from isaaclab_experimental.utils.warp_graph_cache import WarpGraphCache - from isaaclab.utils.timer import Timer +from isaaclab_experimental.utils.warp_graph_cache import WarpGraphCache + class ManagerCallMode(IntEnum): """Execution mode for manager stage calls. diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/warp/utils.py b/source/isaaclab_experimental/isaaclab_experimental/utils/warp/utils.py index a4beb8627ef9..a90f6b2d6fc6 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/warp/utils.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/warp/utils.py @@ -8,7 +8,6 @@ from collections.abc import Sequence import torch - import warp as wp From 2ace55b25f38874e01e3edbdcccfebeb4ecce8b2 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Sat, 16 May 2026 23:17:50 -0700 Subject: [PATCH 094/133] Reworks modular installation and add tests and updates docs (#5650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Refactors the IsaacLab installation model to simplify the user experience, adds comprehensive installation CI tests (including conda). --- ## Motivation The previous installation model required users to name every individual submodule (`./isaaclab.sh -i assets,tasks,physx,contrib,newton,rl[rsl-rl]`), exposing internal implementation details. Users shouldn't need to know that `tasks` depends on `physx` or that `contrib` exists — they just want to train with Newton or run with Isaac Sim. --- ## Changes ### 1. Installation model refactor **`source/isaaclab/isaaclab/cli/commands/install.py`** - Replaced `VALID_ISAACLAB_SUBMODULES` / `VALID_RL_FRAMEWORKS` with three typed constants that make the tier structure explicit: - `CORE_ISAACLAB_SUBMODULES: list[str]` — always installed (isaaclab, assets, contrib, experimental, newton, ov, ovphysx, physx, rl, tasks, tasks_experimental, visualizers) - `OPTIONAL_ISAACLAB_SUBMODULES: dict[str, str]` — opt-in heavy submodules (`mimic`, `teleop`) - `VALID_EXTRA_FEATURES: set[str]` — opt-in heavy dependency groups (`contrib`, `newton`, `ov`, `rl`, `visualizer`) - Rewrote `command_install(install_type)` to always install all core submodules, then layer optional submodules and extra feature dependencies on top. - Added `_install_extra_feature(feature_name, selector)` replacing `_install_extra_frameworks`. - `./isaaclab.sh -i` (no args) or `-i all` installs everything including `mimic` and `teleop`. - `./isaaclab.sh -i none` installs all core submodules with no optional extras. - Unknown tokens (e.g. old `tasks`, `assets`) emit a `[WARNING]` and are skipped gracefully. **`source/isaaclab/isaaclab/cli/__init__.py`** - Rewrote the `-i` help text to document the three-tier model with examples. **`source/isaaclab/setup.py`** - Simplified `EXTRAS_REQUIRE` to `isaacsim` and `all` only. **`source/isaaclab_mimic/setup.py`** - Removed the empty `robomimic` extra. **`pyproject.toml` (root)** - `[project.dependencies]`: lists all core submodules (bare, no extras). - `[project.optional-dependencies]`: simplified to `isaacsim` and `all`. --- ### 2. Documentation **`docs/source/setup/installation/include/src_build_isaaclab.rst`** - Rewrote the install section: new tables for optional submodules and extra feature sets with their selectors; updated all example commands. **`docs/source/setup/installation/kitless_installation.rst`** - Updated the selective-install table and examples to match the new model. ### 3. Installation CI tests #### New and updated test files | File | Status | What it tests | |---|---|---| | `test_install_command_parsing.py` | New | Unit tests for `_split_install_items`, constant consistency, and `command_install` dispatch logic (all mocked, no pip) | | `test_isaaclabx_i_none.py` | New | `./isaaclab.sh -i none` installs all core submodules; optional submodules absent | | `test_isaaclabx_i_rl.py` | New | `rl[rsl-rl]`, `rl[skrl]`, `rl[sb3]` each install the right framework; `rl` (no selector) installs all | | `test_isaaclabx_i_mimic.py` | New | `mimic` is importable after `-i mimic`; absent after `-i none` | | `test_isaaclabx_i_visualizer.py` | New | `visualizer[rerun]`, `visualizer[viser]`, `visualizer` (all) install the right backends | | `test_install_workflow_training.py` | New | E2E uv × conda training workflows (see table below) | | `test_isaaclabx_i_physx.py` | Updated | Reflects physx in core set (no longer requires `-i physx`) | | `test_isaaclabx_uv_smoke.py` | Updated | `assets` / `tasks` are always-installed core; `newton` is an extra | | `test_isaaclabx_uv_training.py` | Updated | Install command updated from old token list to `newton,rl[all]` | #### `test_install_workflow_training.py` — E2E matrix | Test | Install command | Marker | |---|---|---| | `test_uv_none_installs_core_submodules` | `-i none` | `@uv` | | `test_uv_newton_rsl_rl_trains_cartpole` | `-i newton,rl[rsl-rl]` | `@uv` | | `test_uv_newton_ov_rsl_rl_trains_cartpole` | `-i newton,ov,rl[rsl-rl]` | `@uv` | | `test_uv_all_trains_cartpole` | `-i all` | `@uv` | | `test_conda_none_installs_core_submodules` | `-i none` (conda env) | `@conda` | | `test_conda_newton_rsl_rl_trains_cartpole` | `-i newton,rl[rsl-rl]` (conda env) | `@conda` | #### `source/isaaclab/test/install_ci/utils.py` - Added `drop_keys(env, keys)` helper for stripping venv/conda activation markers before creating isolated environments. - Added `Conda_Mixin` with `create_conda_env()`, `destroy_conda_env()`, and `run_in_conda_env()` for conda-based test classes. #### `source/isaaclab/test/install_ci/conftest.py` - Registered `conda` and `timeout` as known pytest markers. --- ### 4. Conda CI infrastructure **`docker/Dockerfile.installci-conda`** (new) - Layers Miniconda on top of the existing uv-based `Dockerfile.installci` image. - Used by the new `install-tests-conda` CI job. **`tools/run_install_ci.py`** - Refactored build logic into `_build_image()` helper. - Added `--conda` flag: builds the uv base image first, then the conda layer on top; routes to the conda image for the Docker run. **`.github/workflows/install-ci.yml`** - Renamed existing job to `Installation Tests (uv)`; added `-m uv` so it only runs uv-marked tests. - Added `Installation Tests (conda)` job with `--conda` and `-m conda`, `timeout-minutes: 150` (extra headroom for two-stage Docker build). - Fixed SIGPIPE in `render_table` / `any_match` shell functions: replaced `printf '%s\n' "$files" | grep` with `grep <<< "$files"` to avoid broken-pipe signals when `grep` exits early with `-m` or `-q`. --- ## Testing - `test_install_command_parsing.py`: 41 unit tests, all pass without GPU or network. - Install + training verified end-to-end on this machine: - `./isaaclab.sh -i newton,ov,rl[rsl_rl]` → clean install, no warnings - `./isaaclab.sh train --rl_library rsl_rl --task Isaac-Cartpole-Direct-v0 --num_envs=16 --max_iterations=10 "presets=newton" --headless` → 10 iterations, exit 0, ~2900 steps/sec --- ## Migration guide | Old command | New command | |---|---| | `./isaaclab.sh -i assets,tasks,physx,contrib` | `./isaaclab.sh -i none` (all core always installed) | | `./isaaclab.sh -i assets,tasks,ov,rl[rsl-rl]` | `./isaaclab.sh -i ov,rl[rsl-rl]` | | `./isaaclab.sh -i newton,rl[all]` | unchanged | | `./isaaclab.sh -i mimic,teleop` | unchanged | | `uv pip install isaaclab[tasks,rl,assets]` | `uv pip install isaaclab[all]` | --- .github/workflows/install-ci.yml | 34 +- docker/test/test_dockerfile_nonroot.py | 29 +- .../include/src_build_isaaclab.rst | 97 ++++- .../installation/kitless_installation.rst | 81 ++-- pyproject.toml | 48 +-- .../kellyguo11-fix-modular-install.minor.rst | 35 ++ source/isaaclab/isaaclab/cli/__init__.py | 56 ++- .../isaaclab/isaaclab/cli/commands/install.py | 340 +++++++++------ source/isaaclab/setup.py | 30 +- .../test/install_ci}/Dockerfile.installci | 17 +- .../install_ci/Dockerfile.installci-conda | 50 +++ source/isaaclab/test/install_ci/conftest.py | 2 + .../test_install_command_parsing.py | 402 ++++++++++++++++++ .../test_install_workflow_training.py | 231 ++++++++++ .../test/install_ci/test_isaaclabx_i_mimic.py | 94 ++++ .../test/install_ci/test_isaaclabx_i_none.py | 120 ++++++ .../test/install_ci/test_isaaclabx_i_physx.py | 32 +- .../test/install_ci/test_isaaclabx_i_rl.py | 202 +++++++++ .../install_ci/test_isaaclabx_i_visualizer.py | 161 +++++++ .../install_ci/test_isaaclabx_uv_smoke.py | 39 +- .../install_ci/test_isaaclabx_uv_training.py | 15 +- source/isaaclab/test/install_ci/utils.py | 84 ++++ .../test_multi_mesh_ray_caster_camera.py | 1 + .../kellyguo11-fix-modular-install.minor.rst | 7 + source/isaaclab_mimic/setup.py | 13 +- tools/run_install_ci.py | 174 ++++++-- 26 files changed, 2044 insertions(+), 350 deletions(-) create mode 100644 source/isaaclab/changelog.d/kellyguo11-fix-modular-install.minor.rst rename {docker => source/isaaclab/test/install_ci}/Dockerfile.installci (72%) create mode 100644 source/isaaclab/test/install_ci/Dockerfile.installci-conda create mode 100644 source/isaaclab/test/install_ci/test_install_command_parsing.py create mode 100644 source/isaaclab/test/install_ci/test_install_workflow_training.py create mode 100644 source/isaaclab/test/install_ci/test_isaaclabx_i_mimic.py create mode 100644 source/isaaclab/test/install_ci/test_isaaclabx_i_none.py create mode 100644 source/isaaclab/test/install_ci/test_isaaclabx_i_rl.py create mode 100644 source/isaaclab/test/install_ci/test_isaaclabx_i_visualizer.py create mode 100644 source/isaaclab_mimic/changelog.d/kellyguo11-fix-modular-install.minor.rst diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index 9a720575118c..69b509edba80 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -132,7 +132,7 @@ jobs: fi install-tests: - name: Installation Tests + name: Installation Tests (uv) needs: [changes] if: needs.changes.outputs.run_install_tests == 'true' runs-on: [self-hosted, gpu] @@ -140,7 +140,7 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 # v6 - - name: Run installation tests + - name: Run installation tests (uv) env: BASE_IMAGE: ${{ github.event_name == 'workflow_dispatch' && inputs.base_image || 'ubuntu:24.04' }} TEST_FILTER: ${{ github.event_name == 'workflow_dispatch' && inputs.test_filter || '' }} @@ -148,8 +148,36 @@ jobs: RUNNER_ARGS="--base-image $BASE_IMAGE" RUNNER_ARGS="$RUNNER_ARGS --results-dir ${{ github.workspace }}/results" RUNNER_ARGS="$RUNNER_ARGS --gpu" + RUNNER_ARGS="$RUNNER_ARGS --no-cache" - PYTEST_EXTRA_ARGS=(-sv) + PYTEST_EXTRA_ARGS=(-sv -m uv) + if [ -n "$TEST_FILTER" ]; then + PYTEST_EXTRA_ARGS+=(-k "$TEST_FILTER") + fi + + tools/run_install_ci.py docker $RUNNER_ARGS -- --tb=short "${PYTEST_EXTRA_ARGS[@]}" + + install-tests-conda: + name: Installation Tests (conda) + needs: [changes] + if: needs.changes.outputs.run_install_tests == 'true' + runs-on: [self-hosted, gpu] + timeout-minutes: 150 + steps: + - name: Checkout + uses: actions/checkout@v6 # v6 + - name: Run installation tests (conda) + env: + BASE_IMAGE: ${{ github.event_name == 'workflow_dispatch' && inputs.base_image || 'ubuntu:24.04' }} + TEST_FILTER: ${{ github.event_name == 'workflow_dispatch' && inputs.test_filter || '' }} + run: | + RUNNER_ARGS="--base-image $BASE_IMAGE" + RUNNER_ARGS="$RUNNER_ARGS --results-dir ${{ github.workspace }}/results-conda" + RUNNER_ARGS="$RUNNER_ARGS --gpu" + RUNNER_ARGS="$RUNNER_ARGS --conda" + RUNNER_ARGS="$RUNNER_ARGS --no-cache" + + PYTEST_EXTRA_ARGS=(-sv -m conda) if [ -n "$TEST_FILTER" ]; then PYTEST_EXTRA_ARGS+=(-k "$TEST_FILTER") fi diff --git a/docker/test/test_dockerfile_nonroot.py b/docker/test/test_dockerfile_nonroot.py index 2ba800d91365..aefbd566bba0 100644 --- a/docker/test/test_dockerfile_nonroot.py +++ b/docker/test/test_dockerfile_nonroot.py @@ -8,19 +8,31 @@ import pytest -DOCKER_DIR = Path(__file__).resolve().parent.parent -DOCKERFILES = sorted(DOCKER_DIR.glob("Dockerfile.*")) +REPO_ROOT = Path(__file__).resolve().parents[2] +DOCKER_DIR = REPO_ROOT / "docker" + +# Collect every Dockerfile.* from the entire repository tree. +DOCKERFILES = sorted(REPO_ROOT.glob("**/Dockerfile.*")) + ROOT_USERS = {"root", "0"} # Keep every Dockerfile in this map so new containers must make an explicit # runtime-user decision instead of silently escaping this regression test. +# Keys are Dockerfile *names* (unique across the repo); values are the +# expected final USER directive (None = not yet migrated, test skipped). DOCKERFILE_RUNTIME_USERS = { "Dockerfile.base": "isaaclab", "Dockerfile.curobo": "isaaclab", - "Dockerfile.installci": None, + "Dockerfile.installci": "isaaclab", + "Dockerfile.installci-conda": "isaaclab", "Dockerfile.ros2": "isaaclab", } -DOCKERFILES_CREATING_RUNTIME_USER = {"Dockerfile.base", "Dockerfile.curobo"} + +# Dockerfiles that are expected to *create* the non-root runtime user +# (i.e. contain groupadd/useradd/USER isaaclab). Inherited-user images +# (like Dockerfile.installci-conda which builds on top of Dockerfile.installci) +# are excluded here. +DOCKERFILES_CREATING_RUNTIME_USER = {"Dockerfile.base", "Dockerfile.curobo", "Dockerfile.installci"} USER_DIRECTIVE_RE = re.compile(r"^USER\s+(\S+)\s*$") @@ -42,6 +54,13 @@ def _final_user(dockerfile_path: Path) -> str | None: return users[-1] if users else None +def _find_dockerfile(name: str) -> Path: + """Return the path of the unique Dockerfile with the given name.""" + matches = [p for p in DOCKERFILES if p.name == name] + assert len(matches) == 1, f"Expected exactly one {name}, found: {matches}" + return matches[0] + + def test_all_dockerfiles_have_runtime_user_expectations(): expected_dockerfiles = set(DOCKERFILE_RUNTIME_USERS) actual_dockerfiles = {dockerfile.name for dockerfile in DOCKERFILES} @@ -63,7 +82,7 @@ def test_non_root_runtime_dockerfiles(dockerfile: Path): @pytest.mark.parametrize("dockerfile_name", sorted(DOCKERFILES_CREATING_RUNTIME_USER)) def test_dockerfile_creates_non_root_runtime_user(dockerfile_name: str): - dockerfile_text = (DOCKER_DIR / dockerfile_name).read_text(encoding="utf-8") + dockerfile_text = _find_dockerfile(dockerfile_name).read_text(encoding="utf-8") assert re.search(r"\bgroupadd\b.*--gid\s+1000\b.*\bisaaclab\b", dockerfile_text, re.DOTALL) assert re.search(r"\buseradd\b.*--uid\s+1000\b.*--gid\s+1000\b.*\bisaaclab\b", dockerfile_text, re.DOTALL) diff --git a/docs/source/setup/installation/include/src_build_isaaclab.rst b/docs/source/setup/installation/include/src_build_isaaclab.rst index 3d93e8f3452d..2335a968c7d9 100644 --- a/docs/source/setup/installation/include/src_build_isaaclab.rst +++ b/docs/source/setup/installation/include/src_build_isaaclab.rst @@ -16,8 +16,8 @@ Installation sudo apt install python3.12-dev libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev -- Run the install command that iterates over all the extensions in ``source`` directory and installs them - using pip (with ``--editable`` flag): +- Run the install command, which installs all core Isaac Lab packages and, by default, + the standard optional submodules and auto-selected extras: .. tab-set:: :sync-group: os @@ -37,33 +37,55 @@ Installation isaaclab.bat --install :: or "isaaclab.bat -i" - By default, the above will install **all** Isaac Lab submodules (under ``source/isaaclab``). - To install only specific Isaac Lab submodules, pass a comma-separated list of submodule names. The available - Isaac Lab submodules are: ``assets``, ``contrib``, ``mimic``, ``newton``, ``ov``, ``physx``, ``rl``, ``tasks``, - ``teleop``, ``visualizers``. Available RL frameworks are: ``rl_games``, ``rsl_rl``, ``sb3``, ``skrl``, ``robomimic``. + All core submodules are **always** installed regardless of what is passed to ``-i``. + The argument controls which optional submodules and extra feature dependencies to add on top. + The contrib and OV source packages (``isaaclab_contrib``, ``isaaclab_ov``, and + ``isaaclab_ovphysx``) are part of the core set so core modules and task configs can + import their config and preset classes without installing their heavy runtime dependencies. - For example, to install a small subset of submodules: + **Optional submodules**: - .. tab-set:: - :sync-group: os + .. list-table:: + :header-rows: 1 - .. tab-item:: :icon:`fa-brands fa-linux` Linux - :sync: linux + * - Token + - What it installs + * - ``mimic`` + - ``isaaclab_mimic`` (ipywidgets, h5py, imitation-learning tools) + * - ``teleop`` + - ``isaaclab_teleop`` (isaacteleop SDK, dex-retargeting — Linux x86 only) - .. code:: bash + **Optional extra feature sets** (heavy optional deps on top of core packages): - ./isaaclab.sh --install physx,newton,assets,rl[rsl_rl],tasks,ov # or "./isaaclab.sh -i physx,newton,assets,rl[rsl_rl],tasks,ov" + .. list-table:: + :header-rows: 1 - .. tab-item:: :icon:`fa-brands fa-windows` Windows - :sync: windows + * - Token + - What it installs + * - ``contrib[]`` + - Contrib runtime extras. Selector: ``rlinf``. + * - ``newton`` + - Newton physics library (``newton[sim]`` git dep) across ``isaaclab_newton``, ``isaaclab_physx``, ``isaaclab_visualizers`` + * - ``ov[]`` + - OV runtime wheels. Selectors: ``ovrtx``, ``ovphysx``. Use ``ov[all]`` for both. + * - ``rl[]`` + - RL framework extras on ``isaaclab_rl``. Selectors: ``rsl-rl``, ``skrl``, ``sb3``, ``rl-games``. Omit selector for all. + * - ``visualizer[]`` + - Visualizer backend extras. Selectors: ``rerun``, ``viser``, ``newton``, ``kit``. Omit selector for all. - .. code:: batch + **Special values**: + + - ``all`` — core + optional submodules (mimic, teleop) + auto extra features (newton, rl, visualizer) — default when ``-i`` is used with no argument + - ``none`` — core submodules only; no optional submodules, no extra feature dependencies - isaaclab.bat --install physx,newton,assets,rl[rsl_rl],tasks,ov :: or "isaaclab.bat -i physx,newton,assets,rl[rsl_rl],tasks,ov" + .. note:: - To install specific visualizer, pass a comma-separated list of supported visualizers, - or ``all`` to install all available options: ``newton``, ``rerun``, ``viser``, ``kit``. Note when following the - default installation, all visualizers are installed. + ``all`` installs the contrib and OV source packages, but not their heavy + dependency extras. Use ``contrib[rlinf]`` for rlinf + dependencies and ``ov[ovrtx]``, ``ov[ovphysx]``, or ``ov[all]`` for OV runtime + wheels. + + Examples: .. tab-set:: :sync-group: os @@ -73,14 +95,43 @@ Installation .. code:: bash - ./isaaclab.sh --install visualizers[rerun] # or "./isaaclab.sh -i visualizers[rerun]" + # Default: core + optional submodules + auto extras + ./isaaclab.sh -i + + # Newton physics + RSL-RL framework + ./isaaclab.sh -i 'newton,rl[rsl-rl]' + + # Newton + rerun visualizer + mimic + ./isaaclab.sh -i 'newton,visualizer[rerun],mimic' + + # OV source packages + OVRTX wheel + ./isaaclab.sh -i 'ov[ovrtx]' + + # Contrib rlinf dependencies + ./isaaclab.sh -i 'contrib[rlinf]' + + # Core only — no optional submodules, no extras + ./isaaclab.sh -i none .. tab-item:: :icon:`fa-brands fa-windows` Windows :sync: windows .. code:: batch - isaaclab.bat --install visualizers[rerun] :: or "isaaclab.bat -i visualizers[rerun]" + :: Default: core + optional submodules + auto extras + isaaclab.bat -i + + :: Newton physics + RSL-RL framework + isaaclab.bat -i "newton,rl[rsl-rl]" + + :: Newton + rerun visualizer + mimic + isaaclab.bat -i "newton,visualizer[rerun],mimic" + + :: OV source packages + OVRTX wheel + isaaclab.bat -i "ov[ovrtx]" + :: Contrib rlinf dependencies + isaaclab.bat -i "contrib[rlinf]" - Pass ``none`` to install only the core ``isaaclab`` package without any Isaac Lab submodules or RL frameworks. + :: Core only - no optional submodules, no extras + isaaclab.bat -i none diff --git a/docs/source/setup/installation/kitless_installation.rst b/docs/source/setup/installation/kitless_installation.rst index 19303b842b9d..644acbb07c1b 100644 --- a/docs/source/setup/installation/kitless_installation.rst +++ b/docs/source/setup/installation/kitless_installation.rst @@ -80,40 +80,45 @@ To install Isaac Sim, use the pip method described in :doc:`pip_installation`. Selective Install ----------------- -If you want a minimal environment, ``./isaaclab.sh -i`` accepts comma-separated -sub-package names: +``./isaaclab.sh -i`` always installs the full core package set (assets, tasks, physx, rl, +visualizers, …). The argument controls which **optional** submodules and extra feature +dependencies are added on top. + +**Optional submodules** (heavy — must be explicitly requested): .. list-table:: :header-rows: 1 - * - Option - - What it does - * - ``isaacsim`` - - Install Isaac Sim pip package + * - Token + - What it installs + * - ``mimic`` + - ``isaaclab_mimic`` — imitation-learning tools (ipywidgets, h5py) + * - ``teleop`` + - ``isaaclab_teleop`` — teleoperation SDK (Linux x86 only) + +**Optional extra feature sets** (heavy deps on top of always-installed core): + +.. list-table:: + :header-rows: 1 + + * - Token + - What it installs * - ``newton`` - - Install Newton physics + Newton visualizer - * - ``physx`` - - Install PhysX physics runtime + - Newton physics library (``newton[sim]``) + newton extras across ``isaaclab_newton``, ``isaaclab_physx``, ``isaaclab_visualizers`` + * - ``rl[]`` + - RL framework. Selectors: ``rsl-rl``, ``skrl``, ``sb3``, ``rl-games``. Omit selector for all. + * - ``visualizer[]`` + - Visualizer backend. Selectors: ``rerun``, ``viser``, ``newton``, ``kit``. Omit selector for all. + * - ``contrib[rlinf]`` + - rlinf extras (ray, diffusers, etc.) * - ``ov`` - - Install Omniverse renderer runtime - * - ``tasks`` - - Install built-in task environments - * - ``assets`` - - Install robot/object configurations - * - ``visualizers`` - - Install all visualizer backends - * - ``rsl_rl`` - - Install RSL-RL framework - * - ``skrl`` - - Install skrl framework - * - ``sb3`` - - Install Stable Baselines3 framework - * - ``rl_games`` - - Install rl_games framework - * - ``robomimic`` - - Install robomimic framework + - OVRTX + OVPhysX extras for Omniverse rendering + * - ``isaacsim`` + - Isaac Sim pip package + * - ``all`` + - Core + optional submodules (mimic, teleop) + auto extras (newton, rl, visualizer, ov). Default. Does not include ``contrib``. * - ``none`` - - Install only core ``isaaclab`` package + - Core packages only — no optional submodules, no extra feature deps Examples: @@ -125,22 +130,28 @@ Examples: .. code-block:: bash - # Minimal Newton setup - ./isaaclab.sh -i newton,tasks,assets,ov,rl[rsl_rl] + # Core only (physx, tasks, assets always included — no optional extras) + ./isaaclab.sh -i none - # Newton with OVRTX, RSL-RL, and Newton visualizer - ./isaaclab.sh -i newton,tasks,assets,ov[ovrtx],rl[rsl_rl],visualizers[newton] + # Newton physics + RSL-RL (most common kitless setup) + ./isaaclab.sh -i newton,'rl[rsl-rl]' + + # Newton + OVRTX renderer + RSL-RL + Newton visualizer + ./isaaclab.sh -i newton,ov,'rl[rsl-rl]','visualizer[newton]' .. tab-item:: :icon:`fa-brands fa-windows` Windows :sync: windows .. code-block:: batch - :: Minimal Newton setup - isaaclab.bat -i newton,tasks,assets,ov,rl[rsl_rl] + :: Core only + isaaclab.bat -i none + + :: Newton physics + RSL-RL + isaaclab.bat -i newton,rl[rsl-rl] - :: Newton with OVRTX, RSL-RL, and Newton visualizer - isaaclab.bat -i newton,tasks,assets,ov[ovrtx],rl[rsl_rl],visualizers[newton] + :: Newton + OVRTX + RSL-RL + Newton visualizer + isaaclab.bat -i newton,ov,rl[rsl-rl],visualizer[newton] .. _installation-ovrtx: diff --git a/pyproject.toml b/pyproject.toml index 572395018482..974ab0caaf19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,57 +12,31 @@ dependencies = [ "isaaclab", "isaaclab-assets", "isaaclab-contrib", - "isaaclab-newton[all]", + "isaaclab-experimental", + "isaaclab-newton", "isaaclab-ov", "isaaclab-ovphysx", - "isaaclab-physx[newton]", - "isaaclab-rl[rsl-rl]", + "isaaclab-physx", + "isaaclab-rl", "isaaclab-tasks", + "isaaclab-tasks-experimental", + "isaaclab-visualizers", "torch==2.10.0", "torchaudio==2.10.0", "torchvision==0.25.0", ] [project.optional-dependencies] -assets = [ - "isaaclab-assets", +isaacsim = [ + "isaacsim[all,extscache]==5.1.0", ] -contrib = [ - "isaaclab-contrib", -] -mimic = [ +all = [ + "isaacsim[all,extscache]==5.1.0", "isaaclab-mimic", -] -newton = [ "isaaclab-newton[all]", "isaaclab-physx[newton]", -] -ov = [ - "isaaclab-ovphysx[ovphysx]", -] -physx = [ - "isaaclab-physx", -] -rl = [ - "isaaclab-rl[rsl-rl]", -] -rl-all = [ "isaaclab-rl[all]", -] -rtx = [ - "isaaclab-ov[ovrtx]", -] -tasks = [ - "isaaclab-assets", - "isaaclab-contrib", - "isaaclab-ov", - "isaaclab-ovphysx", - "isaaclab-tasks", -] -tasks-experimental = [ - "isaaclab-tasks-experimental", -] -visualizers = [ + "isaaclab-teleop", "isaaclab-visualizers[all]", ] diff --git a/source/isaaclab/changelog.d/kellyguo11-fix-modular-install.minor.rst b/source/isaaclab/changelog.d/kellyguo11-fix-modular-install.minor.rst new file mode 100644 index 000000000000..649b8f65c363 --- /dev/null +++ b/source/isaaclab/changelog.d/kellyguo11-fix-modular-install.minor.rst @@ -0,0 +1,35 @@ +Changed +^^^^^^^ + +* Changed the installation model of :meth:`~isaaclab.cli.commands.install.command_install` + from per-submodule selection to a three-tier system. All core submodules + (``isaaclab``, ``isaaclab_assets``, ``isaaclab_contrib``, ``isaaclab_experimental``, + ``isaaclab_newton``, ``isaaclab_ov``, ``isaaclab_ovphysx``, ``isaaclab_physx``, + ``isaaclab_rl``, ``isaaclab_tasks``, ``isaaclab_tasks_experimental``, + ``isaaclab_visualizers``) + are now always installed by ``./isaaclab.sh -i``. Optional submodules + (``mimic``, ``teleop``) and automatic extra feature sets + (``newton``, ``rl[...]``, ``visualizer[...]``) are installed by ``./isaaclab.sh -i`` + / ``./isaaclab.sh -i all``. + Optional dependency extras require selectors, so rlinf dependencies are + installed with ``contrib[rlinf]`` and the ``ovrtx`` / ``ovphysx`` wheels are installed + with ``ov[ovrtx]``, ``ov[ovphysx]``, or ``ov[all]``. Old per-submodule tokens (e.g. + ``assets``, ``tasks``, ``physx``) now emit a warning and are skipped gracefully. + Migrate using the table below: + + +----------------------------------------------+-------------------------------------------+ + | Old command | New command | + +==============================================+===========================================+ + | ``./isaaclab.sh -i assets,tasks,physx`` | ``./isaaclab.sh -i none`` | + +----------------------------------------------+-------------------------------------------+ + | ``./isaaclab.sh -i assets,tasks,ov,rl[...]`` | ``./isaaclab.sh -i ov[all],rl[...]`` | + +----------------------------------------------+-------------------------------------------+ + | ``./isaaclab.sh -i newton,rl[all]`` | unchanged | + +----------------------------------------------+-------------------------------------------+ + | ``./isaaclab.sh -i mimic,teleop`` | unchanged | + +----------------------------------------------+-------------------------------------------+ + | ``uv pip install isaaclab[tasks,rl,assets]`` | ``uv pip install isaaclab[all]`` | + +----------------------------------------------+-------------------------------------------+ + +* Simplified :mod:`isaaclab` package extras to ``isaacsim`` and ``all``; removed the old + per-submodule extras (``tasks``, ``rl``, ``assets``, etc.) from ``pip install isaaclab[...]``. diff --git a/source/isaaclab/isaaclab/cli/__init__.py b/source/isaaclab/isaaclab/cli/__init__.py index ee04e558705b..0d71d4e48bc0 100644 --- a/source/isaaclab/isaaclab/cli/__init__.py +++ b/source/isaaclab/isaaclab/cli/__init__.py @@ -9,7 +9,12 @@ from .commands.envs import command_setup_conda, command_setup_uv from .commands.format import command_format -from .commands.install import VALID_ISAACLAB_SUBMODULES, VALID_RL_FRAMEWORKS, command_install +from .commands.install import ( + CORE_ISAACLAB_SUBMODULES, + OPTIONAL_ISAACLAB_SUBMODULES, + VALID_EXTRA_FEATURES, + command_install, +) from .commands.misc import ( command_build_docs, command_new, @@ -61,28 +66,53 @@ def cli() -> None: ), ) - _submodules_str = ", ".join(sorted(VALID_ISAACLAB_SUBMODULES)) - _frameworks_str = ", ".join(sorted(VALID_RL_FRAMEWORKS)) + _optional_str = ", ".join(sorted(OPTIONAL_ISAACLAB_SUBMODULES)) + _extras_str = ", ".join(sorted(VALID_EXTRA_FEATURES)) + _core_str = ", ".join(CORE_ISAACLAB_SUBMODULES) parser.add_argument( "-i", "--install", nargs="?", const="all", help=( - "Install Isaac Lab submodules and RL frameworks.\n" - "Accepts a comma-separated list of submodule names, one of the RL frameworks, or a special value.\n" + "Install Isaac Lab submodules and optional extra dependencies.\n" + "\n" + "All core submodules are always installed:\n" + f" {_core_str}\n" + "\n" + "Accepts a comma-separated list of optional submodule names and/or\n" + "extra feature selectors, or one of the special values below.\n" "\n" - f"* Isaac Lab submodules: {_submodules_str}\n" - " Any submodule accepts an editable selector, e.g. visualizers[all|kit|newton|rerun|viser], rl[rsl_rl|skrl].\n" + f"* Optional submodules: {_optional_str}\n" + " Installed by 'all' or by explicit token.\n" "\n" - f"* RL frameworks: {_frameworks_str}\n" - " Passing an RL framework name installs all Isaac Lab submodules + that framework.\n" - " On Linux/macOS, quote selectors containing brackets: --install 'visualizers[rerun]'.\n" + f"* Extra feature sets: {_extras_str}\n" + " Install optional heavy dependencies for a feature on top of the core.\n" + " Supports an optional selector in brackets:\n" + " contrib[rlinf]\n" + " ov[ovrtx|ovphysx|all]\n" + " rl[rsl-rl|skrl|sb3|rl-games] (default: all)\n" + " visualizer[kit|newton|rerun|viser] (default: all)\n" + " On Linux/macOS, quote selectors containing brackets:\n" + " --install 'rl[rsl-rl]'\n" "\n" "* Special values:\n" - "- all - Install all Isaac Lab submodules + all RL frameworks (default).\n" - "- none - Install only the core 'isaaclab' package.\n" - "- (-i or --install without value) - Install all Isaac Lab submodules + all RL frameworks.\n" + " all - Core + optional submodules (mimic, teleop) + auto extra\n" + " features (newton, rl, visualizer). Does not install contrib/ov\n" + " dependency extras (default).\n" + " none - Core submodules only; no optional submodules, no extra features.\n" + " (-i with no value) - Same as 'all'.\n" + "\n" + "Note: Contrib and OV source packages are core; runtime dependencies require selectors:\n" + " ./isaaclab.sh -i 'contrib[rlinf]'\n" + " ./isaaclab.sh -i 'ov[ovrtx]'\n" + "\n" + "Examples:\n" + " ./isaaclab.sh -i\n" + " ./isaaclab.sh -i none\n" + " ./isaaclab.sh -i newton,'rl[rsl-rl]'\n" + " ./isaaclab.sh -i mimic,teleop,'visualizer[rerun]'\n" + " ./isaaclab.sh -i 'ov[ovrtx]'\n" "\n" ), ) diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index f83c4dfdbf87..90fa929b69e5 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -431,24 +431,44 @@ def _install_isaacsim() -> None: ) -# Valid Isaac Lab submodule names that can be passed to --install. -# Each Isaac Lab submodule maps to a source directory named "isaaclab_" under source/. -VALID_ISAACLAB_SUBMODULES: set[str] = { - "assets", +# Source directories installed on every ./isaaclab.sh -i invocation (even "none"). +# Order matters: isaaclab must be first so dependents resolve against the local copy. +CORE_ISAACLAB_SUBMODULES: list[str] = [ + "isaaclab", + "isaaclab_assets", + "isaaclab_contrib", + "isaaclab_experimental", + "isaaclab_newton", + "isaaclab_ov", + "isaaclab_ovphysx", + "isaaclab_physx", + "isaaclab_rl", + "isaaclab_tasks", + "isaaclab_tasks_experimental", + "isaaclab_visualizers", +] + +# Optional submodules — only installed when explicitly requested or with 'all'. +# Maps the short CLI name to one or more source directory names under source/. +OPTIONAL_ISAACLAB_SUBMODULES: dict[str, tuple[str, ...]] = { + "mimic": ("isaaclab_mimic",), + "teleop": ("isaaclab_teleop",), +} + +# Extra feature sets that install optional heavy dependencies on top of the +# always-installed core submodules. Each name corresponds to one or more +# 'pip install --editable path[extra]' calls against packages already in the +# core set. +VALID_EXTRA_FEATURES: set[str] = { "contrib", - "mimic", "newton", "ov", - "physx", "rl", - "tasks", - "teleop", - "visualizers", + "visualizer", } -# RL framework names accepted. -# Passing one of these installs all extensions + that framework. -VALID_RL_FRAMEWORKS: set[str] = {"rl_games", "rsl_rl", "sb3", "skrl", "robomimic"} +# Extra features excluded from the automatic ``-i all`` / ``-i`` install. +MANUAL_EXTRA_FEATURES: set[str] = {"contrib", "ov"} def _split_install_items(install_type: str) -> list[str]: @@ -474,25 +494,13 @@ def _split_install_items(install_type: str) -> list[str]: return parts -def _install_isaaclab_submodules( - isaaclab_submodules: list[str] | None = None, - submodule_extras: dict[str, str] | None = None, - exclude: set[str] | None = None, -) -> None: - """Install Isaac Lab submodules from the source directory. - - Scans ``source/`` for sub-directories that contain a ``setup.py`` and - installs each one as an editable pip package. +def _install_isaaclab_submodules(isaaclab_submodules: list[str]) -> None: + """Install Isaac Lab submodules from the source directory as editable packages. Args: - isaaclab_submodules: Optional, list of source directory names to install. - If ``None`` is provided, every submodule found under ``source/`` - is installed (subject to *exclude*). - submodule_extras: Optional mapping from submodule source directory - name to pip editable selector (e.g. - ``{"isaaclab_visualizers": "[rerun]"}``). - exclude: Optional set of source directory names to skip even when - *isaaclab_submodules* is ``None``. + isaaclab_submodules: Ordered list of source directory names to install + (e.g. ``["isaaclab", "isaaclab_assets", ...]``). ``isaaclab`` must + appear first so downstream packages resolve against the local copy. """ python_exe = extract_python_exe() source_dir = ISAACLAB_ROOT / "source" @@ -501,58 +509,132 @@ def _install_isaaclab_submodules( print_warning(f"Source directory not found: {source_dir}") return - # Collect installable submodules from source/. - install_items = [] - for item in source_dir.iterdir(): - if not (item.is_dir() and (item / "setup.py").exists()): - continue - if isaaclab_submodules is not None and item.name not in isaaclab_submodules: - continue - if exclude and item.name in exclude: - continue - install_items.append(item) - - # Install order matters for local editable deps: - # packages like isaaclab_visualizers depend on the local isaaclab package. - install_items.sort(key=lambda item: (item.name != "isaaclab", item.name)) - pip_cmd = get_pip_command(python_exe) - for item in install_items: - print_info(f"Installing submodule: {item.name}") - editable = (submodule_extras or {}).get(item.name, "") - install_target = f"{item}{editable}" - run_command(pip_cmd + ["install", "--editable", install_target]) + for pkg_name in isaaclab_submodules: + item = source_dir / pkg_name + if not item.is_dir() or not (item / "setup.py").exists(): + print_warning(f"Submodule directory not found or missing setup.py: {item}") + continue + print_info(f"Installing submodule: {pkg_name}") + run_command(pip_cmd + ["install", "--editable", str(item)]) _upgrade_extension_pip_dependencies( python_exe, pip_cmd, - item.name, + pkg_name, _get_extension_pip_upgrade_dependencies(item), ) -def _install_extra_frameworks(framework_name: str = "all") -> None: - """install the python packages for supported reinforcement learning frameworks +def _install_optional_submodule_extra_dependencies(submodule_name: str, selector: str) -> None: + """Install optional dependency extras for an optional submodule. + + Args: + submodule_name: One of :data:`OPTIONAL_ISAACLAB_SUBMODULES`. + selector: Extra selector from a token such as ``mimic[foo]``. + """ + if not selector: + return + + print_warning(f"Optional submodule '{submodule_name}' does not support selectors (got '{selector}').") + + +def _install_contrib_extra_dependencies(selector: str) -> None: + """Install optional contrib runtime dependencies. Args: - framework_name: Framework extra to install (for example ``all`` or ``none``). + selector: Contrib extra selector, currently ``rlinf``. """ + if not selector: + print_info( + "Contrib source package is installed with the core submodules. " + "Use 'contrib[rlinf]' to install contrib runtime dependencies." + ) + return + python_exe = extract_python_exe() pip_cmd = get_pip_command(python_exe) + source_dir = ISAACLAB_ROOT / "source" + + print_info(f"Installing contrib optional dependencies: {selector}...") + run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_contrib[{selector}]"]) - extras = "" - if framework_name != "none": - extras = f"[{framework_name}]" - # Check if specified which rl-framework to install. - if framework_name == "none": - print_info("No rl-framework will be installed.") +def _install_ov_extra_dependencies(selector: str) -> None: + """Install optional OV runtime dependencies. + + Args: + selector: One or more OV selectors from ``ov[ovrtx]``, + ``ov[ovphysx]``, or ``ov[all]``. + """ + if not selector: + print_info( + "OV source packages are installed with the core submodules. " + "Use 'ov[ovrtx]', 'ov[ovphysx]', or 'ov[all]' to install OV runtime dependencies." + ) return - print_info(f"Installing rl-framework: {framework_name}") + python_exe = extract_python_exe() + pip_cmd = get_pip_command(python_exe) + source_dir = ISAACLAB_ROOT / "source" + + selectors = {item.strip().lower() for item in selector.split(",") if item.strip()} + valid_selectors = {"all", "ovrtx", "ovphysx"} + unknown_selectors = selectors - valid_selectors + if unknown_selectors: + print_warning( + f"Unknown ov selector(s): {', '.join(sorted(unknown_selectors))}. " + f"Valid selectors: {', '.join(sorted(valid_selectors))}." + ) + if "all" in selectors: + selectors.update({"ovrtx", "ovphysx"}) + if "ovrtx" in selectors: + print_info("Installing OVRTX optional dependency...") + run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_ov[ovrtx]"]) + if "ovphysx" in selectors: + print_info("Installing OVPhysX optional dependency...") + run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_ovphysx[ovphysx]"]) + - # Install the learning frameworks specified. - run_command(pip_cmd + ["install", "-e", f"{ISAACLAB_ROOT}/source/isaaclab_rl{extras}"]) - run_command(pip_cmd + ["install", "-e", f"{ISAACLAB_ROOT}/source/isaaclab_mimic{extras}"]) +def _install_extra_feature(feature_name: str, selector: str = "") -> None: + """Install optional extra dependencies for a feature set. + + Each feature maps to one or more editable installs with extras applied to + packages that are already part of the core set. + + Args: + feature_name: One of :data:`VALID_EXTRA_FEATURES`. + selector: Optional extra selector (e.g. ``"rsl-rl"`` for + ``rl[rsl-rl]``). When empty a sensible default is chosen per + feature (``"all"`` for ``rl`` and ``visualizer``). + """ + python_exe = extract_python_exe() + pip_cmd = get_pip_command(python_exe) + source_dir = ISAACLAB_ROOT / "source" + + if feature_name == "contrib": + _install_contrib_extra_dependencies(selector) + elif feature_name == "newton": + if selector: + print_warning(f"'newton' does not support selectors (got '{selector}'). Installing all newton extras.") + print_info("Installing newton extras (newton[sim], PyOpenGL-accelerate, imgui-bundle)...") + run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_newton[all]"]) + run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_physx[newton]"]) + run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_visualizers[newton]"]) + elif feature_name == "rl": + extra = selector if selector else "all" + print_info(f"Installing RL framework extras: {extra}...") + run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_rl[{extra}]"]) + elif feature_name == "visualizer": + extra = selector if selector else "all" + print_info(f"Installing visualizer extras: {extra}...") + run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_visualizers[{extra}]"]) + elif feature_name == "ov": + _install_ov_extra_dependencies(selector) + else: + print_warning( + f"Unknown extra feature '{feature_name}'. " + f"Valid features: {', '.join(sorted(VALID_EXTRA_FEATURES))}. Skipping." + ) _PREBUNDLE_REPOINT_PACKAGES: list[str] = [ @@ -688,94 +770,92 @@ def _repoint_prebundle_packages() -> None: def command_install(install_type: str = "all") -> None: - """Install Isaac Lab extensions and optional submodules. + """Install Isaac Lab extensions and optional extras. + + All core submodules are always installed. Optional submodules, optional + submodule extras, and extra feature dependencies are installed based on + *install_type*. Args: - install_type: Comma-separated list of extras to install, or one of the - special values ``"all"`` / ``"none"``. Extra names match the keys - in ``source/isaaclab/setup.py``'s ``extras_require``: - * ``"all"`` (default) — install every extension found under - ``source/``, plus all RL frameworks. - * ``"none"`` — install only the "core" ``isaaclab`` package and skip - RL frameworks. - * Comma-separated extras, e.g. ``"mimic,assets"`` — install - only the "core" ``isaaclab`` package plus the listed submodules. + install_type: Controls which optional submodules and extra feature + dependencies to install on top of the always-installed core set. + + * ``"all"`` (default) — install core submodules + optional + submodules (``mimic``, ``teleop``) + all automatic + extra features. + * ``"none"`` — install core submodules only; no optional + submodules, no extra feature dependencies. + * Comma-separated tokens — install core submodules plus the listed + optional submodules and extra features. Valid tokens: + + - Optional submodules: ``mimic``, ``teleop`` + - Extra features: ``contrib[rlinf]``, ``newton``, ``rl[]``, + ``visualizer[]``, ``ov[ovrtx|ovphysx|all]`` + - Special: ``isaacsim`` + + Examples:: + + ./isaaclab.sh -i newton,rl[rsl-rl] + ./isaaclab.sh -i mimic,visualizer[rerun] + ./isaaclab.sh -i teleop,rl[skrl],newton """ # Install system dependencies first. _install_system_deps() - # Install the python packages in IsaacLab/source directory. print_info("Installing extensions inside the Isaac Lab repository...") python_exe = extract_python_exe() - # Show which environment is being used. if os.environ.get("VIRTUAL_ENV"): print_info(f"Using uv/venv environment: {os.environ['VIRTUAL_ENV']}") elif os.environ.get("CONDA_PREFIX"): print_info(f"Using conda environment: {os.environ['CONDA_PREFIX']}") - print_info(f"Python executable: {python_exe}") - # Decide which source directories (source/isaaclab/*) to install. - # "all" : install everything + all RL frameworks - # "none" : core isaaclab only, no RL frameworks - # RL framework : install everything + only that RL framework (e.g. "skrl") - # "a,b" : core + selected submodule directories, no RL frameworks install_isaacsim = False + # Always start with the full core set (isaaclab must be first). + submodules_to_install: list[str] = list(CORE_ISAACLAB_SUBMODULES) + # List of (feature_name, selector) tuples to apply after the base install. + extra_features: list[tuple[str, str]] = [] + # List of (submodule_name, selector) tuples for optional submodule extras. + optional_submodule_extra_dependencies: list[tuple[str, str]] = [] + + def append_submodules_once(package_dirs: tuple[str, ...]) -> None: + for pkg_dir in package_dirs: + if pkg_dir not in submodules_to_install: + submodules_to_install.append(pkg_dir) if install_type == "all": - isaaclab_submodules = None - exclude = None - submodule_extras = {"isaaclab_visualizers": "[all]"} - framework_type = "all" + for package_dirs in OPTIONAL_ISAACLAB_SUBMODULES.values(): + append_submodules_once(package_dirs) + extra_features = [(name, "") for name in sorted(VALID_EXTRA_FEATURES - MANUAL_EXTRA_FEATURES)] elif install_type == "none": - isaaclab_submodules = ["isaaclab"] - exclude = None - submodule_extras = {} - framework_type = "none" - elif install_type in VALID_RL_FRAMEWORKS: - isaaclab_submodules = None - exclude = None - submodule_extras = {"isaaclab_visualizers": "[all]"} - framework_type = install_type + # Core only — no optional submodules, no extra features. + pass else: - # Parse comma-separated submodule names and RL framework names. - isaaclab_submodules = ["isaaclab"] # core is always required - exclude = None # explicit selection — no exclusions - submodule_extras = {} - framework_type = "none" for token in _split_install_items(install_type): - # Parse optional editable selector: "name[extra1,extra2]" if "[" in token: bracket_pos = token.index("[") name = token[:bracket_pos].strip() - editable = token[bracket_pos:].strip() + if "]" not in token: + print_warning(f"Malformed install token '{token}': missing closing ']'. Skipping.") + continue + selector = token[bracket_pos + 1 : token.index("]")].strip() else: name = token.strip() - editable = "" + selector = "" + if name == "isaacsim": install_isaacsim = True - continue - if name in VALID_RL_FRAMEWORKS: - framework_type = name - # Ensure isaaclab_rl is installed so the framework extra works. - if "isaaclab_rl" not in isaaclab_submodules: - isaaclab_submodules.append("isaaclab_rl") - continue - if name in VALID_ISAACLAB_SUBMODULES: - pkg_dir = f"isaaclab_{name}" - if pkg_dir not in isaaclab_submodules: - isaaclab_submodules.append(pkg_dir) - if editable: - submodule_extras[pkg_dir] = editable - # Auto-include the matching visualizer when installing a physics backend. - if name == "newton" and "isaaclab_visualizers" not in isaaclab_submodules: - isaaclab_submodules.append("isaaclab_visualizers") - submodule_extras["isaaclab_visualizers"] = "[newton]" + elif name in OPTIONAL_ISAACLAB_SUBMODULES: + append_submodules_once(OPTIONAL_ISAACLAB_SUBMODULES[name]) + if selector: + optional_submodule_extra_dependencies.append((name, selector)) + elif name in VALID_EXTRA_FEATURES: + extra_features.append((name, selector)) else: - valid = sorted(VALID_ISAACLAB_SUBMODULES) + sorted(VALID_RL_FRAMEWORKS) + ["isaacsim"] - print_warning(f"Unknown Isaac Lab submodule '{name}'. Valid values: {', '.join(valid)}. Skipping.") + valid = sorted(OPTIONAL_ISAACLAB_SUBMODULES) + sorted(VALID_EXTRA_FEATURES) + ["isaacsim"] + print_warning(f"Unknown install token '{name}'. Valid values: {', '.join(valid)}. Skipping.") # Configure extra package indexes for NVIDIA and MuJoCo wheels. os.environ.setdefault("UV_EXTRA_INDEX_URL", "https://pypi.nvidia.com") @@ -841,12 +921,20 @@ def command_install(install_type: str = "all") -> None: # Install pytorch (version based on arch). _ensure_cuda_torch() - # Install the python modules for the extensions in Isaac Lab. - _install_isaaclab_submodules(isaaclab_submodules, submodule_extras, exclude) + # Install all submodules (core set + any explicitly requested optional ones). + _install_isaaclab_submodules(submodules_to_install) + + # Install requested optional submodule dependency extras. + if optional_submodule_extra_dependencies: + print_info("Installing optional submodule dependencies...") + for submodule_name, selector in optional_submodule_extra_dependencies: + _install_optional_submodule_extra_dependencies(submodule_name, selector) - # Install the python packages for supported reinforcement learning frameworks. - print_info("Installing extra requirements such as learning frameworks...") - _install_extra_frameworks(framework_type) + # Install requested extra feature dependencies. + if extra_features: + print_info("Installing extra feature dependencies...") + for feature_name, selector in extra_features: + _install_extra_feature(feature_name, selector) # In some rare cases, torch might not be installed properly by setup.py, add one more check here. # Can prevent that from happening. diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index 48dc5eff70aa..bc64609de3fd 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -87,32 +87,24 @@ PYTORCH_INDEX_URL = ["https://download.pytorch.org/whl/cu128"] -# Isaac Lab subpackages + Isaac Sim +# Optional extras for pip/uv installs. +# Use ``pip install isaaclab[isaacsim]`` to add Isaac Sim, or +# ``pip install isaaclab[all]`` to pull in all sub-packages and extras. EXTRAS_REQUIRE = { "isaacsim": ["isaacsim[all,extscache]==5.1.0"], - # Individual Isaac Lab sub-packages - "assets": ["isaaclab_assets"], - "physx": ["isaaclab_physx"], - "contrib": ["isaaclab_contrib"], - "mimic": ["isaaclab_mimic"], - "newton": ["isaaclab_newton"], - "rl": ["isaaclab_rl"], - "tasks": ["isaaclab_tasks"], - "teleop": ["isaaclab_teleop"], - "visualizers": ["isaaclab_visualizers[all]"], - "visualizers-kit": ["isaaclab_visualizers[kit]"], - "visualizers-newton": ["isaaclab_visualizers[newton]"], - "visualizers-rerun": ["isaaclab_visualizers[rerun]"], - "visualizers-viser": ["isaaclab_visualizers[viser]"], - # Convenience: all sub-packages (does not include isaacsim) "all": [ + "isaacsim[all,extscache]==5.1.0", "isaaclab_assets", - "isaaclab_physx", "isaaclab_contrib", + "isaaclab_experimental", "isaaclab_mimic", - "isaaclab_newton", - "isaaclab_rl", + "isaaclab_newton[all]", + "isaaclab_ov", + "isaaclab_ovphysx", + "isaaclab_physx[newton]", + "isaaclab_rl[all]", "isaaclab_tasks", + "isaaclab_tasks_experimental", "isaaclab_teleop", "isaaclab_visualizers[all]", ], diff --git a/docker/Dockerfile.installci b/source/isaaclab/test/install_ci/Dockerfile.installci similarity index 72% rename from docker/Dockerfile.installci rename to source/isaaclab/test/install_ci/Dockerfile.installci index 30da2b55b82a..0e149e61b84d 100644 --- a/docker/Dockerfile.installci +++ b/source/isaaclab/test/install_ci/Dockerfile.installci @@ -38,14 +38,13 @@ RUN apt-get update && \ # Make python3 the default python RUN ln -sf /usr/bin/python3 /usr/bin/python -# Install test runner dependencies +# Install test runner dependencies into the system Python RUN pip install --break-system-packages \ pytest>=8.0 \ pytest-timeout>=2.0 -# Install uv -RUN curl -LsSf https://astral.sh/uv/install.sh | sh -ENV PATH="/root/.local/bin:${PATH}" +# Install uv system-wide so non-root users can invoke it without PATH trickery +RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh # Copy the repo ARG ISAACLAB_PATH=/workspace/isaaclab @@ -53,12 +52,20 @@ ENV ISAACLAB_PATH=${ISAACLAB_PATH} COPY . ${ISAACLAB_PATH} # Fix line endings in .sh files (Win git may add \r) -# This hack need to be in the main Dockerfiles too RUN find ${ISAACLAB_PATH} -type f -name "*.sh" -exec sed -i 's/\r$//' {} + RUN chmod +x ${ISAACLAB_PATH}/isaaclab.sh +# Create non-root runtime user and transfer workspace ownership. +# uid/gid 1000 match the GitHub runner bind-mounts used by Docker-based tests. +# --non-unique allows reuse of existing uid/gid slots in some base images. +RUN groupadd --non-unique --gid 1000 isaaclab \ + && useradd --non-unique --uid 1000 --gid 1000 -m -s /bin/bash isaaclab \ + && chown -R isaaclab:isaaclab ${ISAACLAB_PATH} + WORKDIR ${ISAACLAB_PATH} +USER isaaclab + # isaaclab.x debug mode ENV DEBUG=1 diff --git a/source/isaaclab/test/install_ci/Dockerfile.installci-conda b/source/isaaclab/test/install_ci/Dockerfile.installci-conda new file mode 100644 index 000000000000..3a90d5c070b5 --- /dev/null +++ b/source/isaaclab/test/install_ci/Dockerfile.installci-conda @@ -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 + +# Conda-enabled layer for installation CI tests. +# Builds on top of the uv-based installci image. +# +# Usage: +# # First build the base uv image +# docker build --build-arg BASE_IMAGE=ubuntu:24.04 \ +# -f docker/Dockerfile.installci -t isaaclab-installci:uv . +# # Then build this conda layer on top +# docker build --build-arg UV_IMAGE=isaaclab-installci:uv \ +# -f docker/Dockerfile.installci-conda -t isaaclab-installci:conda . +# docker run --rm --gpus all isaaclab-installci:conda -v -m conda + +ARG UV_IMAGE=isaaclab-installci:uv +FROM ${UV_IMAGE} + +SHELL ["/bin/bash", "-c"] + +# Switch to root for system-level conda installation +USER root + +# Install Miniconda into the isaaclab user's home directory so the runtime user +# has full write access for creating environments and caching packages. +ARG MINICONDA_VERSION=latest +RUN curl -fsSL "https://repo.anaconda.com/miniconda/Miniconda3-${MINICONDA_VERSION}-Linux-x86_64.sh" \ + -o /tmp/miniconda.sh \ + && bash /tmp/miniconda.sh -b -p /home/isaaclab/miniconda3 \ + && rm /tmp/miniconda.sh \ + && chown -R isaaclab:isaaclab /home/isaaclab/miniconda3 + +ENV PATH="/home/isaaclab/miniconda3/bin:${PATH}" + +# Drop back to the non-root runtime user for all subsequent steps +USER isaaclab + +# Accept Miniconda default-channel Terms of Service non-interactively so that +# `conda create` works without user prompts inside the container. +# Runs as isaaclab so the acceptance is written to /home/isaaclab/.condarc. +RUN conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main && \ + conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r + +# Install test runner dependencies into the conda base Python +RUN pip install "pytest>=8.0" "pytest-timeout>=2.0" + +# Smoke-test: conda should be functional and pytest importable +RUN conda --version && python --version && python -m pytest --version diff --git a/source/isaaclab/test/install_ci/conftest.py b/source/isaaclab/test/install_ci/conftest.py index c4bbc94ab200..4965ac9dd778 100644 --- a/source/isaaclab/test/install_ci/conftest.py +++ b/source/isaaclab/test/install_ci/conftest.py @@ -85,6 +85,8 @@ def pytest_configure(config: pytest.Config) -> None: 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") + config.addinivalue_line("markers", "conda: tests that require the conda package manager") + config.addinivalue_line("markers", "timeout: per-test timeout in seconds") try: config.stash[_EXECUTION_ENVIRONMENT_KEY] = _utils.detect_execution_environment() diff --git a/source/isaaclab/test/install_ci/test_install_command_parsing.py b/source/isaaclab/test/install_ci/test_install_command_parsing.py new file mode 100644 index 000000000000..33cb55dba094 --- /dev/null +++ b/source/isaaclab/test/install_ci/test_install_command_parsing.py @@ -0,0 +1,402 @@ +# 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.py token parsing and command_install dispatch logic. + +These tests exercise the pure parsing logic and the install dispatch logic by +mocking all external I/O (pip, subprocess, filesystem), so they can run +without a GPU or Isaac Sim installation. +""" + +from __future__ import annotations + +import os + +# --------------------------------------------------------------------------- +# Helpers to import install.py symbols with the isaaclab source root on PATH +# --------------------------------------------------------------------------- +import sys +from pathlib import Path +from unittest.mock import patch + +_ISAACLAB_SRC = Path(__file__).resolve().parents[2] +if str(_ISAACLAB_SRC) not in sys.path: + sys.path.insert(0, str(_ISAACLAB_SRC)) + +from isaaclab.cli.commands.install import ( + CORE_ISAACLAB_SUBMODULES, + MANUAL_EXTRA_FEATURES, + OPTIONAL_ISAACLAB_SUBMODULES, + VALID_EXTRA_FEATURES, + _split_install_items, + command_install, +) + + +def _optional_submodule_packages() -> list[str]: + """Return flattened optional submodule source package names.""" + return [pkg for packages in OPTIONAL_ISAACLAB_SUBMODULES.values() for pkg in packages] + + +# --------------------------------------------------------------------------- +# _split_install_items +# --------------------------------------------------------------------------- + + +class TestSplitInstallItems: + """Tests for _split_install_items().""" + + def test_single_token(self): + assert _split_install_items("newton") == ["newton"] + + def test_two_plain_tokens(self): + assert _split_install_items("newton,mimic") == ["newton", "mimic"] + + def test_token_with_selector(self): + assert _split_install_items("rl[rsl-rl]") == ["rl[rsl-rl]"] + + def test_comma_inside_brackets_not_split(self): + assert _split_install_items("rl[rsl-rl,skrl]") == ["rl[rsl-rl,skrl]"] + + def test_mixed_tokens(self): + result = _split_install_items("newton,rl[rsl-rl],mimic") + assert result == ["newton", "rl[rsl-rl]", "mimic"] + + def test_whitespace_stripped(self): + assert _split_install_items("newton , mimic") == ["newton", "mimic"] + + def test_empty_string(self): + assert _split_install_items("") == [] + + def test_all_special_value(self): + assert _split_install_items("all") == ["all"] + + def test_none_special_value(self): + assert _split_install_items("none") == ["none"] + + def test_visualizer_with_selector(self): + assert _split_install_items("visualizer[rerun]") == ["visualizer[rerun]"] + + def test_multiple_selectors_mixed(self): + result = _split_install_items("mimic,visualizer[rerun],rl[rsl-rl]") + assert result == ["mimic", "visualizer[rerun]", "rl[rsl-rl]"] + + def test_nested_brackets_depth(self): + # Depth > 1 should not split on commas. + result = _split_install_items("contrib[a[b,c]]") + assert result == ["contrib[a[b,c]]"] + + def test_missing_closing_bracket_not_split(self): + # A malformed token with no closing ']' should come through as one item; + # the install dispatcher is responsible for emitting the warning. + result = _split_install_items("rl[rsl-rl") + assert result == ["rl[rsl-rl"] + + +# --------------------------------------------------------------------------- +# Constants sanity checks +# --------------------------------------------------------------------------- + + +class TestInstallConstants: + """Sanity checks for module-level install constants.""" + + def test_core_submodules_starts_with_isaaclab(self): + assert CORE_ISAACLAB_SUBMODULES[0] == "isaaclab", ( + "isaaclab must be first so dependents resolve against the local copy" + ) + + def test_core_submodules_contains_expected_packages(self): + expected = { + "isaaclab", + "isaaclab_assets", + "isaaclab_contrib", + "isaaclab_experimental", + "isaaclab_newton", + "isaaclab_ov", + "isaaclab_ovphysx", + "isaaclab_physx", + "isaaclab_rl", + "isaaclab_tasks", + "isaaclab_tasks_experimental", + "isaaclab_visualizers", + } + assert set(CORE_ISAACLAB_SUBMODULES) == expected + + def test_optional_submodules_contains_expected_packages(self): + assert set(OPTIONAL_ISAACLAB_SUBMODULES.keys()) == {"mimic", "teleop"} + assert OPTIONAL_ISAACLAB_SUBMODULES["mimic"] == ("isaaclab_mimic",) + assert OPTIONAL_ISAACLAB_SUBMODULES["teleop"] == ("isaaclab_teleop",) + + def test_valid_extra_features(self): + expected = {"contrib", "newton", "ov", "rl", "visualizer"} + assert expected == VALID_EXTRA_FEATURES + + def test_manual_extra_features_subset_of_valid(self): + assert MANUAL_EXTRA_FEATURES <= VALID_EXTRA_FEATURES + + def test_manual_extra_features(self): + assert {"contrib", "ov"} == MANUAL_EXTRA_FEATURES + + def test_no_overlap_between_optional_submodules_and_extra_features(self): + assert not (set(OPTIONAL_ISAACLAB_SUBMODULES.keys()) & VALID_EXTRA_FEATURES) + + def test_optional_submodules_not_in_core(self): + core_names = set(CORE_ISAACLAB_SUBMODULES) + for pkg in _optional_submodule_packages(): + assert pkg not in core_names + + +# --------------------------------------------------------------------------- +# command_install dispatch tests (all external I/O mocked) +# --------------------------------------------------------------------------- + +_INSTALL_MODULE = "isaaclab.cli.commands.install" + +# Functions that must be mocked to prevent actual system calls. +_PATCHES = [ + f"{_INSTALL_MODULE}._install_system_deps", + f"{_INSTALL_MODULE}._install_isaaclab_submodules", + f"{_INSTALL_MODULE}._install_extra_feature", + f"{_INSTALL_MODULE}._install_optional_submodule_extra_dependencies", + f"{_INSTALL_MODULE}._install_isaacsim", + f"{_INSTALL_MODULE}._ensure_cuda_torch", + f"{_INSTALL_MODULE}._maybe_preinstall_arm_nlopt", + f"{_INSTALL_MODULE}._maybe_uninstall_prebundled_torch", + f"{_INSTALL_MODULE}._ensure_pink_ik_dependencies_installed", + f"{_INSTALL_MODULE}._repoint_prebundle_packages", + f"{_INSTALL_MODULE}.command_vscode_settings", + f"{_INSTALL_MODULE}.get_pip_command", + f"{_INSTALL_MODULE}.extract_python_exe", + # run_command is called directly inside command_install for pip/setuptools upgrades. + f"{_INSTALL_MODULE}.run_command", +] + + +def _make_mock_env(**extra_env): + """Return an os.environ copy suitable for mocking docker-detection.""" + env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "CONDA_PREFIX")} + env.update(extra_env) + return env + + +class TestCommandInstallDispatch: + """Test that command_install() calls the right functions with the right args.""" + + def _run(self, install_type: str): + """Invoke command_install() with all I/O mocked; return captured mock calls.""" + mocks = {} + patchers = [] + for target in _PATCHES: + p = patch(target) + m = p.start() + mocks[target.split(".")[-1]] = m + patchers.append(p) + + # Prevent docker-detection from reading /proc or .dockerenv. + env_patcher = patch.dict(os.environ, {}, clear=False) + exists_patcher = patch("os.path.exists", return_value=False) + env_patcher.start() + exists_patcher.start() + patchers.extend([env_patcher, exists_patcher]) + + try: + command_install(install_type) + finally: + for p in patchers: + p.stop() + + return mocks + + # --- "all" --- + + def test_all_installs_core_plus_optional_submodules(self): + mocks = self._run("all") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + # Core set must be present. + for pkg in CORE_ISAACLAB_SUBMODULES: + assert pkg in installed, f"Expected {pkg} in submodules for 'all'" + # Optional submodules must be present. + for pkg in _optional_submodule_packages(): + assert pkg in installed, f"Expected {pkg} (optional) in submodules for 'all'" + + def test_all_installs_auto_extra_features_not_manual(self): + mocks = self._run("all") + called_features = {c.args[0] for c in mocks["_install_extra_feature"].call_args_list} + expected = VALID_EXTRA_FEATURES - MANUAL_EXTRA_FEATURES + assert called_features == expected, f"'all' should install {expected}, got {called_features}" + + def test_all_does_not_install_optional_submodule_extras(self): + mocks = self._run("all") + mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() + + def test_all_does_not_install_manual_extra_dependencies(self): + mocks = self._run("all") + called_features = {c.args[0] for c in mocks["_install_extra_feature"].call_args_list} + assert "contrib" not in called_features + assert "ov" not in called_features + + def test_all_does_not_call_install_isaacsim(self): + mocks = self._run("all") + mocks["_install_isaacsim"].assert_not_called() + + # --- "none" --- + + def test_none_installs_only_core_submodules(self): + mocks = self._run("none") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) + + def test_none_installs_no_extra_features(self): + mocks = self._run("none") + mocks["_install_extra_feature"].assert_not_called() + + def test_none_does_not_install_optional_submodules(self): + mocks = self._run("none") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + for pkg in _optional_submodule_packages(): + assert pkg not in installed + + # --- extra features --- + + def test_newton_installs_core_plus_newton_extra(self): + mocks = self._run("newton") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) + mocks["_install_extra_feature"].assert_called_once_with("newton", "") + + def test_rl_with_selector(self): + mocks = self._run("rl[rsl-rl]") + mocks["_install_extra_feature"].assert_called_once_with("rl", "rsl-rl") + + def test_rl_without_selector(self): + mocks = self._run("rl") + mocks["_install_extra_feature"].assert_called_once_with("rl", "") + + def test_visualizer_with_selector(self): + mocks = self._run("visualizer[rerun]") + mocks["_install_extra_feature"].assert_called_once_with("visualizer", "rerun") + + # --- manual extra features and optional submodules --- + + def test_contrib_without_selector_dispatches_manual_extra_feature(self): + mocks = self._run("contrib") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) + mocks["_install_extra_feature"].assert_called_once_with("contrib", "") + mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() + + def test_contrib_with_selector_dispatches_manual_extra_feature(self): + mocks = self._run("contrib[rlinf]") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) + mocks["_install_extra_feature"].assert_called_once_with("contrib", "rlinf") + mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() + + def test_mimic_adds_mimic_to_submodules(self): + mocks = self._run("mimic") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert "isaaclab_mimic" in installed + mocks["_install_extra_feature"].assert_not_called() + mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() + + def test_ov_without_selector_dispatches_manual_extra_feature(self): + mocks = self._run("ov") + mocks["_install_extra_feature"].assert_called_once_with("ov", "") + mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() + + def test_ov_with_selector_dispatches_manual_extra_feature(self): + mocks = self._run("ov[ovrtx]") + mocks["_install_extra_feature"].assert_called_once_with("ov", "ovrtx") + mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() + + def test_teleop_adds_teleop_to_submodules(self): + mocks = self._run("teleop") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert "isaaclab_teleop" in installed + mocks["_install_extra_feature"].assert_not_called() + mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() + + # --- combined tokens --- + + def test_newton_and_rl_rsl_rl(self): + mocks = self._run("newton,rl[rsl-rl]") + calls = mocks["_install_extra_feature"].call_args_list + features = {(c.args[0], c.args[1]) for c in calls} + assert ("newton", "") in features + assert ("rl", "rsl-rl") in features + + def test_mimic_and_newton(self): + mocks = self._run("mimic,newton") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert "isaaclab_mimic" in installed + mocks["_install_extra_feature"].assert_called_once_with("newton", "") + + def test_mimic_and_teleop(self): + mocks = self._run("mimic,teleop") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert "isaaclab_mimic" in installed + assert "isaaclab_teleop" in installed + mocks["_install_extra_feature"].assert_not_called() + + # --- isaacsim token --- + + def test_isaacsim_token_triggers_isaacsim_install(self): + mocks = self._run("isaacsim") + mocks["_install_isaacsim"].assert_called_once() + + def test_isaacsim_still_installs_core_submodules(self): + mocks = self._run("isaacsim") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) + + # --- malformed tokens --- + + def test_malformed_bracket_token_emits_warning_and_installs_core(self): + with patch(f"{_INSTALL_MODULE}.print_warning") as mock_warn: + mocks = self._run("rl[rsl-rl") # missing closing bracket + mock_warn.assert_called_once() + warn_msg = mock_warn.call_args[0][0] + assert "rl[rsl-rl" in warn_msg + # Core submodules still installed. + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) + # No extra feature should be installed. + mocks["_install_extra_feature"].assert_not_called() + + def test_newton_with_selector_still_dispatches(self): + # The selector is forwarded to _install_extra_feature which emits the warning + # internally (that function is mocked here; the warning itself is tested separately). + mocks = self._run("newton[sim]") + mocks["_install_extra_feature"].assert_called_once_with("newton", "sim") + + # --- unknown token --- + + def test_unknown_token_emits_warning_and_installs_core(self): + with patch(f"{_INSTALL_MODULE}.print_warning") as mock_warn: + mocks = self._run("totally_unknown_package") + mock_warn.assert_called_once() + warn_msg = mock_warn.call_args[0][0] + assert "totally_unknown_package" in warn_msg + # Core submodules still installed. + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) + + # --- isaaclab is always first --- + + def test_isaaclab_is_first_in_submodules_for_all(self): + mocks = self._run("all") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert installed[0] == "isaaclab" + + def test_isaaclab_is_first_in_submodules_for_none(self): + mocks = self._run("none") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert installed[0] == "isaaclab" + + def test_isaaclab_is_first_when_mimic_added(self): + mocks = self._run("mimic") + installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert installed[0] == "isaaclab" diff --git a/source/isaaclab/test/install_ci/test_install_workflow_training.py b/source/isaaclab/test/install_ci/test_install_workflow_training.py new file mode 100644 index 000000000000..b10c958f5ab5 --- /dev/null +++ b/source/isaaclab/test/install_ci/test_install_workflow_training.py @@ -0,0 +1,231 @@ +# 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 + +"""End-to-end installation and training workflow tests. + +Covers every documented installation path: + - uv × kitless (core-only, ``-i none``) + - uv × newton training (``-i newton,rl[rsl-rl]``) + - uv × ov + newton training (``-i newton,ov,rl[rsl-rl]``) + - conda × kitless (core-only, ``-i none``) + - conda × newton training (``-i newton,rl[rsl-rl]``) + +Tests in this file are intentionally slow and GPU-dependent. They are +gated behind pytest markers so they only run in the appropriate CI +environment: + + ``@pytest.mark.uv`` – routed to the uv-based Docker image + ``@pytest.mark.conda`` – routed to the conda-enabled Docker image + ``@pytest.mark.gpu`` – requires a GPU + ``@pytest.mark.slow`` – skipped in fast/smoke runs +""" + +from __future__ import annotations + +import shutil + +import pytest +from utils import Conda_Mixin, UV_Mixin + +# --------------------------------------------------------------------------- +# Shared training helper +# --------------------------------------------------------------------------- + +_TRAIN_CMD = [ + "train", + "--rl_library", + "rsl_rl", + "--task", + "Isaac-Cartpole-Direct-v0", + "--num_envs", + "16", + "presets=newton_mjwarp", + "--max_iterations", + "5", + "--headless", +] + + +def _assert_training_passed(result) -> None: + output = result.stdout + (result.stderr or "") + assert result.returncode == 0, f"Training failed (rc={result.returncode}):\n{output}" + assert "Traceback (most recent call last):" not in output, f"Training produced a traceback:\n{output}" + assert "Training time:" in output, f"Training did not report completion:\n{output}" + + +# --------------------------------------------------------------------------- +# uv-based tests +# --------------------------------------------------------------------------- + + +class TestUVWorkflow(UV_Mixin): + """Installation and training smoke tests using uv environments.""" + + @classmethod + def setup_class(cls): + if not shutil.which("uv"): + pytest.skip("uv is not available") + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.gpu + @pytest.mark.timeout(900) + def test_uv_none_installs_core_submodules(self, isaaclab_root): + """``./isaaclab.sh -i none`` installs all core submodules without extras.""" + try: + self.create_uv_env(isaaclab_root) + result = self.run_in_uv_env( + [str(self.cli_script), "-i", "none"], + cwd=isaaclab_root, + timeout=600, + ) + assert result.returncode == 0, f"isaaclab -i none failed:\n{result.stdout}\n{result.stderr}" + output = result.stdout + result.stderr + # All core submodules should be installed; no optional tokens should warn + assert "WARNING" not in output or "Unknown install token" not in output, ( + f"Unexpected warnings from -i none:\n{output}" + ) + # Verify core packages importable + for pkg in ("isaaclab", "isaaclab_assets", "isaaclab_tasks", "isaaclab_physx"): + r = self.run_in_uv_env( + [str(self.python), "-c", f"import {pkg}; print({pkg!r}, 'ok')"], + cwd=isaaclab_root, + timeout=60, + ) + assert r.returncode == 0, f"{pkg} not importable after -i none:\n{r.stdout}\n{r.stderr}" + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.gpu + @pytest.mark.timeout(1200) + def test_uv_newton_rsl_rl_trains_cartpole(self, isaaclab_root): + """``./isaaclab.sh -i newton,rl[rsl-rl]`` + training completes successfully.""" + try: + self.create_uv_env(isaaclab_root) + result = self.run_in_uv_env( + [str(self.cli_script), "-i", "newton,rl[rsl-rl]"], + cwd=isaaclab_root, + timeout=900, + ) + assert result.returncode == 0, f"isaaclab -i newton,rl[rsl-rl] failed:\n{result.stdout}\n{result.stderr}" + result = self.run_in_uv_env( + [str(self.cli_script)] + _TRAIN_CMD, + cwd=isaaclab_root, + timeout=600, + ) + _assert_training_passed(result) + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.gpu + @pytest.mark.timeout(1800) + def test_uv_newton_ov_rsl_rl_trains_cartpole(self, isaaclab_root): + """``./isaaclab.sh -i newton,ov,rl[rsl-rl]`` + training completes successfully.""" + try: + self.create_uv_env(isaaclab_root) + result = self.run_in_uv_env( + [str(self.cli_script), "-i", "newton,ov,rl[rsl-rl]"], + cwd=isaaclab_root, + timeout=1200, + ) + assert result.returncode == 0, f"isaaclab -i newton,ov,rl[rsl-rl] failed:\n{result.stdout}\n{result.stderr}" + result = self.run_in_uv_env( + [str(self.cli_script)] + _TRAIN_CMD, + cwd=isaaclab_root, + timeout=600, + ) + _assert_training_passed(result) + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.gpu + @pytest.mark.timeout(1800) + def test_uv_all_trains_cartpole(self, isaaclab_root): + """``./isaaclab.sh -i all`` (full install) + training completes successfully.""" + try: + self.create_uv_env(isaaclab_root) + result = self.run_in_uv_env( + [str(self.cli_script), "-i", "all"], + cwd=isaaclab_root, + timeout=1200, + ) + assert result.returncode == 0, f"isaaclab -i all failed:\n{result.stdout}\n{result.stderr}" + result = self.run_in_uv_env( + [str(self.cli_script)] + _TRAIN_CMD, + cwd=isaaclab_root, + timeout=600, + ) + _assert_training_passed(result) + finally: + self.destroy_uv_env() + + +# --------------------------------------------------------------------------- +# conda-based tests +# --------------------------------------------------------------------------- + + +class TestCondaWorkflow(Conda_Mixin): + """Installation and training smoke tests using conda environments.""" + + @classmethod + def setup_class(cls): + if not shutil.which("conda"): + pytest.skip("conda is not available") + + @pytest.mark.conda + @pytest.mark.slow + @pytest.mark.gpu + @pytest.mark.timeout(1200) + def test_conda_none_installs_core_submodules(self, isaaclab_root): + """conda + ``./isaaclab.sh -i none`` installs all core submodules without extras.""" + try: + self.create_conda_env(isaaclab_root) + result = self.run_in_conda_env( + [str(self.cli_script), "-i", "none"], + cwd=isaaclab_root, + timeout=900, + ) + assert result.returncode == 0, f"conda isaaclab -i none failed:\n{result.stdout}\n{result.stderr}" + for pkg in ("isaaclab", "isaaclab_assets", "isaaclab_tasks", "isaaclab_physx"): + r = self.run_in_conda_env( + [str(self.python), "-c", f"import {pkg}; print({pkg!r}, 'ok')"], + cwd=isaaclab_root, + timeout=60, + ) + assert r.returncode == 0, f"{pkg} not importable after conda -i none:\n{r.stdout}\n{r.stderr}" + finally: + self.destroy_conda_env() + + @pytest.mark.conda + @pytest.mark.slow + @pytest.mark.gpu + @pytest.mark.timeout(1800) + def test_conda_newton_rsl_rl_trains_cartpole(self, isaaclab_root): + """conda + ``./isaaclab.sh -i newton,rl[rsl-rl]`` + training completes successfully.""" + try: + self.create_conda_env(isaaclab_root) + result = self.run_in_conda_env( + [str(self.cli_script), "-i", "newton,rl[rsl-rl]"], + cwd=isaaclab_root, + timeout=1200, + ) + assert result.returncode == 0, ( + f"conda isaaclab -i newton,rl[rsl-rl] failed:\n{result.stdout}\n{result.stderr}" + ) + result = self.run_in_conda_env( + [str(self.cli_script)] + _TRAIN_CMD, + cwd=isaaclab_root, + timeout=600, + ) + _assert_training_passed(result) + finally: + self.destroy_conda_env() diff --git a/source/isaaclab/test/install_ci/test_isaaclabx_i_mimic.py b/source/isaaclab/test/install_ci/test_isaaclabx_i_mimic.py new file mode 100644 index 000000000000..0fc5fbc99bcf --- /dev/null +++ b/source/isaaclab/test/install_ci/test_isaaclabx_i_mimic.py @@ -0,0 +1,94 @@ +# 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 the optional mimic submodule install (./isaaclab.sh -i mimic). + +``mimic`` is an optional submodule — it is not part of the always-installed +core set because its base dependencies (ipywidgets, h5py) are heavier than +the rest. Users who need imitation-learning workflows explicitly opt in with +``./isaaclab.sh -i mimic``. +""" + +from __future__ import annotations + +import shutil + +import pytest +from utils import UV_Mixin, find_isaaclab_root + + +class Test_Install_Mimic(UV_Mixin): + """./isaaclab.sh -i mimic: installs core + isaaclab_mimic.""" + + @classmethod + def setup_class(cls): + if not shutil.which("uv"): + pytest.skip("uv is not available") + + try: + import isaacsim # noqa: F401 + except ImportError: + if not (find_isaaclab_root() / "_isaac_sim").exists(): + pytest.skip("isaacsim is not importable and _isaac_sim link not found, skipping") + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(1800) + def test_mimic_importable_after_install(self, isaaclab_root): + """isaaclab_mimic is importable after ./isaaclab.sh -i mimic.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "mimic"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i mimic failed:\n{result.stdout}\n{result.stderr}" + + result = self.run_in_uv_env(["python", "-c", "import isaaclab_mimic; print('isaaclab_mimic ok')"]) + assert result.returncode == 0, f"import isaaclab_mimic failed:\n{result.stdout}\n{result.stderr}" + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(1800) + def test_mimic_not_installed_by_none(self, isaaclab_root): + """isaaclab_mimic is absent after ./isaaclab.sh -i none (core only).""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "none"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i none failed:\n{result.stdout}\n{result.stderr}" + + result = self.run_in_uv_env(["python", "-c", "import isaaclab_mimic"]) + assert result.returncode != 0, "isaaclab_mimic should not be installed after -i none" + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(1800) + def test_core_still_present_after_mimic_install(self, isaaclab_root): + """Core packages remain importable after ./isaaclab.sh -i mimic.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "mimic"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i mimic failed:\n{result.stdout}\n{result.stderr}" + + for pkg in ("isaaclab", "isaaclab_assets", "isaaclab_tasks", "isaaclab_rl"): + result = self.run_in_uv_env(["python", "-c", f"import {pkg}; print('{pkg} ok')"]) + assert result.returncode == 0, ( + f"import {pkg} failed after mimic install:\n{result.stdout}\n{result.stderr}" + ) + + finally: + self.destroy_uv_env() diff --git a/source/isaaclab/test/install_ci/test_isaaclabx_i_none.py b/source/isaaclab/test/install_ci/test_isaaclabx_i_none.py new file mode 100644 index 000000000000..471da4c8cfef --- /dev/null +++ b/source/isaaclab/test/install_ci/test_isaaclabx_i_none.py @@ -0,0 +1,120 @@ +# 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 the core install (./isaaclab.sh -i none). + +``./isaaclab.sh -i none`` installs the always-on core set of submodules without +any optional submodules (mimic, teleop) or optional extra dependencies +(newton physics library, RL frameworks, visualizer backends, OV wheels). All core +packages must be importable after this install, and training with the default physics +preset must succeed. +""" + +from __future__ import annotations + +import shutil + +import pytest +from utils import UV_Mixin, find_isaaclab_root + +# Core packages that must be importable after ``-i none``. +_CORE_PACKAGES = [ + "isaaclab", + "isaaclab_assets", + "isaaclab_contrib", + "isaaclab_experimental", + "isaaclab_newton", + "isaaclab_ov", + "isaaclab_ovphysx", + "isaaclab_physx", + "isaaclab_rl", + "isaaclab_tasks", + "isaaclab_tasks_experimental", + "isaaclab_visualizers", +] + + +class Test_Install_None(UV_Mixin): + """./isaaclab.sh -i none: core set installed, no optional extras.""" + + @classmethod + def setup_class(cls): + if not shutil.which("uv"): + pytest.skip("uv is not available") + + try: + import isaacsim # noqa: F401 + except ImportError: + if not (find_isaaclab_root() / "_isaac_sim").exists(): + pytest.skip("isaacsim is not importable and _isaac_sim link not found, skipping") + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(1800) + def test_core_install_all_packages_importable(self, isaaclab_root): + """All core packages are importable after ./isaaclab.sh -i none.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "none"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i none failed:\n{result.stdout}\n{result.stderr}" + + for pkg in _CORE_PACKAGES: + result = self.run_in_uv_env(["python", "-c", f"import {pkg}; print('{pkg} ok')"]) + assert result.returncode == 0, f"import {pkg} failed:\n{result.stdout}\n{result.stderr}" + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(1800) + def test_core_install_optional_submodules_not_installed(self, isaaclab_root): + """Optional submodules (mimic, teleop) are absent after -i none.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "none"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i none failed:\n{result.stdout}\n{result.stderr}" + + for pkg in ("isaaclab_mimic", "isaaclab_teleop"): + result = self.run_in_uv_env(["python", "-c", f"import {pkg}"]) + assert result.returncode != 0, f"{pkg} should not be installed after -i none" + + for pkg in ("ovrtx", "ovphysx"): + result = self.run_in_uv_env(["python", "-c", f"import {pkg}"]) + assert result.returncode != 0, f"{pkg} should not be installed after -i none" + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.gpu + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(3600) + def test_core_install_physx_tests_pass(self, isaaclab_root): + """isaaclab_physx tests pass after core install (physx is always in the core set).""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "none"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i none failed:\n{result.stdout}\n{result.stderr}" + + 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_i_physx.py b/source/isaaclab/test/install_ci/test_isaaclabx_i_physx.py index 04bf2b346b23..b9699e9d902d 100644 --- a/source/isaaclab/test/install_ci/test_isaaclabx_i_physx.py +++ b/source/isaaclab/test/install_ci/test_isaaclabx_i_physx.py @@ -3,7 +3,12 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Test installing isaaclab_physx and running its test suite.""" +"""Test that isaaclab_physx is importable after a core install (./isaaclab.sh -i none). + +Under the new installation model, ``isaaclab_physx`` is part of the always-installed +core set. A plain ``./isaaclab.sh -i none`` (core only, no optional extras) is +therefore sufficient to make ``isaaclab_physx`` importable and to run its test suite. +""" from __future__ import annotations @@ -14,23 +19,18 @@ class Test_Install_Physx(UV_Mixin): - """Install ./isaaclab.sh -i physx and run the isaaclab_physx test suite.""" + """Core install (./isaaclab.sh -i none) makes isaaclab_physx importable.""" @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 @@ -38,17 +38,23 @@ def setup_class(cls): @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.""" + def test_core_install_includes_physx_and_runs_tests(self, isaaclab_root): + """./isaaclab.sh -i none installs the core set (including physx) and tests pass.""" 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}" + # Core install — physx is part of the always-installed set. + result = self.run_in_uv_env([str(self.cli_script), "-i", "none"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i none failed:\n{result.stdout}\n{result.stderr}" + + # Verify isaaclab_physx is importable. + result = self.run_in_uv_env( + ["python", "-c", "import isaaclab_physx; print('isaaclab_physx ok')"], + ) + assert result.returncode == 0, f"import isaaclab_physx failed:\n{result.stdout}\n{result.stderr}" - # Run isaaclab_physx test suite + # Run the 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"], diff --git a/source/isaaclab/test/install_ci/test_isaaclabx_i_rl.py b/source/isaaclab/test/install_ci/test_isaaclabx_i_rl.py new file mode 100644 index 000000000000..076b4134c78e --- /dev/null +++ b/source/isaaclab/test/install_ci/test_isaaclabx_i_rl.py @@ -0,0 +1,202 @@ +# 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 RL framework extra-feature installs. + +Each test installs the core set + a specific RL framework via +``./isaaclab.sh -i 'rl[]'`` and then verifies that +(a) the framework is importable and (b) a short training run succeeds. + +Valid selectors for the ``rl`` feature: + - ``rsl-rl`` → rsl-rl-lib + - ``skrl`` → skrl + - ``sb3`` → stable-baselines3 + - ``rl-games`` → rl-games (git dep) + - (no selector / ``all``) → all frameworks +""" + +from __future__ import annotations + +import shutil + +import pytest +from utils import UV_Mixin, find_isaaclab_root + +_TRAIN_SCRIPT = "scripts/reinforcement_learning/{framework}/train.py" + +# (selector, importable_package, train_script_dir, train_extra_args) +_RL_CONFIGS = [ + ("rsl-rl", "rsl_rl", "rsl_rl", ["presets=newton_mjwarp"]), + ("skrl", "skrl", "skrl", []), + ("sb3", "stable_baselines3", "sb3", []), +] + + +class Test_Install_RL_Frameworks(UV_Mixin): + """./isaaclab.sh -i 'rl[]' installs the RL framework extras.""" + + @classmethod + def setup_class(cls): + if not shutil.which("uv"): + pytest.skip("uv is not available") + + try: + import isaacsim # noqa: F401 + except ImportError: + if not (find_isaaclab_root() / "_isaac_sim").exists(): + pytest.skip("isaacsim is not importable and _isaac_sim link not found, skipping") + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(1800) + @pytest.mark.parametrize("selector,import_pkg,_train_dir,_train_args", _RL_CONFIGS) + def test_rl_framework_importable_after_install(self, isaaclab_root, selector, import_pkg, _train_dir, _train_args): + """./isaaclab.sh -i 'rl[]' makes the framework importable.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", f"rl[{selector}]"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i rl[{selector}] failed:\n{result.stdout}\n{result.stderr}" + + result = self.run_in_uv_env(["python", "-c", f"import {import_pkg}; print('{import_pkg} ok')"]) + assert result.returncode == 0, ( + f"import {import_pkg} failed after rl[{selector}]:\n{result.stdout}\n{result.stderr}" + ) + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.gpu + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(3600) + def test_train_cartpole_rsl_rl(self, isaaclab_root): + """./isaaclab.sh -i 'newton,rl[rsl-rl]' then train Isaac-Cartpole-Direct-v0 with rsl_rl.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "newton,rl[rsl-rl]"], cwd=isaaclab_root) + assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}" + + result = self.run_in_uv_env( + [ + str(self.cli_script), + "-p", + "scripts/reinforcement_learning/rsl_rl/train.py", + "--task", + "Isaac-Cartpole-Direct-v0", + "--num_envs", + "64", + "presets=newton_mjwarp", + "--max_iterations", + "5", + "--headless", + ], + cwd=isaaclab_root, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, f"rsl_rl training failed (rc={result.returncode}):\n{output}" + assert "Traceback (most recent call last):" not in output, f"rsl_rl training raised an exception:\n{output}" + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.gpu + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(3600) + def test_train_cartpole_skrl(self, isaaclab_root): + """./isaaclab.sh -i 'newton,rl[skrl]' then train Isaac-Cartpole-Direct-v0 with skrl.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "newton,rl[skrl]"], cwd=isaaclab_root) + assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}" + + result = self.run_in_uv_env( + [ + str(self.cli_script), + "-p", + "scripts/reinforcement_learning/skrl/train.py", + "--task", + "Isaac-Cartpole-Direct-v0", + "--num_envs", + "64", + "presets=newton_mjwarp", + "--max_iterations", + "5", + "--headless", + ], + cwd=isaaclab_root, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, f"skrl training failed (rc={result.returncode}):\n{output}" + assert "Traceback (most recent call last):" not in output, f"skrl training raised an exception:\n{output}" + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.gpu + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(3600) + def test_train_cartpole_sb3(self, isaaclab_root): + """./isaaclab.sh -i 'newton,rl[sb3]' then train Isaac-Cartpole-Direct-v0 with sb3.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "newton,rl[sb3]"], cwd=isaaclab_root) + assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}" + + result = self.run_in_uv_env( + [ + str(self.cli_script), + "-p", + "scripts/reinforcement_learning/sb3/train.py", + "--task", + "Isaac-Cartpole-Direct-v0", + "--num_envs", + "64", + "presets=newton_mjwarp", + "--max_iterations", + "5", + "--headless", + ], + cwd=isaaclab_root, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, f"sb3 training failed (rc={result.returncode}):\n{output}" + assert "Traceback (most recent call last):" not in output, f"sb3 training raised an exception:\n{output}" + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(1800) + def test_rl_all_installs_all_frameworks(self, isaaclab_root): + """./isaaclab.sh -i 'rl' (no selector) installs all RL frameworks.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "rl"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i rl failed:\n{result.stdout}\n{result.stderr}" + + for pkg in ("rsl_rl", "skrl", "stable_baselines3"): + result = self.run_in_uv_env(["python", "-c", f"import {pkg}; print('{pkg} ok')"]) + assert result.returncode == 0, f"import {pkg} failed after rl[all]:\n{result.stdout}\n{result.stderr}" + + finally: + self.destroy_uv_env() diff --git a/source/isaaclab/test/install_ci/test_isaaclabx_i_visualizer.py b/source/isaaclab/test/install_ci/test_isaaclabx_i_visualizer.py new file mode 100644 index 000000000000..d22e8cf9ef4e --- /dev/null +++ b/source/isaaclab/test/install_ci/test_isaaclabx_i_visualizer.py @@ -0,0 +1,161 @@ +# 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 + +"""Tests for visualizer backend extra-feature installs. + +``visualizer`` is an extra feature selector that reinstalls the already-present +``isaaclab_visualizers`` core package with specific backend extras: + + - ``./isaaclab.sh -i 'visualizer[rerun]'`` → rerun-sdk + newton[sim] + - ``./isaaclab.sh -i 'visualizer[viser]'`` → viser + newton[sim] + - ``./isaaclab.sh -i 'visualizer[newton]'`` → imgui-bundle + newton[sim] + - ``./isaaclab.sh -i visualizer`` → all backends (default) + +All backends also pull in the ``newton[sim]`` git dependency because the +Newton renderer underpins every visualizer implementation. +""" + +from __future__ import annotations + +import shutil + +import pytest +from utils import UV_Mixin, find_isaaclab_root + + +class Test_Install_Visualizer(UV_Mixin): + """./isaaclab.sh -i 'visualizer[]' installs the chosen visualizer extras.""" + + @classmethod + def setup_class(cls): + if not shutil.which("uv"): + pytest.skip("uv is not available") + + try: + import isaacsim # noqa: F401 + except ImportError: + if not (find_isaaclab_root() / "_isaac_sim").exists(): + pytest.skip("isaacsim is not importable and _isaac_sim link not found, skipping") + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(1800) + def test_visualizer_rerun_backend_importable(self, isaaclab_root): + """rerun-sdk is importable after ./isaaclab.sh -i 'visualizer[rerun]'.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "visualizer[rerun]"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i visualizer[rerun] failed:\n{result.stdout}\n{result.stderr}" + + result = self.run_in_uv_env(["python", "-c", "import rerun; print('rerun ok')"]) + assert result.returncode == 0, f"import rerun failed:\n{result.stdout}\n{result.stderr}" + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(1800) + def test_visualizer_viser_backend_importable(self, isaaclab_root): + """viser is importable after ./isaaclab.sh -i 'visualizer[viser]'.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "visualizer[viser]"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i visualizer[viser] failed:\n{result.stdout}\n{result.stderr}" + + result = self.run_in_uv_env(["python", "-c", "import viser; print('viser ok')"]) + assert result.returncode == 0, f"import viser failed:\n{result.stdout}\n{result.stderr}" + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(1800) + def test_visualizer_default_installs_all_backends(self, isaaclab_root): + """./isaaclab.sh -i visualizer (no selector) installs all visualizer backends.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "visualizer"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i visualizer failed:\n{result.stdout}\n{result.stderr}" + + for pkg in ("rerun", "viser"): + result = self.run_in_uv_env(["python", "-c", f"import {pkg}; print('{pkg} ok')"]) + assert result.returncode == 0, ( + f"import {pkg} failed after visualizer[all]:\n{result.stdout}\n{result.stderr}" + ) + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(1800) + def test_visualizer_all_backends_pull_newton_sim(self, isaaclab_root): + """Every visualizer backend install also provides the newton package.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env([str(self.cli_script), "-i", "visualizer"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i visualizer failed:\n{result.stdout}\n{result.stderr}" + + result = self.run_in_uv_env(["python", "-c", "import newton; print('newton ok')"]) + assert result.returncode == 0, ( + f"import newton failed after visualizer install:\n{result.stdout}\n{result.stderr}" + ) + + finally: + self.destroy_uv_env() + + @pytest.mark.uv + @pytest.mark.gpu + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(3600) + def test_train_with_rerun_visualizer(self, isaaclab_root): + """Training with --visualizer rerun works after ./isaaclab.sh -i 'newton,rl[rsl-rl],visualizer[rerun]'.""" + + try: + self.create_uv_env(isaaclab_root) + + result = self.run_in_uv_env( + [str(self.cli_script), "-i", "newton,rl[rsl-rl],visualizer[rerun]"], + cwd=isaaclab_root, + ) + assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}" + + result = self.run_in_uv_env( + [ + str(self.cli_script), + "-p", + "scripts/reinforcement_learning/rsl_rl/train.py", + "--task", + "Isaac-Cartpole-Direct-v0", + "--num_envs", + "64", + "presets=newton_mjwarp", + "--max_iterations", + "5", + "--headless", + ], + cwd=isaaclab_root, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, f"training failed (rc={result.returncode}):\n{output}" + assert "Traceback (most recent call last):" not in output, f"training raised an exception:\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 8bff8426e1fe..9d8fefd370ce 100644 --- a/source/isaaclab/test/install_ci/test_isaaclabx_uv_smoke.py +++ b/source/isaaclab/test/install_ci/test_isaaclabx_uv_smoke.py @@ -36,38 +36,49 @@ def test_isaaclab_sh_uv_creates_env_with_python_312(self, isaaclab_root): @pytest.mark.uv @pytest.mark.timeout(200) - def test_isaaclab_install_assets(self, isaaclab_root): - """Run ./isaaclab.x -i 'assets' and verify isaaclab_assets is importable.""" + def test_isaaclab_none_installs_core_including_assets(self, isaaclab_root): + """Run ./isaaclab.x -i none and verify the core set (incl. assets) is importable. + + Under the new install model, ``isaaclab_assets`` is always installed as + part of the core set. Passing ``none`` installs the full core set without + any optional submodules or extra feature dependencies. + """ try: self.create_uv_env(isaaclab_root) - # ./isaaclab.x -i assets - result = self.run_in_uv_env([str(self.cli_script), "-i", "assets"], cwd=isaaclab_root) - assert result.returncode == 0, f"isaaclab -i assets failed:\n{result.stdout}\n{result.stderr}" + # ./isaaclab.x -i none — core set only, no optional extras + result = self.run_in_uv_env([str(self.cli_script), "-i", "none"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i none failed:\n{result.stdout}\n{result.stderr}" - # import isaaclab_assets - result = self.run_in_uv_env(["python", "-c", "import isaaclab_assets; print(isaaclab_assets.__version__)"]) - assert result.returncode == 0, f"import isaaclab_assets failed:\n{result.stdout}\n{result.stderr}" + # All core packages should be importable. + for pkg in ("isaaclab_assets", "isaaclab_tasks", "isaaclab_rl", "isaaclab_physx"): + result = self.run_in_uv_env(["python", "-c", f"import {pkg}; print('{pkg} ok')"]) + assert result.returncode == 0, f"import {pkg} failed:\n{result.stdout}\n{result.stderr}" finally: self.destroy_uv_env() @pytest.mark.uv @pytest.mark.timeout(300) - def test_isaaclab_newton_installs_isaaclab_newton(self, isaaclab_root): - """Run ./isaaclab.x -i 'newton' and verify isaaclab_newton is importable.""" + def test_isaaclab_newton_extra_installs_newton_sim(self, isaaclab_root): + """Run ./isaaclab.x -i newton and verify the newton[sim] extra is installed. + + ``newton`` is an extra feature selector: it reinstalls the already-present + core packages (``isaaclab_newton``, ``isaaclab_physx``, ``isaaclab_visualizers``) + with their newton extras, pulling in the ``newton[sim]`` git dependency. + """ try: self.create_uv_env(isaaclab_root) - # ./isaaclab.x -i newton + # ./isaaclab.x -i newton — installs core + newton extras 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_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}" + # The newton[sim] extra should make the newton package importable. + result = self.run_in_uv_env(["python", "-c", "import newton; print('newton ok')"]) + assert result.returncode == 0, f"import newton failed:\n{result.stdout}\n{result.stderr}" finally: self.destroy_uv_env() diff --git a/source/isaaclab/test/install_ci/test_isaaclabx_uv_training.py b/source/isaaclab/test/install_ci/test_isaaclabx_uv_training.py index 9bd2bfa40f32..eae856c6a725 100644 --- a/source/isaaclab/test/install_ci/test_isaaclabx_uv_training.py +++ b/source/isaaclab/test/install_ci/test_isaaclabx_uv_training.py @@ -28,16 +28,19 @@ def setup_class(cls): @pytest.mark.skip(reason="Cartpole training fails in MuJoCo stiffness conversion.") @pytest.mark.timeout(1200) def test_install_and_train_cartpole(self, isaaclab_root): - """`isaaclab.x -i assets,tasks,rl[all],physx,newton,contrib` then train Isaac-Cartpole-Direct-v0""" + """``./isaaclab.sh -i newton,'rl[all]'`` then train Isaac-Cartpole-Direct-v0. + + Under the new install model, the core set (assets, tasks, physx, contrib, …) + is always installed. Only the optional extras (newton physics library and + RL frameworks) need to be explicitly requested. + """ try: self.create_uv_env(isaaclab_root) - # Install assets, tasks, rl[all], physx, newton, contrib - result = self.run_in_uv_env( - [str(self.cli_script), "-i", "assets,tasks,rl[all],physx,newton,contrib"], cwd=isaaclab_root - ) - assert result.returncode == 0, f"isaaclab -i failed:\n{result.stdout}\n{result.stderr}" + # Core set is always installed; only request optional extras. + result = self.run_in_uv_env([str(self.cli_script), "-i", "newton,rl[all]"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i newton,rl[all] failed:\n{result.stdout}\n{result.stderr}" # Run a short training result = self.run_in_uv_env( diff --git a/source/isaaclab/test/install_ci/utils.py b/source/isaaclab/test/install_ci/utils.py index 85055f26c0bc..4a24ce21bd97 100644 --- a/source/isaaclab/test/install_ci/utils.py +++ b/source/isaaclab/test/install_ci/utils.py @@ -242,3 +242,87 @@ def run_in_uv_env(self, cmd: list[str], **kwargs) -> subprocess.CompletedProcess activate = shlex.quote(str(self.env_path / "bin" / "activate")) shell_cmd = f"source {activate} && {escaped}" return run_cmd(["bash", "-c", shell_cmd], **kwargs) + + +def drop_keys(env: dict[str, str], keys: tuple[str, ...]) -> dict[str, str]: + """Return a copy of *env* with the given keys removed. + + Useful for stripping venv/conda activation markers from the environment + before creating a fresh isolated environment inside a test. + + Args: + env: Source environment dictionary (e.g. ``os.environ``). + keys: Variable names to exclude from the returned copy. + """ + return {k: v for k, v in env.items() if k not in keys} + + +class Conda_Mixin: + """Mixin providing conda virtual-environment helpers for test classes. + + Pair with :class:`UVMixin` or use standalone for conda-only tests. + Requires ``conda`` (or ``mamba``) to be on ``PATH``. + + Usage:: + + class TestFoo(Conda_Mixin, unittest.TestCase): + def setUp(self): + self.create_conda_env(find_isaaclab_root()) + + def tearDown(self): + self.destroy_conda_env() + """ + + @property + def cli_script(self) -> Path: + """Path to ``isaaclab.sh`` (or ``.bat``) inside the repository root.""" + root: Path = self._isaaclab_root # type: ignore[attr-defined] + return root / ("isaaclab.bat" if _IS_WINDOWS else "isaaclab.sh") + + def create_conda_env(self, isaaclab_root: Path, env_name: str = "", python_version: str = "3.12") -> None: + """Create an isolated conda environment. + + Args: + isaaclab_root: Path to the IsaacLab repository root. + env_name: Name for the conda environment. A unique name based on the + test id is generated automatically when left empty. + python_version: Python version to install in the environment. + """ + if not env_name: + import uuid + + env_name = f"isaaclab_ci_{uuid.uuid4().hex[:8]}" + self._conda_env_name = env_name + self._isaaclab_root = isaaclab_root + + result = run_cmd( + ["conda", "create", "-n", env_name, f"python={python_version}", "-y", "--quiet"], + env=drop_keys(dict(os.environ), ("VIRTUAL_ENV", "CONDA_DEFAULT_ENV", "CONDA_PREFIX")), + ) + assert result.returncode == 0, f"conda env creation failed:\n{result.stdout}\n{result.stderr}" + + # Resolve the python executable inside the new conda env. + conda_prefix_result = run_cmd( + ["conda", "run", "-n", env_name, "python", "-c", "import sys; print(sys.executable)"], + env=drop_keys(dict(os.environ), ("VIRTUAL_ENV", "CONDA_DEFAULT_ENV", "CONDA_PREFIX")), + ) + assert conda_prefix_result.returncode == 0, ( + f"Could not resolve conda python:\n{conda_prefix_result.stdout}\n{conda_prefix_result.stderr}" + ) + self.python = Path(conda_prefix_result.stdout.strip()) + + def destroy_conda_env(self) -> None: + """Remove the conda environment created by :meth:`create_conda_env`.""" + if hasattr(self, "_conda_env_name"): + run_cmd(["conda", "env", "remove", "-n", self._conda_env_name, "-y", "--quiet"]) + + def run_in_conda_env(self, cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + """Run *cmd* inside the conda environment. + + Args: + cmd: Command and arguments to execute. + **kwargs: Extra keyword arguments forwarded to :func:`run_cmd`. + """ + escaped = " ".join(shlex.quote(str(a)) for a in cmd) + shell_cmd = f"conda run -n {shlex.quote(self._conda_env_name)} --no-capture-output {escaped}" + return run_cmd(["bash", "-c", shell_cmd], **kwargs) 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 28501f1a733f..16170c40610e 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 @@ -897,6 +897,7 @@ def test_output_equal_to_usd_camera_intrinsics(setup_simulation, height, width): del camera_usd, camera_warp +@pytest.mark.flaky(max_runs=3, min_passes=1) @pytest.mark.isaacsim_ci def test_output_equal_to_usd_camera_when_intrinsics_set(setup_simulation): """Test that the output of the ray caster camera is equal to the output of the usd camera when both are placed diff --git a/source/isaaclab_mimic/changelog.d/kellyguo11-fix-modular-install.minor.rst b/source/isaaclab_mimic/changelog.d/kellyguo11-fix-modular-install.minor.rst new file mode 100644 index 000000000000..4cc6d862d135 --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/kellyguo11-fix-modular-install.minor.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* Moved ``robomimic`` from an opt-in extra (``isaaclab_mimic[robomimic]``) to a + required dependency of :mod:`isaaclab_mimic` on Linux (via a ``sys_platform`` + marker). ``robomimic`` is now installed automatically whenever + ``isaaclab_mimic`` is installed on Linux; no extra selector is needed. diff --git a/source/isaaclab_mimic/setup.py b/source/isaaclab_mimic/setup.py index 279a7a0a248d..7b1aa19f6b79 100644 --- a/source/isaaclab_mimic/setup.py +++ b/source/isaaclab_mimic/setup.py @@ -5,7 +5,6 @@ """Installation script for the 'isaaclab_mimic' python package.""" -import itertools import os import platform @@ -30,17 +29,10 @@ if platform.machine() != "aarch64": INSTALL_REQUIRES.append("nvidia-srl-usd-to-urdf") -# Extra dependencies for IL agents -EXTRAS_REQUIRE = {"robomimic": []} - -# Check if the platform is Linux and add the dependency +# robomimic has no Windows/macOS wheels; only add it on Linux if platform.system() == "Linux": - EXTRAS_REQUIRE["robomimic"].append("robomimic@git+https://github.com/ARISE-Initiative/robomimic.git@v0.4.0") + INSTALL_REQUIRES.append("robomimic @ git+https://github.com/ARISE-Initiative/robomimic.git@v0.4.0") -# Cumulation of all extra-requires -EXTRAS_REQUIRE["all"] = list(itertools.chain.from_iterable(EXTRAS_REQUIRE.values())) -# Remove duplicates in the all list to avoid double installations -EXTRAS_REQUIRE["all"] = list(set(EXTRAS_REQUIRE["all"])) # Installation operation setup( @@ -53,7 +45,6 @@ description=EXTENSION_TOML_DATA["package"]["description"], keywords=EXTENSION_TOML_DATA["package"]["keywords"], install_requires=INSTALL_REQUIRES, - extras_require=EXTRAS_REQUIRE, license="Apache-2.0", include_package_data=True, python_requires=">=3.12", diff --git a/tools/run_install_ci.py b/tools/run_install_ci.py index ba2dedd1fe60..e233326ffe45 100755 --- a/tools/run_install_ci.py +++ b/tools/run_install_ci.py @@ -51,6 +51,7 @@ import subprocess import sys import time +import uuid from pathlib import Path _DIM = "\033[2m" @@ -151,63 +152,142 @@ def _find_repo_root() -> Path: # Docker mode +def _build_image( + repo_root: Path, + dockerfile: Path, + image_tag: str, + build_args: dict[str, str], + no_cache: bool, +) -> int: + """Build a Docker image, returning the exit code.""" + build_cmd = ["docker", "build", "--progress=plain"] + for key, value in build_args.items(): + build_cmd.extend(["--build-arg", f"{key}={value}"]) + build_cmd.extend(["-f", str(dockerfile), "-t", image_tag]) + if no_cache: + build_cmd.append("--no-cache") + build_cmd.append(str(repo_root)) + result = run_cmd(build_cmd, check=False, stream=True) + return result.returncode + + +def _prepare_results_dir(results_dir: str) -> Path: + """Prepare a host directory for Docker-copied test results. + + Args: + results_dir: Host directory where the JUnit XML file should be copied. + + Returns: + Path to the expected host JUnit XML file. + """ + results_abs = Path(results_dir).resolve() + results_abs.mkdir(parents=True, exist_ok=True) + + # Keep the directory writable across repeated self-hosted runner jobs. The + # actual XML is copied out with ``docker cp`` after the container exits, so + # pytest never has to open a bind-mounted host file from inside Docker. + try: + results_abs.chmod(0o777) + except OSError as exc: + print(f"Warning: could not chmod results directory {results_abs}: {exc}", file=sys.stderr) + + results_xml = results_abs / "results.xml" + if results_xml.exists() or results_xml.is_symlink(): + try: + if results_xml.is_dir(): + shutil.rmtree(results_xml) + else: + results_xml.unlink() + except OSError as exc: + print(f"Warning: could not remove stale results file {results_xml}: {exc}", file=sys.stderr) + + return results_xml + + +def _copy_junit_xml(container_name: str, container_results_xml: str, host_results_xml: Path) -> None: + """Copy JUnit XML from a completed Docker container to the host. + + Args: + container_name: Name of the Docker container to copy from. + container_results_xml: Path to the JUnit XML file inside the container. + host_results_xml: Host path where the JUnit XML file should be stored. + """ + copy_cmd = ["docker", "cp", f"{container_name}:{container_results_xml}", str(host_results_xml)] + copy_result = run_cmd(copy_cmd, check=False, stream=True) + if copy_result.returncode != 0: + print( + f"Warning: could not copy JUnit XML from {container_name}:{container_results_xml} to {host_results_xml}.", + file=sys.stderr, + ) + return + + try: + host_results_xml.chmod(0o666) + except OSError as exc: + print(f"Warning: could not chmod results file {host_results_xml}: {exc}", file=sys.stderr) + + def _cmd_docker(args: argparse.Namespace) -> int: """Build the Docker image and run tests inside the container based on *args*.""" repo_root = _find_repo_root() - dockerfile = repo_root / "docker" / "Dockerfile.installci" - image_tag = f"isaaclab-installci:{args.base_image.replace(':', '-').replace('/', '-')}" - - # Build the Docker image - build_cmd = [ - "docker", - "build", - "--build-arg", - f"BASE_IMAGE={args.base_image}", - "-f", - str(dockerfile), - "-t", - image_tag, - "--progress=plain", - ] - - if args.no_cache: - build_cmd.append("--no-cache") - build_cmd.append(str(repo_root)) - - result = run_cmd(build_cmd, check=False, stream=True) - if result.returncode != 0: - print(f"Docker build failed (exit {result.returncode})") - return result.returncode + # Build the uv base image first. + _install_ci_dir = repo_root / "source" / "isaaclab" / "test" / "install_ci" + uv_dockerfile = _install_ci_dir / "Dockerfile.installci" + uv_tag = f"isaaclab-installci:{args.base_image.replace(':', '-').replace('/', '-')}" + + rc = _build_image(repo_root, uv_dockerfile, uv_tag, {"BASE_IMAGE": args.base_image}, args.no_cache) + if rc != 0: + print(f"Docker build (uv base) failed (exit {rc})") + return rc + print(f"Docker uv base image built: {uv_tag}") + + # If conda mode, build the conda layer on top. + if getattr(args, "conda", False): + conda_dockerfile = _install_ci_dir / "Dockerfile.installci-conda" + image_tag = f"{uv_tag}-conda" + rc = _build_image(repo_root, conda_dockerfile, image_tag, {"UV_IMAGE": uv_tag}, args.no_cache) + if rc != 0: + print(f"Docker build (conda layer) failed (exit {rc})") + return rc + print(f"Docker conda image built: {image_tag}") + else: + image_tag = uv_tag - print(f"Docker image built successfully: {image_tag}") + host_results_xml: Path | None = None + container_name: str | None = None + container_results_xml = "/tmp/isaaclab-installci-results.xml" + if args.results_dir: + host_results_xml = _prepare_results_dir(args.results_dir) + if not args.shell: + container_name = f"isaaclab-installci-{os.getpid()}-{uuid.uuid4().hex[:8]}" # Run - docker_run_cmd: list[str] = [ - "docker", - "run", - "--rm", - "--network=host", - ] + docker_run_cmd: list[str] = ["docker", "run"] + if container_name: + docker_run_cmd.extend(["--name", container_name]) + else: + docker_run_cmd.append("--rm") + docker_run_cmd.append("--network=host") if args.gpu: docker_run_cmd.extend(["--gpus", "all"]) - # Persist pip and uv caches across runs via named Docker volumes + # Persist pip and uv caches across runs via named Docker volumes. + # The container runs as the non-root 'isaaclab' user (uid 1000), so caches + # must live under /home/isaaclab rather than /root. if not args.no_pip_cache: - docker_run_cmd.extend(["-v", "isaaclab-installci-pip-cache:/root/.cache/pip"]) + docker_run_cmd.extend(["-v", "isaaclab-installci-pip-cache:/home/isaaclab/.cache/pip"]) if not args.no_uv_cache: - docker_run_cmd.extend(["-v", "isaaclab-installci-uv-cache:/root/.cache/uv"]) + docker_run_cmd.extend(["-v", "isaaclab-installci-uv-cache:/home/isaaclab/.cache/uv"]) # Pass environment variables docker_run_cmd.extend(["-e", "OMNI_KIT_ACCEPT_EULA=Y"]) docker_run_cmd.extend(["-e", "ACCEPT_EULA=Y"]) - if args.results_dir: - results_abs = Path(args.results_dir).resolve() - results_abs.mkdir(parents=True, exist_ok=True) - docker_run_cmd.extend(["-v", f"{results_abs}:/tmp/results"]) + if args.results_dir and args.shell and host_results_xml is not None: + docker_run_cmd.extend(["-v", f"{host_results_xml.parent}:/tmp/results"]) if args.wheel: wheel_abs = Path(args.wheel).resolve() @@ -222,7 +302,7 @@ def _cmd_docker(args: argparse.Namespace) -> int: # Test execution mode pytest_args = args.pytest_args or ["--tb=short"] if args.results_dir: - pytest_args = ["--junitxml=/tmp/results/results.xml"] + pytest_args + pytest_args = [f"--junitxml={container_results_xml}"] + pytest_args docker_run_cmd.extend([image_tag] + pytest_args) print(f"{_MAGENTA}[COMMAND] {' '.join(docker_run_cmd)}{_RESET}") @@ -232,7 +312,14 @@ def _cmd_docker(args: argparse.Namespace) -> int: ret = subprocess.call(docker_run_cmd, timeout=5400) except subprocess.TimeoutExpired: print("Docker run timed out after 90 minutes", file=sys.stderr) + if container_name: + run_cmd(["docker", "kill", container_name], check=False, stream=True) ret = 124 + finally: + if container_name: + if host_results_xml is not None: + _copy_junit_xml(container_name, container_results_xml, host_results_xml) + run_cmd(["docker", "rm", "-f", container_name], check=False, stream=True) elapsed = time.monotonic() - t0 print(f"{_MAGENTA}[{elapsed:.1f}s]{_RESET}") return ret @@ -279,6 +366,7 @@ def main() -> int: docker options: --base-image IMAGE Docker base image (default: ubuntu:24.04) --gpu Pass --gpus all to docker run + --conda Build and use a conda-enabled image (layered on the uv base) --shell Drop into interactive bash instead of running tests --no-cache Build Docker image without layer cache --no-pip-cache Disable persistent pip cache volume @@ -296,7 +384,8 @@ def main() -> int: %(prog)s docker # run all tests in Docker %(prog)s docker --base-image ubuntu:22.04 -- -vs -k "testname" # custom base image %(prog)s docker --gpu # GPU support (--gpus all) - %(prog)s docker -- -m uv # filter by marker + %(prog)s docker --gpu -- -m uv # uv tests only + %(prog)s docker --gpu --conda -- -m conda # conda tests (conda image) %(prog)s docker --gpu -- -m "slow and gpu" # combine markers with GPU %(prog)s docker --gpu -- -m nvbugs_5968136 # filter by bug ID %(prog)s docker --shell # drop into shell for debugging @@ -315,6 +404,11 @@ def main() -> int: help="Docker base image (default: ubuntu:24.04)", ) docker_p.add_argument("--gpu", action="store_true", help="Pass --gpus all to docker run") + docker_p.add_argument( + "--conda", + action="store_true", + help="Build and use a conda-enabled Docker image (layered on top of the uv base image)", + ) docker_p.add_argument( "--shell", action="store_true", help="Drop into an interactive bash shell instead of running tests" ) From 0bb80fd392a78f137aa503fac4cdde9712690c10 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Sun, 17 May 2026 17:02:49 -0700 Subject: [PATCH 095/133] Fixes uv run path and aligns closer to ./isaaclab.sh -i approach (#5663) # Description Fixes the uv run path that got broken by #5650. Updates the available options to align closer with the new logic for ./isaaclab.sh -i modular installation. Also adds more unit tests for uv run and pip package. ## 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 - [ ] 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 --- .github/workflows/license-exceptions.json | 5 + .github/workflows/wheel.yml | 39 +++++- docker/test/test_run_install_ci.py | 112 ++++++++++++++++++ pyproject.toml | 33 ++++-- .../changelog.d/fix-rsl-rl-wheel-pin.rst | 6 + .../changelog.d/fix-uv-run-pyproject.rst | 8 ++ .../test/cli/test_uv_run_pyproject.py | 79 ++++++++++++ .../test/cli/test_wheel_builder_metadata.py | 54 +++++++++ tools/wheel_builder/res/python_packages.toml | 6 +- 9 files changed, 329 insertions(+), 13 deletions(-) create mode 100644 docker/test/test_run_install_ci.py create mode 100644 source/isaaclab/changelog.d/fix-rsl-rl-wheel-pin.rst create mode 100644 source/isaaclab/changelog.d/fix-uv-run-pyproject.rst create mode 100644 source/isaaclab/test/cli/test_uv_run_pyproject.py create mode 100644 source/isaaclab/test/cli/test_wheel_builder_metadata.py diff --git a/.github/workflows/license-exceptions.json b/.github/workflows/license-exceptions.json index 8ac605547dcf..461439328380 100644 --- a/.github/workflows/license-exceptions.json +++ b/.github/workflows/license-exceptions.json @@ -497,5 +497,10 @@ "package": "ovphysx", "license": "LicenseRef-NVIDIA-Omniverse", "comment": "NVIDIA" + }, + { + "package": "decorator", + "license": "UNKNOWN", + "comment": "BSD-2" } ] diff --git a/.github/workflows/wheel.yml b/.github/workflows/wheel.yml index 841d395afa32..a9ecbede49c6 100644 --- a/.github/workflows/wheel.yml +++ b/.github/workflows/wheel.yml @@ -26,7 +26,7 @@ jobs: build-wheel: name: Build Wheel runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 20 steps: - name: Detect wheel-relevant changes @@ -119,14 +119,14 @@ jobs: - name: Checkout code if: steps.changes.outputs.run_build == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 1 lfs: true - name: Setup Python if: steps.changes.outputs.run_build == 'true' - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" architecture: x64 @@ -159,3 +159,36 @@ jobs: path: tools/wheel_builder/build/dist/isaaclab-*.whl if-no-files-found: error retention-days: 30 + + - name: Download wheel artifact for install test + if: steps.changes.outputs.run_build == 'true' + uses: actions/download-artifact@v7 + with: + name: ${{ steps.meta.outputs.artifact_name }} + path: /tmp/isaaclab-wheel-artifact + + - name: Test wheel extras resolution + if: steps.changes.outputs.run_build == 'true' + run: | + set -euo pipefail + + python -m pip install --user uv + export PATH="$HOME/.local/bin:$PATH" + cd /tmp + + uv venv --python 3.12 /tmp/isaaclab-wheel-install + source /tmp/isaaclab-wheel-install/bin/activate + uv pip install --upgrade pip + + wheel="$(find /tmp/isaaclab-wheel-artifact -name 'isaaclab-*.whl' -print -quit)" + if [ -z "$wheel" ]; then + echo "No isaaclab wheel found in downloaded artifact" >&2 + exit 1 + fi + + uv pip install \ + --dry-run \ + --extra-index-url https://pypi.nvidia.com \ + --index-strategy unsafe-best-match \ + --prerelease=allow \ + "${wheel}[isaacsim,all]" diff --git a/docker/test/test_run_install_ci.py b/docker/test/test_run_install_ci.py new file mode 100644 index 000000000000..f51be58787f0 --- /dev/null +++ b/docker/test/test_run_install_ci.py @@ -0,0 +1,112 @@ +# 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 argparse +import importlib.util +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +RUNNER_PATH = REPO_ROOT / "tools" / "run_install_ci.py" +CONTAINER_RESULTS_XML = "/tmp/isaaclab-installci-results.xml" + + +def _load_runner(): + spec = importlib.util.spec_from_file_location("run_install_ci", RUNNER_PATH) + assert spec is not None + assert spec.loader is not None + runner = importlib.util.module_from_spec(spec) + spec.loader.exec_module(runner) + return runner + + +def _docker_args(**overrides): + args = { + "base_image": "ubuntu:24.04", + "conda": False, + "gpu": False, + "no_cache": False, + "no_pip_cache": True, + "no_uv_cache": True, + "pytest_args": ["--tb=short", "-sv", "-m", "uv"], + "results_dir": None, + "shell": False, + "wheel": None, + } + args.update(overrides) + return argparse.Namespace(**args) + + +def test_docker_results_dir_copies_junit_after_container_exit(tmp_path, monkeypatch): + runner = _load_runner() + docker_runs = [] + docker_side_effects = [] + + def fake_build_image(*_args, **_kwargs): + return 0 + + def fake_call(cmd, timeout): + docker_runs.append((cmd, timeout)) + return 0 + + def fake_run_cmd(cmd, **kwargs): + docker_side_effects.append((cmd, kwargs)) + if cmd[:2] == ["docker", "cp"]: + Path(cmd[3]).write_text("\n", encoding="utf-8") + return subprocess.CompletedProcess(cmd, 0, "", "") + + monkeypatch.setattr(runner, "_build_image", fake_build_image) + monkeypatch.setattr(runner, "_find_repo_root", lambda: REPO_ROOT) + monkeypatch.setattr(runner, "run_cmd", fake_run_cmd) + monkeypatch.setattr(runner.subprocess, "call", fake_call) + + rc = runner._cmd_docker(_docker_args(results_dir=str(tmp_path))) + + assert rc == 0 + assert len(docker_runs) == 1 + + docker_run_cmd, timeout = docker_runs[0] + assert timeout == 5400 + assert "--rm" not in docker_run_cmd + assert "--name" in docker_run_cmd + assert f"{tmp_path.resolve()}:/tmp/results" not in docker_run_cmd + assert f"--junitxml={CONTAINER_RESULTS_XML}" in docker_run_cmd + + container_name = docker_run_cmd[docker_run_cmd.index("--name") + 1] + host_results_xml = tmp_path / "results.xml" + assert host_results_xml.read_text(encoding="utf-8") == "\n" + + side_effect_cmds = [cmd for cmd, _kwargs in docker_side_effects] + assert [ + "docker", + "cp", + f"{container_name}:{CONTAINER_RESULTS_XML}", + str(host_results_xml.resolve()), + ] in side_effect_cmds + assert ["docker", "rm", "-f", container_name] in side_effect_cmds + + +def test_docker_without_results_dir_uses_rm_and_no_junit_copy(monkeypatch): + runner = _load_runner() + docker_runs = [] + docker_side_effects = [] + + monkeypatch.setattr(runner, "_build_image", lambda *_args, **_kwargs: 0) + monkeypatch.setattr(runner, "_find_repo_root", lambda: REPO_ROOT) + monkeypatch.setattr(runner, "run_cmd", lambda cmd, **kwargs: docker_side_effects.append((cmd, kwargs))) + monkeypatch.setattr(runner.subprocess, "call", lambda cmd, timeout: docker_runs.append((cmd, timeout)) or 0) + + rc = runner._cmd_docker(_docker_args()) + + assert rc == 0 + assert len(docker_runs) == 1 + + docker_run_cmd, _timeout = docker_runs[0] + assert "--rm" in docker_run_cmd + assert "--name" not in docker_run_cmd + assert all(not arg.startswith("--junitxml=") for arg in docker_run_cmd) + assert docker_side_effects == [] diff --git a/pyproject.toml b/pyproject.toml index 974ab0caaf19..86ab12b38ceb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,11 +13,11 @@ dependencies = [ "isaaclab-assets", "isaaclab-contrib", "isaaclab-experimental", - "isaaclab-newton", + "isaaclab-newton[all]", "isaaclab-ov", "isaaclab-ovphysx", - "isaaclab-physx", - "isaaclab-rl", + "isaaclab-physx[newton]", + "isaaclab-rl[rsl-rl]", "isaaclab-tasks", "isaaclab-tasks-experimental", "isaaclab-visualizers", @@ -27,16 +27,34 @@ dependencies = [ ] [project.optional-dependencies] -isaacsim = [ - "isaacsim[all,extscache]==5.1.0", +contrib = [ + "isaaclab-contrib", +] +mimic = [ + "isaaclab-mimic", +] +newton = [ + "isaaclab-newton[all]", + "isaaclab-physx[newton]", + "isaaclab-visualizers[newton]", +] +ov = [ + "isaaclab-ovphysx[ovphysx]", +] +rl = [ + "isaaclab-rl[rsl-rl]", +] +rl-all = [ + "isaaclab-rl[all]", +] +rtx = [ + "isaaclab-ov[ovrtx]", ] all = [ - "isaacsim[all,extscache]==5.1.0", "isaaclab-mimic", "isaaclab-newton[all]", "isaaclab-physx[newton]", "isaaclab-rl[all]", - "isaaclab-teleop", "isaaclab-visualizers[all]", ] @@ -201,6 +219,7 @@ explicit = true index-strategy = "unsafe-best-match" prerelease = "allow" override-dependencies = ["numpy>=2"] +python-preference = "only-managed" package = false [tool.uv.sources] diff --git a/source/isaaclab/changelog.d/fix-rsl-rl-wheel-pin.rst b/source/isaaclab/changelog.d/fix-rsl-rl-wheel-pin.rst new file mode 100644 index 000000000000..71bcaafbb097 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-rsl-rl-wheel-pin.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed the ``isaaclab`` wheel's ``rsl-rl`` optional dependency to install + ``rsl-rl-lib==5.0.1``, matching the version required by the RSL-RL training + scripts. diff --git a/source/isaaclab/changelog.d/fix-uv-run-pyproject.rst b/source/isaaclab/changelog.d/fix-uv-run-pyproject.rst new file mode 100644 index 000000000000..d52910a9c120 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-uv-run-pyproject.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Fixed the root ``uv run`` workflow by restoring the documented + ``pyproject.toml`` extras, the IsaacLab-only ``all`` extra, and removing the + Isaac Sim extra from the development project. +* Fixed ``uv run`` creating ``.venv`` from an active conda Python by requiring + uv-managed Python for the development project. diff --git a/source/isaaclab/test/cli/test_uv_run_pyproject.py b/source/isaaclab/test/cli/test_uv_run_pyproject.py new file mode 100644 index 000000000000..cec68c7bc9c5 --- /dev/null +++ b/source/isaaclab/test/cli/test_uv_run_pyproject.py @@ -0,0 +1,79 @@ +# 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 + +"""Tests for the root pyproject metadata used by the ``uv run`` workflow.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import tomllib + + +def _repo_root() -> Path: + """Find the Isaac Lab repository root from this test file.""" + for parent in Path(__file__).resolve().parents: + if (parent / "pyproject.toml").is_file() and (parent / "source").is_dir(): + return parent + raise RuntimeError("Could not find Isaac Lab repository root.") + + +def _root_pyproject() -> dict: + """Load the root development ``pyproject.toml``.""" + with (_repo_root() / "pyproject.toml").open("rb") as f: + return tomllib.load(f) + + +def test_uv_run_extra_names_match_documented_workflow(): + """Docs must only reference ``uv run --extra`` names that pyproject defines.""" + repo_root = _repo_root() + docs = (repo_root / "docs/source/setup/installation/uv_run.rst").read_text(encoding="utf-8") + documented_extras = set(re.findall(r"--extra\s+([A-Za-z0-9_-]+)", docs)) + optional_dependencies = _root_pyproject()["project"]["optional-dependencies"] + + assert documented_extras + assert documented_extras <= set(optional_dependencies) + + +def test_uv_run_keeps_modular_extras_without_isaacsim(): + """The root dev project keeps local module extras but leaves Isaac Sim opt-in out.""" + optional_dependencies = _root_pyproject()["project"]["optional-dependencies"] + + expected_extras = { + "contrib": ["isaaclab-contrib"], + "mimic": ["isaaclab-mimic"], + "newton": ["isaaclab-newton[all]", "isaaclab-physx[newton]", "isaaclab-visualizers[newton]"], + "ov": ["isaaclab-ovphysx[ovphysx]"], + "rl": ["isaaclab-rl[rsl-rl]"], + "rl-all": ["isaaclab-rl[all]"], + "rtx": ["isaaclab-ov[ovrtx]"], + "all": [ + "isaaclab-mimic", + "isaaclab-newton[all]", + "isaaclab-physx[newton]", + "isaaclab-rl[all]", + "isaaclab-visualizers[all]", + ], + } + + assert optional_dependencies == expected_extras + assert "isaacsim" not in optional_dependencies + + +def test_uv_run_base_dependencies_cover_newton_rsl_rl_training(): + """The documented bare ``uv run train`` command needs Newton and RSL-RL extras.""" + dependencies = _root_pyproject()["project"]["dependencies"] + + assert "isaaclab-newton[all]" in dependencies + assert "isaaclab-physx[newton]" in dependencies + assert "isaaclab-rl[rsl-rl]" in dependencies + + +def test_uv_run_uses_managed_python(): + """Avoid building the project venv from conda Python and its older C++ runtime.""" + tool_uv = _root_pyproject()["tool"]["uv"] + + assert tool_uv["python-preference"] == "only-managed" diff --git a/source/isaaclab/test/cli/test_wheel_builder_metadata.py b/source/isaaclab/test/cli/test_wheel_builder_metadata.py new file mode 100644 index 000000000000..a8c6e405a90c --- /dev/null +++ b/source/isaaclab/test/cli/test_wheel_builder_metadata.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 + +"""Tests for wheel-builder package metadata.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import tomllib + + +def _repo_root() -> Path: + """Find the Isaac Lab repository root from this test file.""" + for parent in Path(__file__).resolve().parents: + if (parent / "pyproject.toml").is_file() and (parent / "source").is_dir(): + return parent + raise RuntimeError("Could not find Isaac Lab repository root.") + + +def _rsl_rl_pin_from_setup() -> str: + """Return the ``rsl-rl-lib`` pin declared by ``source/isaaclab_rl/setup.py``.""" + setup_path = _repo_root() / "source/isaaclab_rl/setup.py" + module = ast.parse(setup_path.read_text(encoding="utf-8")) + + for node in module.body: + if not isinstance(node, ast.Assign): + continue + if not any(isinstance(target, ast.Name) and target.id == "EXTRAS_REQUIRE" for target in node.targets): + continue + extras_require = ast.literal_eval(node.value) + for dependency in extras_require["rsl-rl"]: + if dependency.startswith("rsl-rl-lib=="): + return dependency + + raise AssertionError("Could not find rsl-rl-lib pin in source/isaaclab_rl/setup.py") + + +def test_wheel_builder_rsl_rl_pin_matches_source_package(): + """The bundled wheel metadata must install the RSL-RL version required by training scripts.""" + expected_pin = _rsl_rl_pin_from_setup() + packages_path = _repo_root() / "tools/wheel_builder/res/python_packages.toml" + with packages_path.open("rb") as f: + packages = tomllib.load(f) + + optional_dependencies = packages["isaaclab"]["pyproject"]["optional-dependencies"]["all"] + dependencies_by_extra = {name: deps for entry in optional_dependencies for name, deps in entry.items()} + + for extra_name in ("rsl-rl", "rsl_rl", "all"): + rsl_rl_pins = [dep for dep in dependencies_by_extra[extra_name] if dep.startswith("rsl-rl-lib==")] + assert rsl_rl_pins == [expected_pin] diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index 56519fcda625..e0a207f77e38 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -98,8 +98,8 @@ pyproject.optional-dependencies.all = [ { "skrl" = ["skrl>=1.4.3"] }, # { "rl-games" = ["rl-games==1.6.1"] }, # TODO: re-enable when rl-games Python package supports Python 3.11 # { "rl_games" = ["rl-games==1.6.1"] }, # TODO: re-enable when rl-games Python package supports Python 3.11 - { "rsl-rl" = ["rsl-rl-lib==3.1.2", "onnxscript>=0.5"] }, - { "rsl_rl" = ["rsl-rl-lib==3.1.2", "onnxscript>=0.5"] }, + { "rsl-rl" = ["rsl-rl-lib==5.0.1", "onnxscript>=0.5"] }, + { "rsl_rl" = ["rsl-rl-lib==5.0.1", "onnxscript>=0.5"] }, # ================================================================================ # https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_visualizers/setup.py # ================================================================================ @@ -113,7 +113,7 @@ pyproject.optional-dependencies.all = [ "rich", "skrl>=1.4.3", # "rl-games==1.6.1", # TODO: re-enable when rl-games Python package supports Python 3.11 - "rsl-rl-lib==3.1.2", + "rsl-rl-lib==5.0.1", "onnxscript>=0.5", ] }, ] From 5d69131cee961ca83667b6e37bf14d21d87eee6f Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Mon, 18 May 2026 12:34:11 +0800 Subject: [PATCH 096/133] Remove the `is_homogeneous` helper from isaaclab core (#5667) # Description Removed `is_homogeneous()` helper for clone plan from the IsaacLab core. ## Type of change - Internal refactoring (non-user-visible behavior 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` - [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 --- .../huidongc-remove-homogeneous-helper.rst | 4 ++++ source/isaaclab/isaaclab/cloner/cloner_utils.py | 15 --------------- .../huidongc-remove-homogeneous-helper.skip | 3 +++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 3 +-- 4 files changed, 8 insertions(+), 17 deletions(-) create mode 100644 source/isaaclab/changelog.d/huidongc-remove-homogeneous-helper.rst create mode 100644 source/isaaclab_ov/changelog.d/huidongc-remove-homogeneous-helper.skip diff --git a/source/isaaclab/changelog.d/huidongc-remove-homogeneous-helper.rst b/source/isaaclab/changelog.d/huidongc-remove-homogeneous-helper.rst new file mode 100644 index 000000000000..c64ab1f614a2 --- /dev/null +++ b/source/isaaclab/changelog.d/huidongc-remove-homogeneous-helper.rst @@ -0,0 +1,4 @@ +Removed +^^^^^^^ + +* Removed :func:`~isaaclab.cloner.cloner_utils.is_homogeneous` because it is an implementation detail. diff --git a/source/isaaclab/isaaclab/cloner/cloner_utils.py b/source/isaaclab/isaaclab/cloner/cloner_utils.py index 6c72422159f8..337fad42f45f 100644 --- a/source/isaaclab/isaaclab/cloner/cloner_utils.py +++ b/source/isaaclab/isaaclab/cloner/cloner_utils.py @@ -393,18 +393,3 @@ def grid_transforms(N: int, spacing: float = 1.0, up_axis: str = "z", device="cp ori = torch.zeros((N, 4), device=device) ori[:, 3] = 1.0 # w=1 for identity quaternion return pos, ori - - -def is_homogeneous(clone_plan: ClonePlan) -> bool: - """Check if a clone plan is homogeneous. - - Homogeneous here means every element of :attr:`~isaaclab.cloner.ClonePlan.clone_mask` - is ``True`` (equivalent to ``clone_plan.clone_mask.all()``). - - Args: - clone_plan: The clone plan to check. - - Returns: - ``True`` if all elements of ``clone_mask`` are ``True``, otherwise ``False``. - """ - return bool(clone_plan.clone_mask.all().item()) diff --git a/source/isaaclab_ov/changelog.d/huidongc-remove-homogeneous-helper.skip b/source/isaaclab_ov/changelog.d/huidongc-remove-homogeneous-helper.skip new file mode 100644 index 000000000000..e96d6f541cad --- /dev/null +++ b/source/isaaclab_ov/changelog.d/huidongc-remove-homogeneous-helper.skip @@ -0,0 +1,3 @@ + +Internal refactor: inlined the homogeneous clone-plan check in +:class:`~isaaclab_ov.renderers.OVRTXRenderer`; no user-facing API change. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index bf918dedf084..1f1e35cc6165 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -44,7 +44,6 @@ from ovrtx import Device, PrimMode, Renderer, RendererConfig, Semantic from packaging.version import Version -from isaaclab.cloner.cloner_utils import is_homogeneous from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec from isaaclab.sim import SimulationContext from isaaclab.utils.warp.warp_math import convert_camera_frame_orientation_convention_wp @@ -174,7 +173,7 @@ def __init__(self, cfg: OVRTXRendererCfg): if self._use_ovrtx_cloning: clone_plan = SimulationContext.instance().get_clone_plan() - if clone_plan and not is_homogeneous(clone_plan): + if clone_plan and not clone_plan.clone_mask.all().item(): logger.warning("OVRTX cloning disabled because the simulation uses a heterogeneous env setup") self._use_ovrtx_cloning = False From 27924a8bcd4df4bc580408c0a4535fb6ccea5516 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 06:29:19 +0000 Subject: [PATCH 097/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 5.4.0 → 5.5.0 - isaaclab_experimental: 0.0.4 → 0.0.5 - isaaclab_mimic: 1.2.7 → 1.3.0 --- .../changelog.d/fix-rsl-rl-wheel-pin.rst | 6 -- .../changelog.d/fix-uv-run-pyproject.rst | 8 --- .../huidongc-remove-homogeneous-helper.rst | 4 -- .../kellyguo11-fix-modular-install.minor.rst | 35 ------------ source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 57 +++++++++++++++++++ .../changelog.d/api-docs.rst | 5 -- .../config/extension.toml | 2 +- .../isaaclab_experimental/docs/CHANGELOG.rst | 10 ++++ .../kellyguo11-fix-modular-install.minor.rst | 7 --- source/isaaclab_mimic/config/extension.toml | 2 +- source/isaaclab_mimic/docs/CHANGELOG.rst | 12 ++++ .../huidongc-remove-homogeneous-helper.skip | 3 - 13 files changed, 82 insertions(+), 71 deletions(-) delete mode 100644 source/isaaclab/changelog.d/fix-rsl-rl-wheel-pin.rst delete mode 100644 source/isaaclab/changelog.d/fix-uv-run-pyproject.rst delete mode 100644 source/isaaclab/changelog.d/huidongc-remove-homogeneous-helper.rst delete mode 100644 source/isaaclab/changelog.d/kellyguo11-fix-modular-install.minor.rst delete mode 100644 source/isaaclab_experimental/changelog.d/api-docs.rst delete mode 100644 source/isaaclab_mimic/changelog.d/kellyguo11-fix-modular-install.minor.rst delete mode 100644 source/isaaclab_ov/changelog.d/huidongc-remove-homogeneous-helper.skip diff --git a/source/isaaclab/changelog.d/fix-rsl-rl-wheel-pin.rst b/source/isaaclab/changelog.d/fix-rsl-rl-wheel-pin.rst deleted file mode 100644 index 71bcaafbb097..000000000000 --- a/source/isaaclab/changelog.d/fix-rsl-rl-wheel-pin.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Fixed the ``isaaclab`` wheel's ``rsl-rl`` optional dependency to install - ``rsl-rl-lib==5.0.1``, matching the version required by the RSL-RL training - scripts. diff --git a/source/isaaclab/changelog.d/fix-uv-run-pyproject.rst b/source/isaaclab/changelog.d/fix-uv-run-pyproject.rst deleted file mode 100644 index d52910a9c120..000000000000 --- a/source/isaaclab/changelog.d/fix-uv-run-pyproject.rst +++ /dev/null @@ -1,8 +0,0 @@ -Fixed -^^^^^ - -* Fixed the root ``uv run`` workflow by restoring the documented - ``pyproject.toml`` extras, the IsaacLab-only ``all`` extra, and removing the - Isaac Sim extra from the development project. -* Fixed ``uv run`` creating ``.venv`` from an active conda Python by requiring - uv-managed Python for the development project. diff --git a/source/isaaclab/changelog.d/huidongc-remove-homogeneous-helper.rst b/source/isaaclab/changelog.d/huidongc-remove-homogeneous-helper.rst deleted file mode 100644 index c64ab1f614a2..000000000000 --- a/source/isaaclab/changelog.d/huidongc-remove-homogeneous-helper.rst +++ /dev/null @@ -1,4 +0,0 @@ -Removed -^^^^^^^ - -* Removed :func:`~isaaclab.cloner.cloner_utils.is_homogeneous` because it is an implementation detail. diff --git a/source/isaaclab/changelog.d/kellyguo11-fix-modular-install.minor.rst b/source/isaaclab/changelog.d/kellyguo11-fix-modular-install.minor.rst deleted file mode 100644 index 649b8f65c363..000000000000 --- a/source/isaaclab/changelog.d/kellyguo11-fix-modular-install.minor.rst +++ /dev/null @@ -1,35 +0,0 @@ -Changed -^^^^^^^ - -* Changed the installation model of :meth:`~isaaclab.cli.commands.install.command_install` - from per-submodule selection to a three-tier system. All core submodules - (``isaaclab``, ``isaaclab_assets``, ``isaaclab_contrib``, ``isaaclab_experimental``, - ``isaaclab_newton``, ``isaaclab_ov``, ``isaaclab_ovphysx``, ``isaaclab_physx``, - ``isaaclab_rl``, ``isaaclab_tasks``, ``isaaclab_tasks_experimental``, - ``isaaclab_visualizers``) - are now always installed by ``./isaaclab.sh -i``. Optional submodules - (``mimic``, ``teleop``) and automatic extra feature sets - (``newton``, ``rl[...]``, ``visualizer[...]``) are installed by ``./isaaclab.sh -i`` - / ``./isaaclab.sh -i all``. - Optional dependency extras require selectors, so rlinf dependencies are - installed with ``contrib[rlinf]`` and the ``ovrtx`` / ``ovphysx`` wheels are installed - with ``ov[ovrtx]``, ``ov[ovphysx]``, or ``ov[all]``. Old per-submodule tokens (e.g. - ``assets``, ``tasks``, ``physx``) now emit a warning and are skipped gracefully. - Migrate using the table below: - - +----------------------------------------------+-------------------------------------------+ - | Old command | New command | - +==============================================+===========================================+ - | ``./isaaclab.sh -i assets,tasks,physx`` | ``./isaaclab.sh -i none`` | - +----------------------------------------------+-------------------------------------------+ - | ``./isaaclab.sh -i assets,tasks,ov,rl[...]`` | ``./isaaclab.sh -i ov[all],rl[...]`` | - +----------------------------------------------+-------------------------------------------+ - | ``./isaaclab.sh -i newton,rl[all]`` | unchanged | - +----------------------------------------------+-------------------------------------------+ - | ``./isaaclab.sh -i mimic,teleop`` | unchanged | - +----------------------------------------------+-------------------------------------------+ - | ``uv pip install isaaclab[tasks,rl,assets]`` | ``uv pip install isaaclab[all]`` | - +----------------------------------------------+-------------------------------------------+ - -* Simplified :mod:`isaaclab` package extras to ``isaacsim`` and ``all``; removed the old - per-submodule extras (``tasks``, ``rl``, ``assets``, etc.) from ``pip install isaaclab[...]``. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 049c10eee348..922af835cb71 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 = "5.4.0" +version = "5.5.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 8027eea55803..400b5dfadfe3 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,63 @@ Changelog --------- +5.5.0 (2026-05-18) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed the installation model of :meth:`~isaaclab.cli.commands.install.command_install` + from per-submodule selection to a three-tier system. All core submodules + (``isaaclab``, ``isaaclab_assets``, ``isaaclab_contrib``, ``isaaclab_experimental``, + ``isaaclab_newton``, ``isaaclab_ov``, ``isaaclab_ovphysx``, ``isaaclab_physx``, + ``isaaclab_rl``, ``isaaclab_tasks``, ``isaaclab_tasks_experimental``, + ``isaaclab_visualizers``) + are now always installed by ``./isaaclab.sh -i``. Optional submodules + (``mimic``, ``teleop``) and automatic extra feature sets + (``newton``, ``rl[...]``, ``visualizer[...]``) are installed by ``./isaaclab.sh -i`` + / ``./isaaclab.sh -i all``. + Optional dependency extras require selectors, so rlinf dependencies are + installed with ``contrib[rlinf]`` and the ``ovrtx`` / ``ovphysx`` wheels are installed + with ``ov[ovrtx]``, ``ov[ovphysx]``, or ``ov[all]``. Old per-submodule tokens (e.g. + ``assets``, ``tasks``, ``physx``) now emit a warning and are skipped gracefully. + Migrate using the table below: + + +----------------------------------------------+-------------------------------------------+ + | Old command | New command | + +==============================================+===========================================+ + | ``./isaaclab.sh -i assets,tasks,physx`` | ``./isaaclab.sh -i none`` | + +----------------------------------------------+-------------------------------------------+ + | ``./isaaclab.sh -i assets,tasks,ov,rl[...]`` | ``./isaaclab.sh -i ov[all],rl[...]`` | + +----------------------------------------------+-------------------------------------------+ + | ``./isaaclab.sh -i newton,rl[all]`` | unchanged | + +----------------------------------------------+-------------------------------------------+ + | ``./isaaclab.sh -i mimic,teleop`` | unchanged | + +----------------------------------------------+-------------------------------------------+ + | ``uv pip install isaaclab[tasks,rl,assets]`` | ``uv pip install isaaclab[all]`` | + +----------------------------------------------+-------------------------------------------+ + +* Simplified :mod:`isaaclab` package extras to ``isaacsim`` and ``all``; removed the old + per-submodule extras (``tasks``, ``rl``, ``assets``, etc.) from ``pip install isaaclab[...]``. + +Removed +^^^^^^^ + +* Removed :func:`~isaaclab.cloner.cloner_utils.is_homogeneous` because it is an implementation detail. + +Fixed +^^^^^ + +* Fixed the ``isaaclab`` wheel's ``rsl-rl`` optional dependency to install + ``rsl-rl-lib==5.0.1``, matching the version required by the RSL-RL training + scripts. +* Fixed the root ``uv run`` workflow by restoring the documented + ``pyproject.toml`` extras, the IsaacLab-only ``all`` extra, and removing the + Isaac Sim extra from the development project. +* Fixed ``uv run`` creating ``.venv`` from an active conda Python by requiring + uv-managed Python for the development project. + + 5.4.0 (2026-05-17) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_experimental/changelog.d/api-docs.rst b/source/isaaclab_experimental/changelog.d/api-docs.rst deleted file mode 100644 index 9dfe5a38dc28..000000000000 --- a/source/isaaclab_experimental/changelog.d/api-docs.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed :mod:`isaaclab_experimental.utils` package exports so its utility - modules appear in API documentation. diff --git a/source/isaaclab_experimental/config/extension.toml b/source/isaaclab_experimental/config/extension.toml index 6e5bee6fb01b..eac7386afa33 100644 --- a/source/isaaclab_experimental/config/extension.toml +++ b/source/isaaclab_experimental/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.0.4" +version = "0.0.5" # Description title = "Experimental playground for upcoming IsaacLab features" diff --git a/source/isaaclab_experimental/docs/CHANGELOG.rst b/source/isaaclab_experimental/docs/CHANGELOG.rst index 2131ff672994..7894fe80993a 100644 --- a/source/isaaclab_experimental/docs/CHANGELOG.rst +++ b/source/isaaclab_experimental/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.0.5 (2026-05-18) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed :mod:`isaaclab_experimental.utils` package exports so its utility + modules appear in API documentation. + + 0.0.4 (2026-05-12) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_mimic/changelog.d/kellyguo11-fix-modular-install.minor.rst b/source/isaaclab_mimic/changelog.d/kellyguo11-fix-modular-install.minor.rst deleted file mode 100644 index 4cc6d862d135..000000000000 --- a/source/isaaclab_mimic/changelog.d/kellyguo11-fix-modular-install.minor.rst +++ /dev/null @@ -1,7 +0,0 @@ -Changed -^^^^^^^ - -* Moved ``robomimic`` from an opt-in extra (``isaaclab_mimic[robomimic]``) to a - required dependency of :mod:`isaaclab_mimic` on Linux (via a ``sys_platform`` - marker). ``robomimic`` is now installed automatically whenever - ``isaaclab_mimic`` is installed on Linux; no extra selector is needed. diff --git a/source/isaaclab_mimic/config/extension.toml b/source/isaaclab_mimic/config/extension.toml index 1a4f579a323c..00b2fbbe87e9 100644 --- a/source/isaaclab_mimic/config/extension.toml +++ b/source/isaaclab_mimic/config/extension.toml @@ -1,7 +1,7 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "1.2.7" +version = "1.3.0" # Description category = "isaaclab" diff --git a/source/isaaclab_mimic/docs/CHANGELOG.rst b/source/isaaclab_mimic/docs/CHANGELOG.rst index e1e50f99e72b..ff1d42cd8492 100644 --- a/source/isaaclab_mimic/docs/CHANGELOG.rst +++ b/source/isaaclab_mimic/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +1.3.0 (2026-05-18) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Moved ``robomimic`` from an opt-in extra (``isaaclab_mimic[robomimic]``) to a + required dependency of :mod:`isaaclab_mimic` on Linux (via a ``sys_platform`` + marker). ``robomimic`` is now installed automatically whenever + ``isaaclab_mimic`` is installed on Linux; no extra selector is needed. + + 1.2.7 (2026-05-14) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/changelog.d/huidongc-remove-homogeneous-helper.skip b/source/isaaclab_ov/changelog.d/huidongc-remove-homogeneous-helper.skip deleted file mode 100644 index e96d6f541cad..000000000000 --- a/source/isaaclab_ov/changelog.d/huidongc-remove-homogeneous-helper.skip +++ /dev/null @@ -1,3 +0,0 @@ - -Internal refactor: inlined the homogeneous clone-plan check in -:class:`~isaaclab_ov.renderers.OVRTXRenderer`; no user-facing API change. From 0609d5681b2e08b728b88c5a003b2406f96d6674 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Mon, 18 May 2026 03:22:40 -0500 Subject: [PATCH 098/133] Migration of camera.py to completely remove torch (#5651) # Description Cleaned up camera internals and converted more camera state handling to Warp-based paths. This keeps camera pose, intrinsic, and frame updates closer to the Warp-backed data buffers used by the renderer and sensor views. ## 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: Kelly Guo --- .../isaaclab/changelog.d/mh-warp_cam_2.skip | 0 .../isaaclab/envs/manager_based_rl_env.py | 2 +- .../isaaclab/sensors/camera/camera.py | 309 +++++++++++++----- .../isaaclab/sensors/camera/camera_data.py | 88 ++--- source/isaaclab/test/sensors/test_camera.py | 38 +-- .../test_multi_mesh_ray_caster_camera.py | 35 +- .../test/sensors/test_ray_caster_camera.py | 34 +- 7 files changed, 320 insertions(+), 186 deletions(-) create mode 100644 source/isaaclab/changelog.d/mh-warp_cam_2.skip diff --git a/source/isaaclab/changelog.d/mh-warp_cam_2.skip b/source/isaaclab/changelog.d/mh-warp_cam_2.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index 3e48dcd19f88..9060e7874c32 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -233,7 +233,7 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: self.recorder_manager.record_post_step() # -- reset envs that terminated/timed-out and log the episode information - reset_env_ids = self.reset_buf.nonzero(as_tuple=False).squeeze(-1) + reset_env_ids = self.reset_buf.nonzero(as_tuple=False).squeeze(-1).int() if len(reset_env_ids) > 0: # trigger recorder terms for pre-reset calls self.recorder_manager.record_pre_reset(reset_env_ids) diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 319fcc27f22b..588d4cb000f4 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -26,7 +26,7 @@ create_rotation_matrix_from_view, quat_from_matrix, ) -from isaaclab.utils.warp.warp_math import convert_camera_frame_orientation_convention_wp +from isaaclab.utils.warp import ProxyArray from ..sensor_base import SensorBase from .camera_data import CameraData, RenderBufferKind @@ -38,6 +38,45 @@ logger = logging.getLogger(__name__) +@wp.kernel +def _camera_update_state_kernel( + pos_src: wp.array(dtype=wp.vec3f), + quat_src: wp.array(dtype=wp.quatf), + intrinsics_src: wp.array(dtype=wp.mat33f), + pos_dst: wp.array(dtype=wp.vec3f), + quat_world_dst: wp.array(dtype=wp.quatf), + intrinsics_dst: wp.array(dtype=wp.mat33f), + frame: wp.array(dtype=wp.int64), + env_mask: wp.array(dtype=wp.bool), + env_ids: wp.array(dtype=wp.int32), + use_env_ids: bool, + use_env_mask: bool, + update_pose: bool, + update_intrinsics: bool, + frame_op: int, +): + """Update camera state for all, indexed, or masked cameras. + + ``frame_op`` uses 0 for no-op, 1 for increment, and 2 for reset. + """ + src_id = wp.tid() + dst_id = src_id + if use_env_ids: + dst_id = env_ids[src_id] + if use_env_mask and not env_mask[dst_id]: + return + + if update_pose: + pos_dst[dst_id] = pos_src[src_id] + quat_world_dst[dst_id] = quat_src[src_id] * wp.quatf(-0.5, 0.5, 0.5, 0.5) + if update_intrinsics: + intrinsics_dst[dst_id] = intrinsics_src[src_id] + if frame_op == 1: + frame[dst_id] = frame[dst_id] + wp.int64(1) + elif frame_op == 2: + frame[dst_id] = wp.int64(0) + + class Camera(SensorBase): r"""The camera sensor for acquiring visual data. @@ -171,7 +210,7 @@ def data(self) -> CameraData: return self._data @property - def frame(self) -> torch.tensor: + def frame(self) -> ProxyArray: """Frame number when the measurement took place.""" return self._frame @@ -185,7 +224,7 @@ def image_shape(self) -> tuple[int, int]: """ def set_intrinsic_matrices( - self, matrices: torch.Tensor, focal_length: float | None = None, env_ids: Sequence[int] | None = None + self, matrices: torch.Tensor | wp.array, focal_length: float | None = None, env_ids: Sequence[int] | None = None ): """Set parameters of the USD camera from its intrinsic matrix. @@ -209,16 +248,25 @@ def set_intrinsic_matrices( focal_length will be calculated 1 / width. env_ids: A sensor ids to manipulate. Defaults to None, which means all sensor indices. """ - # resolve env_ids - if env_ids is None: - env_ids = self._ALL_INDICES - # convert matrices to numpy tensors if isinstance(matrices, torch.Tensor): - matrices = matrices.cpu().numpy() + if not matrices.is_contiguous(): + matrices = matrices.contiguous() + matrices = wp.from_torch(matrices) + elif not isinstance(matrices, wp.array): + raise TypeError(f"Unsupported type for matrices: {type(matrices)}. Expected torch.Tensor or wp.array.") + + if env_ids is None: + env_ids_np = np.arange(self._view.count) + elif isinstance(env_ids, slice): + env_ids_np = np.arange(self._view.count)[env_ids] else: - matrices = np.asarray(matrices, dtype=float) + env_ids_np = np.asarray(env_ids, dtype=np.int32).reshape(-1) + + matrices = matrices.numpy().astype(float, copy=False) + if matrices.ndim == 2: + matrices = matrices[None, ...] # iterate over env_ids - for i, intrinsic_matrix in zip(env_ids, matrices): + for i, intrinsic_matrix in zip(env_ids_np, matrices): height, width = self.image_shape params = sensor_utils.convert_camera_intrinsics_to_usd( @@ -239,7 +287,7 @@ def set_intrinsic_matrices( # set value using pure USD API param_attr().Set(param_value) # update the internal buffers - self._update_intrinsic_matrices(env_ids) + self._update_intrinsic_matrices(env_ids_np) """ Operations - Set pose. @@ -275,31 +323,24 @@ def set_world_poses( Raises: RuntimeError: If the camera prim is not set. Need to call :meth:`initialize` method first. """ - # resolve env_ids - if env_ids is None: - env_ids = self._ALL_INDICES - # convert to backend tensor + pos_wp = None if positions is not None: if isinstance(positions, np.ndarray): positions = torch.from_numpy(positions).to(device=self._device) elif not isinstance(positions, torch.Tensor): positions = torch.tensor(positions, device=self._device) - # convert rotation matrix from input convention to OpenGL + positions = positions.to(device=self._device, dtype=torch.float32).reshape(-1, 3) + pos_wp = wp.from_torch(positions.contiguous()) + ori_wp = None if orientations is not None: if isinstance(orientations, np.ndarray): orientations = torch.from_numpy(orientations).to(device=self._device) elif not isinstance(orientations, torch.Tensor): orientations = torch.tensor(orientations, device=self._device) + orientations = orientations.to(device=self._device, dtype=torch.float32).reshape(-1, 4) orientations = convert_camera_frame_orientation_convention(orientations, origin=convention, target="opengl") - # 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 + ori_wp = wp.from_torch(orientations.contiguous()) + idx_wp = self._resolve_env_ids_wp(env_ids) self._view.set_world_poses(pos_wp, ori_wp, idx_wp) def set_world_poses_from_view( @@ -319,11 +360,26 @@ def set_world_poses_from_view( whole batch). When only some rows are degenerate, those rows are skipped and the remaining poses are still applied; a warning is logged. """ - # resolve env_ids to a tensor up front so we can index it during partial-failure filtering + if isinstance(eyes, np.ndarray): + eyes = torch.from_numpy(eyes).to(device=self._device) + elif not isinstance(eyes, torch.Tensor): + eyes = torch.tensor(eyes, device=self._device) + eyes = eyes.to(device=self._device, dtype=torch.float32).reshape(-1, 3) + if isinstance(targets, np.ndarray): + targets = torch.from_numpy(targets).to(device=self._device) + elif not isinstance(targets, torch.Tensor): + targets = torch.tensor(targets, device=self._device) + targets = targets.to(device=self._device, dtype=torch.float32).reshape(-1, 3) if env_ids is None: - env_ids = self._ALL_INDICES - if not isinstance(env_ids, torch.Tensor): - env_ids = torch.tensor(env_ids, dtype=torch.int32, device=self._device) + env_ids_torch = torch.arange(self._view.count, dtype=torch.int32, device=self._device) + elif isinstance(env_ids, slice): + env_ids_torch = torch.arange(self._view.count, dtype=torch.int32, device=self._device)[env_ids] + elif isinstance(env_ids, wp.array): + env_ids_torch = wp.to_torch(env_ids).to(device=self._device, dtype=torch.int32).reshape(-1) + elif isinstance(env_ids, torch.Tensor): + env_ids_torch = env_ids.to(device=self._device, dtype=torch.int32).reshape(-1) + else: + env_ids_torch = torch.tensor(env_ids, dtype=torch.int32, device=self._device).reshape(-1) # get up axis of current stage up_axis = UsdGeom.GetStageUpAxis(self.stage) # set camera poses using the view; degenerate rows (eye == target) come back as NaN @@ -340,10 +396,14 @@ def set_world_poses_from_view( ) rotation_matrix = rotation_matrix.index_select(0, valid_indices) eyes = eyes.index_select(0, valid_indices) - env_ids = env_ids.index_select(0, valid_indices) + env_ids_torch = env_ids_torch.index_select(0, valid_indices) orientations = quat_from_matrix(rotation_matrix) - 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) + idx_wp = wp.from_torch(env_ids_torch.contiguous(), dtype=wp.int32) + self._view.set_world_poses( + wp.from_torch(eyes.contiguous()), + wp.from_torch(orientations.contiguous()), + idx_wp, + ) """ Operations @@ -356,16 +416,15 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None ) # reset the timestamps super().reset(env_ids, env_mask) - # resolve to indices for torch indexing - if env_ids is None and env_mask is not None: - env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) - elif env_ids is None: - env_ids = self._ALL_INDICES # reset the data # note: this recomputation is useful if one performs events such as randomizations on the camera poses. - self._update_poses(env_ids) - # Reset the frame count - self._frame[env_ids] = 0 + if env_mask is not None: + self._update_poses(env_mask=env_mask, frame_op=2) + elif env_ids is None: + self._update_poses(frame_op=2) + else: + env_ids_wp = self._resolve_env_ids_wp(env_ids) + self._update_poses(env_ids_wp, frame_op=2) """ Implementation. @@ -407,9 +466,9 @@ def _initialize_impl(self): ) # Create all env_ids buffer - self._ALL_INDICES = torch.arange(self._view.count, device=self._device, dtype=torch.long) + self._ALL_INDICES = wp.array(np.arange(self._view.count, dtype=np.int32), device=self._device) # Create frame count buffer - self._frame = torch.zeros(self._view.count, device=self._device, dtype=torch.long) + self._frame = ProxyArray(wp.zeros(self._view.count, device=self._device, dtype=wp.int64)) # Convert all encapsulated prims to Camera for cam_prim in self._view.prims: @@ -442,14 +501,13 @@ def _initialize_impl(self): self._create_buffers() def _update_buffers_impl(self, env_mask: wp.array): - env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) - if len(env_ids) == 0: + if not self._env_mask_has_any(env_mask): return # Increment frame count - self._frame[env_ids] += 1 - # update latest camera pose if requested if self.cfg.update_latest_camera_pose: - self._update_poses(env_ids) + self._update_poses(env_mask=env_mask, frame_op=1) + else: + self._update_camera_state(env_mask=env_mask, frame_op=1) sim_ctx = sim_utils.SimulationContext.instance() renderer = self._renderer @@ -472,7 +530,7 @@ def _update_buffers_impl(self, env_mask: wp.array): def _check_supported_data_types(self, cfg: CameraCfg): """Checks if the data types are supported by the ray-caster camera.""" # check if there is any intersection in unsupported types - # reason: these use np structured data types which we can't yet convert to torch tensor + # reason: these use np structured data types which are not compatible with the camera buffer contract common_elements = set(cfg.data_types) & Camera.UNSUPPORTED_TYPES if common_elements: # provide alternative fast counterparts @@ -484,7 +542,7 @@ def _check_supported_data_types(self, cfg: CameraCfg): raise ValueError( f"Camera class does not support the following sensor types: {common_elements}." "\n\tThis is because these sensor types output numpy structured data types which" - "can't be converted to torch tensors easily." + "can't be stored in the camera output buffers easily." "\n\tHint: If you need to work with these sensor types, we recommend using their fast counterparts." f"\n\t\tFast counterparts: {fast_common_elements}" ) @@ -521,11 +579,11 @@ def _create_buffers(self): # Camera-frame state (pose / intrinsics) is owned by the camera, not # the renderer: allocate warp buffers and populate them. self._data.create_buffers(self._view.count, device_str) - self._update_intrinsic_matrices(self._ALL_INDICES) - self._update_poses(self._ALL_INDICES) + self._update_intrinsic_matrices() + self._update_poses() self._renderer.set_outputs(self._render_data, self._data.output) - def _update_intrinsic_matrices(self, env_ids: Sequence[int]): + def _update_intrinsic_matrices(self, env_ids: Sequence[int] | wp.array | None = None): """Compute camera's matrix of intrinsic parameters. Also called calibration matrix. This matrix works for linear depth images. We assume square pixels. @@ -534,10 +592,15 @@ def _update_intrinsic_matrices(self, env_ids: Sequence[int]): The calibration matrix projects points in the 3D scene onto an imaginary screen of the camera. The coordinates of points on the image plane are in the homogeneous representation. """ + env_ids_np = self._resolve_env_ids_np(env_ids) + if len(env_ids_np) == 0: + return + + intrinsic_matrices = np.zeros((len(env_ids_np), 3, 3), dtype=np.float32) # iterate over all cameras - for i in env_ids: + for matrix_id, i in enumerate(env_ids_np): # Get corresponding sensor prim - sensor_prim = self._sensor_prims[i] + sensor_prim = self._sensor_prims[int(i)] # get camera parameters # currently rendering does not use aperture offsets or vertical aperture focal_length = sensor_prim.GetFocalLengthAttr().Get() @@ -551,14 +614,22 @@ def _update_intrinsic_matrices(self, env_ids: Sequence[int]): c_x = width * 0.5 c_y = height * 0.5 # create intrinsic matrix for depth linear - intrinsics_t = self._data.intrinsic_matrices.torch - intrinsics_t[i, 0, 0] = f_x - intrinsics_t[i, 0, 2] = c_x - intrinsics_t[i, 1, 1] = f_y - intrinsics_t[i, 1, 2] = c_y - intrinsics_t[i, 2, 2] = 1 - - def _update_poses(self, env_ids: Sequence[int]): + intrinsic_matrices[matrix_id, 0, 0] = f_x + intrinsic_matrices[matrix_id, 0, 2] = c_x + intrinsic_matrices[matrix_id, 1, 1] = f_y + intrinsic_matrices[matrix_id, 1, 2] = c_y + intrinsic_matrices[matrix_id, 2, 2] = 1.0 + + intrinsic_matrices_wp = wp.array(intrinsic_matrices, dtype=wp.mat33f, device=self._device) + self._update_camera_state( + env_ids=None if env_ids is None else self._resolve_env_ids_wp(env_ids_np), + intrinsics_src=intrinsic_matrices_wp, + update_intrinsics=True, + ) + + def _update_poses( + self, env_ids: Sequence[int] | wp.array | None = None, env_mask: wp.array | None = None, frame_op: int = 0 + ): """Computes the pose of the camera in the world frame with ROS convention. This methods uses the ROS convention to resolve the input pose. In this convention, @@ -572,23 +643,32 @@ def _update_poses(self, env_ids: Sequence[int]): raise RuntimeError("Camera prim is None. Please call 'sim.play()' first.") # get the poses from the view (returns ProxyArray) - 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_w, quat_w = self._view.get_world_poses(indices) - self._data.pos_w.torch[env_ids] = pos_w.torch - - # get_world_poses() returns orientations as a flat 4-float, convert to wp.quatf typed array - quat_w_quatf = wp.array( - ptr=quat_w.warp.ptr, dtype=wp.quatf, shape=(quat_w.warp.shape[0],), device=quat_w.warp.device, copy=False + env_ids_wp = None if env_mask is not None else self._resolve_env_ids_wp(env_ids) + pos_w, quat_w = self._view.get_world_poses(env_ids_wp) + pos_w_wp = pos_w.warp + pos_w_wp = wp.array( + ptr=pos_w_wp.ptr, + dtype=wp.vec3f, + shape=(pos_w_wp.shape[0],), + device=pos_w_wp.device, + copy=False, ) - convert_camera_frame_orientation_convention_wp( - src=quat_w_quatf, - dst=self._data.quat_w_world, - origin="opengl", - target="world", - indices=indices, - device=self._device, + quat_w_wp = quat_w.warp + quat_w_wp = wp.array( + ptr=quat_w_wp.ptr, + dtype=wp.quatf, + shape=(quat_w_wp.shape[0],), + device=quat_w_wp.device, + copy=False, + ) + + self._update_camera_state( + env_ids=env_ids_wp, + env_mask=env_mask, + pos_src=pos_w_wp, + quat_src=quat_w_wp, + update_pose=True, + frame_op=frame_op, ) # notify renderer of updated poses (guarded in case called before initialization completes) if self._render_data is not None: @@ -596,6 +676,79 @@ def _update_poses(self, env_ids: Sequence[int]): self._render_data, self._data.pos_w, self._data.quat_w_world, self._data.intrinsic_matrices ) + def _update_camera_state( + self, + env_ids: wp.array | None = None, + env_mask: wp.array | None = None, + pos_src: wp.array | None = None, + quat_src: wp.array | None = None, + intrinsics_src: wp.array | None = None, + update_pose: bool = False, + update_intrinsics: bool = False, + frame_op: int = 0, + ): + """Update camera pose, intrinsics, and frame counters through one Warp kernel.""" + count = env_ids.shape[0] if env_ids is not None else self._view.count + if count == 0: + return + wp.launch( + _camera_update_state_kernel, + dim=count, + inputs=[ + pos_src if pos_src is not None else self._data.pos_w.warp, + quat_src if quat_src is not None else self._data.quat_w_world.warp, + intrinsics_src if intrinsics_src is not None else self._data.intrinsic_matrices.warp, + self._data.pos_w.warp, + self._data.quat_w_world.warp, + self._data.intrinsic_matrices.warp, + self._frame.warp, + env_mask if env_mask is not None else self._ALL_ENV_MASK, + env_ids if env_ids is not None else self._ALL_INDICES, + env_ids is not None, + env_mask is not None, + update_pose, + update_intrinsics, + frame_op, + ], + device=self._device, + ) + + def _resolve_env_ids_np(self, env_ids: Sequence[int] | wp.array | None) -> np.ndarray: + """Resolve camera indices to a host ``int32`` array for USD metadata reads.""" + if env_ids is None: + return np.arange(self._view.count, dtype=np.int32) + if isinstance(env_ids, slice): + return np.arange(self._view.count, dtype=np.int32)[env_ids] + if isinstance(env_ids, wp.array): + return env_ids.numpy().astype(np.int32, copy=False).reshape(-1) + return np.asarray(env_ids, dtype=np.int32).reshape(-1) + + def _resolve_env_ids_wp(self, env_ids: Sequence[int] | torch.Tensor | wp.array | slice | None) -> wp.array | None: + """Resolve camera indices to a Warp ``int32`` array.""" + if env_ids is None: + return None + if isinstance(env_ids, wp.array): + if env_ids.dtype != wp.int32: + raise TypeError(f"Unsupported wp.array dtype for env_ids: {env_ids.dtype}. Expected wp.int32.") + if str(env_ids.device) == str(self._device): + return env_ids + env_ids = env_ids.numpy().astype(np.int32, copy=False).reshape(-1) + elif isinstance(env_ids, torch.Tensor): + env_ids = env_ids.to(device=self._device, dtype=torch.int32).reshape(-1) + if not env_ids.is_contiguous(): + env_ids = env_ids.contiguous() + return wp.from_torch(env_ids, dtype=wp.int32) + elif isinstance(env_ids, slice): + env_ids = np.arange(self._view.count, dtype=np.int32)[env_ids] + else: + env_ids = np.asarray(env_ids, dtype=np.int32).reshape(-1) + return wp.array(env_ids, dtype=wp.int32, device=self._device) + + @staticmethod + def _env_mask_has_any(env_mask: wp.array) -> bool: + """Return whether the mask selects any camera.""" + return bool(np.any(env_mask.numpy())) + """ Internal simulation callbacks. """ diff --git a/source/isaaclab/isaaclab/sensors/camera/camera_data.py b/source/isaaclab/isaaclab/sensors/camera/camera_data.py index a03b4a69e646..aa31ebb69e04 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera_data.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera_data.py @@ -7,7 +7,6 @@ from typing import Any -import torch import warp as wp # Re-exported as part of the public isaaclab.sensors.camera API @@ -27,20 +26,12 @@ class CameraData: """ def __init__(self): - # Warp arrays for pose / intrinsics — allocated in create_buffers() - self._pos_w_wp: wp.array | None = None - self._quat_w_world_wp: wp.array | None = None - self._intrinsic_matrices_wp: wp.array | None = None - # Pre-allocated output buffers for derived orientation properties - self._quat_w_ros_wp: wp.array | None = None - self._quat_w_opengl_wp: wp.array | None = None - # ProxyArray wrappers — created in create_buffers() - self._pos_w_pa: ProxyArray | None = None - self._quat_w_world_pa: ProxyArray | None = None - self._intrinsic_matrices_pa: ProxyArray | None = None - self._quat_w_ros_pa: ProxyArray | None = None - self._quat_w_opengl_pa: ProxyArray | None = None + self._pos_w: ProxyArray | None = None + self._quat_w_world: ProxyArray | None = None + self._intrinsic_matrices: ProxyArray | None = None + self._quat_w_ros: ProxyArray | None = None + self._quat_w_opengl: ProxyArray | None = None # Output image buffers — allocated in allocate() self._output: dict[str, ProxyArray] | None = None @@ -68,7 +59,7 @@ def pos_w(self) -> ProxyArray: where N is the number of sensors. Use ``.warp`` for the underlying ``wp.array`` or ``.torch`` for a cached zero-copy ``torch.Tensor`` view. """ - return self._pos_w_pa + return self._pos_w @property def quat_w_world(self) -> ProxyArray: @@ -82,7 +73,7 @@ def quat_w_world(self) -> ProxyArray: where N is the number of sensors. Use ``.warp`` for the underlying ``wp.array`` or ``.torch`` for a cached zero-copy ``torch.Tensor`` view. """ - return self._quat_w_world_pa + return self._quat_w_world ## # Camera data @@ -96,7 +87,7 @@ def intrinsic_matrices(self) -> ProxyArray: where N is the number of sensors. Use ``.warp`` for the underlying ``wp.array`` or ``.torch`` for a cached zero-copy ``torch.Tensor`` view. """ - return self._intrinsic_matrices_pa + return self._intrinsic_matrices @property def output(self) -> dict[str, ProxyArray] | None: @@ -124,17 +115,11 @@ def create_buffers(self, num_views: int, device: str) -> None: num_views: Number of camera views (batch dimension). device: Device for tensor storage (e.g. ``"cuda:0"``). """ - self._pos_w_wp = wp.zeros(num_views, dtype=wp.vec3f, device=device) - self._quat_w_world_wp = wp.zeros(num_views, dtype=wp.quatf, device=device) - self._intrinsic_matrices_wp = wp.zeros(num_views, dtype=wp.mat33f, device=device) - self._quat_w_ros_wp = wp.zeros(num_views, dtype=wp.quatf, device=device) - self._quat_w_opengl_wp = wp.zeros(num_views, dtype=wp.quatf, device=device) - - self._pos_w_pa = ProxyArray(self._pos_w_wp) - self._quat_w_world_pa = ProxyArray(self._quat_w_world_wp) - self._intrinsic_matrices_pa = ProxyArray(self._intrinsic_matrices_wp) - self._quat_w_ros_pa = ProxyArray(self._quat_w_ros_wp) - self._quat_w_opengl_pa = ProxyArray(self._quat_w_opengl_wp) + self._pos_w = ProxyArray(wp.zeros(num_views, dtype=wp.vec3f, device=device)) + self._quat_w_world = ProxyArray(wp.zeros(num_views, dtype=wp.quatf, device=device)) + self._intrinsic_matrices = ProxyArray(wp.zeros(num_views, dtype=wp.mat33f, device=device)) + self._quat_w_ros = ProxyArray(wp.zeros(num_views, dtype=wp.quatf, device=device)) + self._quat_w_opengl = ProxyArray(wp.zeros(num_views, dtype=wp.quatf, device=device)) @classmethod def allocate( @@ -143,7 +128,7 @@ def allocate( height: int, width: int, num_views: int, - device: torch.device | str, + device: str, supported_specs: dict[RenderBufferKind, RenderBufferSpec], ) -> CameraData: """Build a :class:`CameraData` with output buffers pre-allocated as warp arrays. @@ -160,7 +145,7 @@ def allocate( height: Image height in pixels. width: Image width in pixels. num_views: Number of camera views (batch dimension). - device: Torch device on which to allocate the buffers. + device: Device on which to allocate the buffers. supported_specs: Per-buffer layout the active renderer can produce, keyed by :class:`RenderBufferKind`. Names absent from this mapping are not allocated. @@ -174,36 +159,27 @@ def allocate( ValueError: If ``data_types`` contains names that are not members of :class:`RenderBufferKind`. """ - requested: set[RenderBufferKind] = set() - unknown: list[str] = [] - for name in data_types: - try: - requested.add(RenderBufferKind(name)) - except ValueError: - unknown.append(name) + valid_names = {kind.value for kind in RenderBufferKind} + unknown = [name for name in data_types if name not in valid_names] if unknown: raise ValueError(f"Unknown RenderBufferKind name(s): {unknown}. Expected members of RenderBufferKind.") + requested = {RenderBufferKind(name) for name in data_types} - # rgb is exposed as a strided view into rgba when the renderer publishes both, - # so requesting either one allocates the shared rgba buffer. - rgb_alias = ( - RenderBufferKind.RGBA in supported_specs - and RenderBufferKind.RGB in supported_specs - and (RenderBufferKind.RGB in requested or RenderBufferKind.RGBA in requested) - ) + rgb_kinds = {RenderBufferKind.RGB, RenderBufferKind.RGBA} + rgb_alias = rgb_kinds <= supported_specs.keys() and not requested.isdisjoint(rgb_kinds) if rgb_alias: - requested.update({RenderBufferKind.RGB, RenderBufferKind.RGBA}) + requested.update(rgb_kinds) - device_str = device if isinstance(device, str) else str(device) + allocated = requested.intersection(supported_specs) + if rgb_alias: + allocated.remove(RenderBufferKind.RGB) buffers: dict[str, ProxyArray] = {} for name, spec in supported_specs.items(): - if name not in requested: + if name not in allocated: continue - if rgb_alias and name == RenderBufferKind.RGB: - continue # created below as a strided view into rgba - wp_arr = wp.zeros((num_views, height, width, spec.channels), dtype=spec.dtype, device=device_str) - buffers[str(name)] = ProxyArray(wp_arr) + shape = (num_views, height, width, spec.channels) + buffers[str(name)] = ProxyArray(wp.zeros(shape, dtype=spec.dtype, device=device)) if rgb_alias: # Zero-copy strided view into rgba: shape (N, H, W, 3), skipping the alpha channel. @@ -242,8 +218,8 @@ def quat_w_ros(self) -> ProxyArray: where N is the number of sensors. Use ``.warp`` for the underlying ``wp.array`` or ``.torch`` for a cached zero-copy ``torch.Tensor`` view. """ - convert_camera_frame_orientation_convention_wp(self._quat_w_world_wp, self._quat_w_ros_wp, "world", "ros") - return self._quat_w_ros_pa + convert_camera_frame_orientation_convention_wp(self._quat_w_world.warp, self._quat_w_ros.warp, "world", "ros") + return self._quat_w_ros @property def quat_w_opengl(self) -> ProxyArray: @@ -257,5 +233,7 @@ def quat_w_opengl(self) -> ProxyArray: where N is the number of sensors. Use ``.warp`` for the underlying ``wp.array`` or ``.torch`` for a cached zero-copy ``torch.Tensor`` view. """ - convert_camera_frame_orientation_convention_wp(self._quat_w_world_wp, self._quat_w_opengl_wp, "world", "opengl") - return self._quat_w_opengl_pa + convert_camera_frame_orientation_convention_wp( + self._quat_w_world.warp, self._quat_w_opengl.warp, "world", "opengl" + ) + return self._quat_w_opengl diff --git a/source/isaaclab/test/sensors/test_camera.py b/source/isaaclab/test/sensors/test_camera.py index 6d09bf6082a1..9b41899765b7 100644 --- a/source/isaaclab/test/sensors/test_camera.py +++ b/source/isaaclab/test/sensors/test_camera.py @@ -333,15 +333,14 @@ def test_camera_set_world_poses(setup_sim_camera): # play sim sim.reset() - # convert to torch tensors - position = torch.tensor([POSITION], dtype=torch.float32, device=camera.device) - orientation = torch.tensor([QUAT_WORLD], dtype=torch.float32, device=camera.device) + position = np.asarray([POSITION], dtype=np.float32) + orientation = np.asarray([QUAT_WORLD], dtype=np.float32) # set new pose - camera.set_world_poses(position.clone(), orientation.clone(), convention="world") + camera.set_world_poses(position, orientation, convention="world") # check if transform correctly set in output - torch.testing.assert_close(camera.data.pos_w.torch, position) - torch.testing.assert_close(camera.data.quat_w_world.torch, orientation) + np.testing.assert_allclose(camera.data.pos_w.warp.numpy(), position) + _assert_quat_close(camera.data.quat_w_world.warp.numpy(), orientation, rtol=1e-5, atol=1e-5) def test_camera_set_world_poses_from_view(setup_sim_camera): @@ -354,12 +353,12 @@ def test_camera_set_world_poses_from_view(setup_sim_camera): # play sim sim.reset() - # convert to torch tensors - eyes = torch.tensor([POSITION], dtype=torch.float32, device=camera.device) - targets = torch.tensor([[0.0, 0.0, 0.0]], dtype=torch.float32, device=camera.device) + eyes_np = np.asarray([POSITION], dtype=np.float32) + targets_np = np.asarray([[0.0, 0.0, 0.0]], dtype=np.float32) + eyes = torch.tensor(eyes_np, dtype=torch.float32, device=camera.device) quat_ros_gt = torch.tensor([QUAT_ROS], dtype=torch.float32, device=camera.device) # set new pose - camera.set_world_poses_from_view(eyes.clone(), targets.clone()) + camera.set_world_poses_from_view(eyes_np, targets_np) # check if transform correctly set in output torch.testing.assert_close(camera.data.pos_w.torch, eyes) @@ -377,9 +376,10 @@ def test_intrinsic_matrix(setup_sim_camera): sim.reset() # Desired properties (obtained from realsense camera at 320x240 resolution) rs_intrinsic_matrix = [229.8, 0.0, 160.0, 0.0, 229.8, 120.0, 0.0, 0.0, 1.0] - rs_intrinsic_matrix = torch.tensor(rs_intrinsic_matrix, device=camera.device).reshape(3, 3).unsqueeze(0) + rs_intrinsic_matrix = np.asarray(rs_intrinsic_matrix, dtype=float).reshape(1, 3, 3) + rs_intrinsic_matrix_tensor = torch.tensor(rs_intrinsic_matrix, dtype=torch.float32, device=camera.device) # Set matrix into simulator - camera.set_intrinsic_matrices(rs_intrinsic_matrix.clone()) + camera.set_intrinsic_matrices(rs_intrinsic_matrix_tensor) # Simulate physics for _ in range(10): @@ -388,10 +388,10 @@ def test_intrinsic_matrix(setup_sim_camera): # update camera camera.update(dt) # Check that matrix is correct - torch.testing.assert_close(rs_intrinsic_matrix[0, 0, 0], camera.data.intrinsic_matrices[0, 0, 0]) - torch.testing.assert_close(rs_intrinsic_matrix[0, 1, 1], camera.data.intrinsic_matrices[0, 1, 1]) - torch.testing.assert_close(rs_intrinsic_matrix[0, 0, 2], camera.data.intrinsic_matrices[0, 0, 2]) - torch.testing.assert_close(rs_intrinsic_matrix[0, 1, 2], camera.data.intrinsic_matrices[0, 1, 2]) + assert np.isclose(rs_intrinsic_matrix[0, 0, 0], camera.data.intrinsic_matrices.torch[0, 0, 0].item()) + assert np.isclose(rs_intrinsic_matrix[0, 1, 1], camera.data.intrinsic_matrices.torch[0, 1, 1].item()) + assert np.isclose(rs_intrinsic_matrix[0, 0, 2], camera.data.intrinsic_matrices.torch[0, 0, 2].item()) + assert np.isclose(rs_intrinsic_matrix[0, 1, 2], camera.data.intrinsic_matrices.torch[0, 1, 2].item()) def test_depth_clipping(setup_sim_camera): @@ -1206,18 +1206,18 @@ def test_camera_pose_update_reflected_in_render(setup_camera_device, device): try: sim.reset() - target = torch.tensor([[0.0, 0.0, 0.0]], dtype=torch.float32, device=camera.device) + target = np.asarray([[0.0, 0.0, 0.0]], dtype=np.float32) max_range = cam_cfg.spawn.clipping_range[1] # -- close position -- - eyes_close = torch.tensor([[2.0, 2.0, 2.0]], dtype=torch.float32, device=camera.device) + eyes_close = np.asarray([[2.0, 2.0, 2.0]], dtype=np.float32) camera.set_world_poses_from_view(eyes_close, target) sim.step() camera.update(dt) depth_close = camera.data.output["distance_to_camera"].clone() # -- far position -- - eyes_far = torch.tensor([[8.0, 8.0, 8.0]], dtype=torch.float32, device=camera.device) + eyes_far = np.asarray([[8.0, 8.0, 8.0]], dtype=np.float32) camera.set_world_poses_from_view(eyes_far, target) sim.step() camera.update(dt) 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 16170c40610e..8f5014b3a860 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 @@ -400,12 +400,13 @@ def test_output_equal_to_usdcamera(setup_simulation, data_types): sim.reset() sim.play() - # convert to torch tensors - eyes = torch.tensor([[2.5, 2.5, 4.5]], dtype=torch.float32, device=camera_warp.device) - targets = torch.tensor([[0.0, 0.0, 0.0]], dtype=torch.float32, device=camera_warp.device) + eyes_np = np.asarray([[2.5, 2.5, 4.5]], dtype=np.float32) + targets_np = np.asarray([[0.0, 0.0, 0.0]], dtype=np.float32) + eyes = torch.tensor(eyes_np, dtype=torch.float32, device=camera_warp.device) + targets = torch.tensor(targets_np, dtype=torch.float32, device=camera_warp.device) # set views camera_warp.set_world_poses_from_view(eyes, targets) - camera_usd.set_world_poses_from_view(eyes, targets) + camera_usd.set_world_poses_from_view(eyes_np, targets_np) # perform steps for _ in range(5): @@ -440,7 +441,7 @@ def test_output_equal_to_usdcamera(setup_simulation, data_types): elif data_type == "normals": # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( - camera_usd.data.output[data_type][..., :3], + camera_usd.data.output[data_type].torch[..., :3], camera_warp.data.output[data_type].torch, rtol=1e-5, atol=1e-4, @@ -584,7 +585,7 @@ def test_depth_output_equal_to_usd_camera_heterogeneous_scene(setup_simulation): eyes = env_origins + torch.tensor((1.8, -2.5, 2.5), dtype=torch.float32, device=sim.device) targets = env_origins + torch.tensor((0.0, 0.0, 0.0), dtype=torch.float32, device=sim.device) camera_warp.set_world_poses_from_view(eyes=eyes, targets=targets) - camera_usd.set_world_poses_from_view(eyes=eyes, targets=targets) + camera_usd.set_world_poses_from_view(eyes=eyes.cpu().numpy(), targets=targets.cpu().numpy()) for _ in range(5): sim.render() @@ -695,7 +696,7 @@ def test_output_equal_to_usdcamera_offset(setup_simulation): # check normals # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( - camera_usd.data.output["normals"][..., :3], + camera_usd.data.output["normals"].torch[..., :3], camera_warp.data.output["normals"].torch, rtol=1e-5, atol=1e-4, @@ -771,8 +772,8 @@ def test_output_equal_to_usdcamera_prim_offset(setup_simulation): camera_warp.update(dt) # check if pos and orientation are correct - torch.testing.assert_close(camera_warp.data.pos_w[0], camera_usd.data.pos_w[0]) - _assert_quat_close(camera_warp.data.quat_w_ros[0], camera_usd.data.quat_w_ros[0]) + torch.testing.assert_close(camera_warp.data.pos_w.torch[0], camera_usd.data.pos_w.torch[0]) + _assert_quat_close(camera_warp.data.quat_w_ros.torch[0], camera_usd.data.quat_w_ros.torch[0]) # check image data torch.testing.assert_close( @@ -791,7 +792,7 @@ def test_output_equal_to_usdcamera_prim_offset(setup_simulation): # check normals # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( - camera_usd.data.output["normals"][..., :3], + camera_usd.data.output["normals"].torch[..., :3], camera_warp.data.output["normals"].torch, rtol=1e-5, atol=1e-4, @@ -891,7 +892,7 @@ def test_output_equal_to_usd_camera_intrinsics(setup_simulation, height, width): cam_warp_output, cam_usd_output, atol=5e-5, - rtol=5e-6, + rtol=1e-5, ) del camera_usd, camera_warp @@ -941,12 +942,12 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_simulation): # set intrinsic matrix # NOTE: extend the test to cover aperture offsets once supported by the usd camera - intrinsic_matrix = torch.tensor( - [[380.0831, 0.0, camera_cfg_usd.width / 2, 0.0, 380.0831, camera_cfg_usd.height / 2, 0.0, 0.0, 1.0]], - device=camera_warp.device, + intrinsic_matrix_np = np.asarray( + [[380.0831, 0.0, camera_cfg_usd.width / 2, 0.0, 380.0831, camera_cfg_usd.height / 2, 0.0, 0.0, 1.0]] ).reshape(1, 3, 3) + intrinsic_matrix = torch.tensor(intrinsic_matrix_np, device=camera_warp.device) camera_warp.set_intrinsic_matrices(intrinsic_matrix, focal_length=10) - camera_usd.set_intrinsic_matrices(intrinsic_matrix, focal_length=10) + camera_usd.set_intrinsic_matrices(torch.tensor(intrinsic_matrix_np, device=camera_usd.device), focal_length=10) # set camera position camera_warp.set_world_poses_from_view( @@ -954,8 +955,8 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_simulation): targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_warp.device), ) camera_usd.set_world_poses_from_view( - eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_usd.device), - targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_usd.device), + eyes=np.asarray([[0.0, 0.0, 5.0]], dtype=np.float32), + targets=np.asarray([[0.0, 0.0, 0.0]], dtype=np.float32), ) # perform steps diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index db252f9456c8..f255caaf94af 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -510,12 +510,13 @@ def test_output_equal_to_usdcamera(setup_sim): sim.reset() sim.play() - # convert to torch tensors - eyes = torch.tensor([[2.5, 2.5, 4.5]], dtype=torch.float32, device=camera_warp.device) - targets = torch.tensor([[0.0, 0.0, 0.0]], dtype=torch.float32, device=camera_warp.device) + eyes_np = np.asarray([[2.5, 2.5, 4.5]], dtype=np.float32) + targets_np = np.asarray([[0.0, 0.0, 0.0]], dtype=np.float32) + eyes = torch.tensor(eyes_np, dtype=torch.float32, device=camera_warp.device) + targets = torch.tensor(targets_np, dtype=torch.float32, device=camera_warp.device) # set views camera_warp.set_world_poses_from_view(eyes, targets) - camera_usd.set_world_poses_from_view(eyes, targets) + camera_usd.set_world_poses_from_view(eyes_np, targets_np) # perform steps for _ in range(5): @@ -562,7 +563,7 @@ def test_output_equal_to_usdcamera(setup_sim): # check normals # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( - camera_usd.data.output["normals"][..., :3], + camera_usd.data.output["normals"].torch[..., :3], camera_warp.data.output["normals"].torch, rtol=1e-5, atol=1e-4, @@ -638,7 +639,7 @@ def test_output_equal_to_usdcamera_offset(setup_sim): # check normals # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( - camera_usd.data.output["normals"][..., :3], + camera_usd.data.output["normals"].torch[..., :3], camera_warp.data.output["normals"].torch, rtol=1e-5, atol=1e-4, @@ -712,8 +713,8 @@ def test_output_equal_to_usdcamera_prim_offset(setup_sim): camera_warp.update(dt) # check if pos and orientation are correct - torch.testing.assert_close(camera_warp.data.pos_w[0], camera_usd.data.pos_w[0]) - _assert_quat_close(camera_warp.data.quat_w_ros[0], camera_usd.data.quat_w_ros[0]) + torch.testing.assert_close(camera_warp.data.pos_w.torch[0], camera_usd.data.pos_w.torch[0]) + _assert_quat_close(camera_warp.data.quat_w_ros.torch[0], camera_usd.data.quat_w_ros.torch[0]) # check image data torch.testing.assert_close( @@ -732,7 +733,7 @@ def test_output_equal_to_usdcamera_prim_offset(setup_sim): # check normals # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case torch.testing.assert_close( - camera_usd.data.output["normals"][..., :3], + camera_usd.data.output["normals"].torch[..., :3], camera_warp.data.output["normals"].torch, rtol=1e-5, atol=1e-4, @@ -857,7 +858,7 @@ def test_output_equal_to_usd_camera_intrinsics(setup_sim, focal_length): cam_warp_output, cam_usd_output, atol=5e-5, - rtol=5e-6, + rtol=1e-5, ) del camera_warp, camera_usd @@ -919,14 +920,15 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_sim, focal_length_ # camera_usd.set_intrinsic_matrices(intrinsic_matrix, focal_length=10) # set camera position + eyes_np = np.asarray([[0.0, 0.0, 5.0]], dtype=np.float32) + targets_np = np.asarray([[0.0, 0.0, 0.0]], dtype=np.float32) + eyes = torch.tensor(eyes_np, device=camera_warp.device) + targets = torch.tensor(targets_np, device=camera_warp.device) camera_warp.set_world_poses_from_view( - eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_warp.device), - targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_warp.device), - ) - camera_usd.set_world_poses_from_view( - eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_usd.device), - targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_usd.device), + eyes=eyes, + targets=targets, ) + camera_usd.set_world_poses_from_view(eyes=eyes_np, targets=targets_np) # perform steps for _ in range(5): From 3d10e743c90d6f6c93d0c104546f628b8085c476 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Mon, 18 May 2026 14:14:14 +0200 Subject: [PATCH 099/133] [OVPHYSX] SceneDataProvider (#5589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds `OvPhysxSceneDataProvider`, the OVPhysX-backend implementation of `BaseSceneDataProvider`. Enables Newton-based visualizers (Rerun, Viser, native Newton viewport) to render OVPhysX simulations. Also fixes the `SceneDataProvider` factory dispatch (and adds a regression test) so the substring check `"physx" in "ovphysxmanager"` no longer routes ovphysx to PhysX. Fixes #5327 > [!IMPORTANT] > **Stacked on #5459** (`[OVPHYSX] Articulation rewrite`). The base of this PR is `develop`, so the diff includes every commit from #5459 — review only the 16 SDP-specific commits on top (`git log antoiner/feat/ovphysx_articulation..antoiner/feat/ovphysx_scene_data_provider`). Once #5459 merges, this PR will rebase cleanly. ## Architectural notes Mirrors `PhysxSceneDataProvider` end-to-end: build a Newton model from the scene's `ClonePlan` at init (gated on `SceneDataRequirement.requires_newton_model`), then on every `update()` read body poses through ovphysx `TensorBinding`s and write them into the Newton state's `body_q` via a single Warp kernel. The interesting OVPhysX-specific surface: - **One-pattern-per-binding**: the ovphysx wheel's `PhysX.create_tensor_binding` accepts a single glob-style pattern per binding (not a list, unlike PhysX's `RigidBodyView`). We bucket Newton body paths by their env-wildcard form (`/World/envs/env_*/Robot/cart`, `/World/envs/env_*/Robot/pole`, ...), one pose+velocity binding per distinct relative path. Cartpole produces 2 bindings; Allegro hand ~17 — each covering all envs. - **`RIGID_BODY_POSE` covers everything**: confirmed via standalone probe + wheel docs that the matcher accepts any prim with `UsdPhysics.RigidBodyAPI`, including articulation links. No separate `LINK_POSE` plumbing is needed. Paths flow through a `RigidBodyAPI` filter (mirroring `PhysxSceneDataProvider._setup_rigid_body_view` line ~281) so joints and articulation-root xforms are excluded from binding patterns. - **Wheel-0.4 `binding.read(dst)` API**: pre-allocated warp destination buffers, so per-step reads are allocation-free. - **View-order reorder**: each binding's `prim_paths` is matched against the Newton `body_label` ordering to build a per-binding reorder tensor with `-1` sentinels for rows owned by other bindings. The pose merge respects partial coverage across bindings, with `FrameView` fallback for anything that lacks `RigidBodyAPI` (cameras, decorative xforms). - **Factory fix**: `SceneDataProvider._get_backend` now checks `"ovphysx"` explicitly before `"physx"` so the substring overlap doesn't route the wrong backend. ## Files changed (16 commits since `antoiner/feat/ovphysx_articulation`) - `source/isaaclab/isaaclab/physics/scene_data_provider.py` — factory dispatch fix (+9/-1). - `source/isaaclab/test/sim/test_scene_data_provider_factory.py` — new parametrized regression test (44 lines). - `source/isaaclab_ovphysx/isaaclab_ovphysx/scene_data_providers/__init__.py`, `__init__.pyi` — package scaffolding. - `source/isaaclab_ovphysx/isaaclab_ovphysx/scene_data_providers/ovphysx_scene_data_provider.py` — the provider (~760 lines). - `source/isaaclab_ovphysx/test/scene_data_providers/test_ovphysx_scene_data_provider.py` — 18 stub-based unit tests covering factory dispatch, metadata, env discovery, Newton-model build, binding setup, pose-merge orchestration, transform/velocity getters, view-order reorder, camera-transform stage walk. - `docs/source/api/index.rst` + `docs/source/api/lab_ovphysx/isaaclab_ovphysx.scene_data_providers.rst` — first `isaaclab_ovphysx` section in the API reference. - `source/isaaclab/changelog.d/antoiner-feat-ovphysx-scene-data-provider.rst` — patch fragment for the factory fix. - `source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-scene-data-provider.minor.rst` — minor fragment for the new provider. ## Type of change - New feature (non-breaking change which adds functionality). - Bug fix (factory dispatch). ## Validation 1. **Unit tests** — `./isaaclab.sh -p -m pytest source/isaaclab/test/sim/test_scene_data_provider_factory.py source/isaaclab_ovphysx/test/scene_data_providers/` → 23 passed. 2. **End-to-end** — `./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task=Isaac-Cartpole-Direct-v0 --visualizer newton --num_envs 4096 presets=ovphysx` reaches the simulation loop; visualizer renders the cartpole scene. 3. **Pre-commit** — `./isaaclab.sh -f` clean. 4. **Pattern probe** — standalone reproduction at `/tmp/probe_rigid_body_pose_on_articulation.py` confirmed `RIGID_BODY_POSE` accepts articulation-link patterns. Reproducible against the wheel's bundled minimal articulation USD. ## Known follow-ups (not in this PR) - Extract `_build_newton_model_from_clone_plan` into a shared helper used by both PhysX and OVPhysX providers (currently a verbatim copy). - `FrameView` factory could grow an explicit `"ovphysx"` registration entry (today's dispatch falls through to the `"physx"` branch, which works because `FabricFrameView` reads from the shared USD stage). - Pattern length cap: if a scene's distinct-body-path count exceeds the wheel's per-pattern matcher limit (unknown), chunk into multiple bindings indexed by buffer-row offset. Hasn't bitten yet. - A future `test_ovphysx_scene_data_provider_visualizer_contract.py` mirroring `test_physx_scene_data_provider_visualizer_contract.py`. ## 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 feature works - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- ...-feat-ovphysx-scene-data-backend.minor.rst | 16 + .../physics/ovphysx_manager.py | 197 +++++++++- .../test_ovphysx_scene_data_backend.py | 352 ++++++++++++++++++ 3 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-scene-data-backend.minor.rst create mode 100644 source/isaaclab_ovphysx/test/physics/test_ovphysx_scene_data_backend.py diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-scene-data-backend.minor.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-scene-data-backend.minor.rst new file mode 100644 index 000000000000..9612b5dcac1b --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-scene-data-backend.minor.rst @@ -0,0 +1,16 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.physics.OvPhysxSceneDataBackend` and + :meth:`~isaaclab_ovphysx.physics.OvPhysxManager.get_scene_data_backend` + so the central + :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` + (introduced in #5128) can expose OVPhysX rigid-body transforms to + Rerun, Viser, and the native Newton viewport. The backend creates one + ovphysx ``TT.RIGID_BODY_POSE`` binding per distinct env-wildcard + rigid-body prim path (cartpole produces 2 bindings, Allegro hand ~17, + each covering all envs), reads each binding into a pre-allocated + ``wp.float32`` staging buffer via ``TensorBinding.read(dst)``, and + concatenates the per-binding reads into a single ``wp.transformf`` + merged buffer that the central provider consumes as + :class:`~isaaclab.physics.scene_data_backend.SceneDataFormat.Transform`. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index e96154bacf59..3b6f49e916f1 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -16,21 +16,178 @@ import inspect import logging import os +import re import tempfile from typing import TYPE_CHECKING, Any, ClassVar -from isaaclab.physics import PhysicsEvent, PhysicsManager +import warp as wp + +from pxr import UsdPhysics + +from isaaclab.physics import PhysicsEvent, PhysicsManager, SceneDataBackend, SceneDataFormat if TYPE_CHECKING: from isaaclab.sim.simulation_context import SimulationContext from .ovphysx_manager_cfg import OvPhysxCfg -__all__ = ["OvPhysxManager"] +__all__ = ["OvPhysxManager", "OvPhysxSceneDataBackend"] logger = logging.getLogger(__name__) +class OvPhysxSceneDataBackend(SceneDataBackend): + """Scene-data backend for the OVPhysX physics manager. + + Mirrors the contract of ``PhysxSceneDataBackend`` but adapts to the + ovphysx wheel's one-pattern-per-binding API: each distinct env-wildcard + rigid-body prim path produces its own ``TT.RIGID_BODY_POSE`` binding. + :attr:`transforms` reads each binding into its pre-allocated float32 + staging buffer and concatenates them into a single ``wp.transformf`` + array. + + The merged-buffer + staging-buffer separation is required because the + wheel's ``TensorBinding.read(dst)`` writes into ``dst`` only when + ``dst.shape == binding.shape``, so we cannot read directly into a slice + of the merged buffer. + + Unlike PhysX -- which receives a live :class:`omni.physics.tensors.SimulationView` + via a ``simulation_view`` property setter and discovers prims lazily -- + OVPhysX wires bindings through an explicit :meth:`setup` call that + takes the live ``ovphysx.PhysX`` handle and the USD stage. The wheel + exposes a ``physx + stage`` pair rather than a single ``SimulationView``, + so a property setter would have to either bundle the two or fire on the + second assignment; the explicit call keeps the lifecycle obvious. + """ + + def __init__(self): + self._physx = None + # Each entry: ``{"pattern": str, "pose": TensorBinding, + # "pose_buf": wp.array (float32, (N, 7)), + # "pose_buf_transformf": wp.array (transformf, (N,)), + # "row_offset": int, "row_count": int}``. + # The ``pose_buf_transformf`` view aliases ``pose_buf`` via zero-copy + # ``wp.array(ptr=...)``; cached at setup time so per-step reads in + # :attr:`transforms` don't churn Python allocations. + self._rigid_bindings: list[dict[str, Any]] = [] + self._merged_transforms: wp.array | None = None + self._scene_data = SceneDataFormat.Transform() + + @property + def transform_count(self) -> int: + """Sum of per-binding row counts.""" + return sum(int(entry["row_count"]) for entry in self._rigid_bindings) + + @property + def transform_paths(self) -> list[str]: + """Concatenated ``prim_paths`` across all bindings, in registration order.""" + paths: list[str] = [] + for entry in self._rigid_bindings: + paths.extend(list(entry["pose"].prim_paths)) + return paths + + def setup(self, physx, stage, device: str) -> None: + """Discover RigidBodyAPI prims, dedup by env-wildcard form, create one binding per pattern. + + Args: + physx: Live ``ovphysx.PhysX`` instance (the wheel handle). + stage: USD stage to traverse for RigidBodyAPI prims. + device: Warp device string used to allocate the staging and merged buffers. + """ + from isaaclab_ovphysx import tensor_types as TT # local: keep heavy ovphysx out of module load + + self._physx = physx + self._rigid_bindings = [] + self._merged_transforms = None + + if stage is None: + return + + # Discover RigidBodyAPI prims, dedup by env-wildcard form. + patterns: set[str] = set() + for prim in stage.Traverse(): + if prim.HasAPI(UsdPhysics.RigidBodyAPI): + patterns.add(re.sub(r"/World/envs/env_\d+", "/World/envs/env_*", prim.GetPath().pathString)) + + if not patterns: + return + + # One pose binding per distinct pattern. + total_count = 0 + for pattern in sorted(patterns): + try: + pose_binding = physx.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_POSE) + except Exception as exc: + logger.warning("Failed to create RIGID_BODY_POSE binding for %s: %s", pattern, exc) + continue + row_count = int(pose_binding.shape[0]) + if row_count == 0: + logger.debug("Pattern %s matched 0 rigid bodies; skipping.", pattern) + continue + pose_buf = wp.zeros(pose_binding.shape, dtype=wp.float32, device=device) + # Zero-copy reinterpret of the (N, 7) float32 staging buffer as (N,) wp.transformf. + # Same pointer + layout; transformf is 7 float32s (pos.xyz + quat.xyzw). Cached + # so per-step ``transforms`` reads don't reallocate the view object. + pose_buf_transformf = wp.array( + ptr=pose_buf.ptr, + shape=(row_count,), + dtype=wp.transformf, + device=str(pose_buf.device), + copy=False, + ) + self._rigid_bindings.append( + { + "pattern": pattern, + "pose": pose_binding, + "pose_buf": pose_buf, + "pose_buf_transformf": pose_buf_transformf, + "row_offset": total_count, + "row_count": row_count, + } + ) + total_count += row_count + + if total_count > 0: + self._merged_transforms = wp.zeros((total_count,), dtype=wp.transformf, device=device) + + @property + def transforms(self) -> SceneDataFormat.Transform: + """Read all bindings into the merged buffer; return as ``SceneDataFormat.Transform``. + + Each binding's float32 ``(N, 7)`` read buffer is reinterpreted as ``(N,)`` of + ``wp.transformf`` (zero-copy via ``wp.array(ptr=..., dtype=wp.transformf)``, + cached on the entry at setup time) and copied into the merged buffer at the + binding's ``row_offset``. + + Returns: + ``SceneDataFormat.Transform`` whose ``transforms`` field is a + ``wp.array(dtype=wp.transformf)`` of length :attr:`transform_count`. + Each ``wp.transformf`` row carries position [m] followed by + quaternion (xyzw, unit). ``transforms`` is ``None`` when no + bindings are wired. + """ + if self._merged_transforms is None or not self._rigid_bindings: + self._scene_data.transforms = self._merged_transforms + return self._scene_data + + for entry in self._rigid_bindings: + try: + entry["pose"].read(entry["pose_buf"]) + except Exception as exc: + logger.warning("RIGID_BODY_POSE read failed for %s: %s", entry["pattern"], exc) + continue + wp.copy( + self._merged_transforms, + entry["pose_buf_transformf"], + dest_offset=int(entry["row_offset"]), + src_offset=0, + count=int(entry["row_count"]), + ) + + self._scene_data.transforms = self._merged_transforms + return self._scene_data + + class OvPhysxManager(PhysicsManager): """Manages an ovphysx-backed physics simulation lifecycle. @@ -59,6 +216,7 @@ class OvPhysxManager(PhysicsManager): # parent_positions is a list of (x, y, z) tuples — one per target. _pending_clones: ClassVar[list[tuple[str, list[str], list[tuple[float, float, float]]]]] = [] _atexit_registered: ClassVar[bool] = False + _scene_data_backend: ClassVar[OvPhysxSceneDataBackend | None] = None @classmethod def get_dt(cls) -> float: @@ -108,6 +266,14 @@ def initialize(cls, sim_context: SimulationContext) -> None: cls._usd_handle = None cls._stage_path = None cls._pending_clones = [] + # Construct the SceneDataBackend eagerly so :class:`SimulationContext` + # captures a real instance (not ``None``) when it builds the central + # :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` in + # its own ``__init__``. Bindings stay empty until :meth:`_warmup_and_load` + # calls :meth:`OvPhysxSceneDataBackend.setup`, at which point the wheel + # and the USD stage are live. Matches PhysX's pattern of constructing + # the backend during ``initialize()``. + cls._scene_data_backend = OvPhysxSceneDataBackend() @classmethod def reset(cls, soft: bool = False) -> None: @@ -149,6 +315,11 @@ def close(cls) -> None: cls._usd_handle = None cls._stage_path = None cls._warmup_done = False + # Drop the SceneDataBackend singleton: its cached ``TensorBinding`` handles + # point into the wheel's prior scene which we just ``physx.reset()``-ed. + # The next :class:`SimulationContext` re-creates the backend in + # :meth:`initialize`. Matches Newton's lifecycle. + cls._scene_data_backend = None if cls._tmp_dir is not None: cls._tmp_dir.cleanup() @@ -185,6 +356,20 @@ def get_physx_instance(cls) -> Any: """Return the underlying ovphysx.PhysX instance (or None if not yet created).""" return cls._physx + @classmethod + def get_scene_data_backend(cls) -> SceneDataBackend: + """Return the SceneDataBackend for the central SceneDataProvider. + + Constructed eagerly in :meth:`initialize` so :class:`SimulationContext` + captures a real instance (not ``None``) when wiring up the central + :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider`. Bindings + are empty until :meth:`_warmup_and_load` calls + :meth:`OvPhysxSceneDataBackend.setup` against the live ovphysx ``PhysX`` + and USD stage; reads against an unsetup backend return empty data + rather than raising. + """ + return cls._scene_data_backend + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @@ -286,6 +471,14 @@ def _warmup_and_load(cls) -> None: if ovphysx_device == "gpu": cls._physx.warmup_gpu() + # Initialize the SceneDataBackend now that the wheel's PhysX is live and + # the USD is loaded. The central + # ``isaaclab.scene.scene_data_provider.SceneDataProvider`` consumes this + # via :meth:`get_scene_data_backend`. + if cls._scene_data_backend is None: + cls._scene_data_backend = OvPhysxSceneDataBackend() + cls._scene_data_backend.setup(cls._physx, sim.stage, PhysicsManager._device) + cls.dispatch_event(PhysicsEvent.MODEL_INIT, payload={}) cls._warmup_done = True diff --git a/source/isaaclab_ovphysx/test/physics/test_ovphysx_scene_data_backend.py b/source/isaaclab_ovphysx/test/physics/test_ovphysx_scene_data_backend.py new file mode 100644 index 000000000000..013ef8bdc092 --- /dev/null +++ b/source/isaaclab_ovphysx/test/physics/test_ovphysx_scene_data_backend.py @@ -0,0 +1,352 @@ +# 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 OvPhysxSceneDataBackend (new SceneDataBackend interface, post-#5128).""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +# The CI isaaclab_ov* pattern unintentionally collects isaaclab_ovphysx tests, +# but the ovphysx wheel is not installed in that environment. Skip gracefully +# so the isaaclab_ov CI pipeline is not blocked by an unrelated dependency. +pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") + + +def _make_stub_binding(prim_paths: list[str]) -> SimpleNamespace: + """Stub an ovphysx ``TensorBinding`` exposing ``shape``, ``count``, ``prim_paths``, and ``read(dst)``.""" + n = len(prim_paths) + return SimpleNamespace( + shape=(n, 7), + count=n, + prim_paths=list(prim_paths), + read=lambda dst: None, # no-op write; transform_count/paths don't trigger reads. + ) + + +def _bare_backend(): + """Construct an ``OvPhysxSceneDataBackend`` instance bypassing the live-wheel ``__init__``. + + Tests seed ``_rigid_bindings`` and the merged buffer directly, mirroring the + bypass-init pattern used in ``test_newton_manager_visualization_state.py``. + """ + from isaaclab_ovphysx.physics.ovphysx_manager import OvPhysxSceneDataBackend + + return object.__new__(OvPhysxSceneDataBackend) + + +def test_transform_count_sums_across_bindings(): + """``transform_count`` returns the sum of each binding's row count.""" + b = _bare_backend() + b._rigid_bindings = [ + { + "pose": _make_stub_binding(["/World/envs/env_0/Cube", "/World/envs/env_1/Cube"]), + "pose_buf": None, + "row_offset": 0, + "row_count": 2, + }, + {"pose": _make_stub_binding(["/World/envs/env_0/Pole"]), "pose_buf": None, "row_offset": 2, "row_count": 1}, + ] + assert b.transform_count == 3 + + +def test_transform_paths_concatenates_prim_paths(): + """``transform_paths`` concatenates each binding's ``prim_paths`` in registration order.""" + b = _bare_backend() + b._rigid_bindings = [ + { + "pose": _make_stub_binding(["/World/envs/env_0/Cube", "/World/envs/env_1/Cube"]), + "pose_buf": None, + "row_offset": 0, + "row_count": 2, + }, + {"pose": _make_stub_binding(["/World/envs/env_0/Pole"]), "pose_buf": None, "row_offset": 2, "row_count": 1}, + ] + assert b.transform_paths == [ + "/World/envs/env_0/Cube", + "/World/envs/env_1/Cube", + "/World/envs/env_0/Pole", + ] + + +def test_transform_count_zero_when_no_bindings(): + """``transform_count`` returns 0 when the bindings list is empty.""" + b = _bare_backend() + b._rigid_bindings = [] + assert b.transform_count == 0 + + +def test_transform_paths_empty_when_no_bindings(): + """``transform_paths`` returns an empty list when the bindings list is empty.""" + b = _bare_backend() + b._rigid_bindings = [] + assert b.transform_paths == [] + + +def test_setup_creates_one_binding_per_distinct_pattern(monkeypatch): + """``setup(physx, stage, device)`` buckets RigidBodyAPI prims by env-wildcard form. + + For cartpole-shaped scenes (``cart``, ``pole``), expect 2 bindings — one + per distinct env-relative prim path. + """ + from isaaclab_ovphysx.physics.ovphysx_manager import OvPhysxSceneDataBackend + + b = OvPhysxSceneDataBackend() + + # Stage stub: traversal yields four RigidBodyAPI prims (cart/pole across two envs). + paths = [ + "/World/envs/env_0/Robot/cart", + "/World/envs/env_0/Robot/pole", + "/World/envs/env_1/Robot/cart", + "/World/envs/env_1/Robot/pole", + ] + + def fake_traverse(): + for p in paths: + yield SimpleNamespace( + HasAPI=lambda api: True, + GetPath=lambda p=p: SimpleNamespace(pathString=p), + ) + + stage = SimpleNamespace(Traverse=fake_traverse) + + created: list[SimpleNamespace] = [] + + class FakePhysX: + def create_tensor_binding(self, pattern, tensor_type): + shape = (2, 7) # 2 envs match each pattern + b = SimpleNamespace( + pattern=pattern, + tensor_type=tensor_type, + shape=shape, + count=2, + prim_paths=[], + read=lambda dst: None, + ) + created.append(b) + return b + + # Patch UsdPhysics so HasAPI in the test doesn't depend on the real PXR module. + import isaaclab_ovphysx.physics.ovphysx_manager as om_mod + + monkeypatch.setattr(om_mod, "UsdPhysics", SimpleNamespace(RigidBodyAPI=object())) + + b.setup(FakePhysX(), stage, "cpu") + + # Cartpole = 2 distinct env-wildcard patterns -> 2 bindings. + assert len(created) == 2 + assert {c.pattern for c in created} == { + "/World/envs/env_*/Robot/cart", + "/World/envs/env_*/Robot/pole", + } + # Per-binding row counts sum to 4. + assert b.transform_count == 4 + + +def test_transforms_reads_each_binding_and_returns_transform_format(): + """``transforms`` writes each binding's poses into the merged buffer at its offset. + + The returned struct is ``SceneDataFormat.Transform`` with ``transforms`` set to + the merged ``wp.transformf`` array. + """ + import warp as _wp + + _wp.init() + + from isaaclab_ovphysx.physics.ovphysx_manager import OvPhysxSceneDataBackend + + b = OvPhysxSceneDataBackend() + b._merged_transforms = _wp.zeros((3,), dtype=_wp.transformf, device="cpu") + + # Two bindings: first with 2 rows, second with 1 row. + buf_a = _wp.zeros((2, 7), dtype=_wp.float32, device="cpu") + buf_b = _wp.zeros((1, 7), dtype=_wp.float32, device="cpu") + + def fake_read_a(dst): + # Fill with row-distinct sentinel transforms (pos.x = row index, quat = identity). + import numpy as np + + host = np.array([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], [2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]], dtype=np.float32) + _wp.copy(dst, _wp.from_numpy(host, dtype=_wp.float32, device="cpu").reshape((2, 7))) + + def fake_read_b(dst): + import numpy as np + + host = np.array([[3.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]], dtype=np.float32) + _wp.copy(dst, _wp.from_numpy(host, dtype=_wp.float32, device="cpu").reshape((1, 7))) + + # ``pose_buf_transformf`` is the zero-copy transformf view over the float32 staging + # buffer; production code caches it at setup time. Tests mirror that shape here. + buf_a_tf = _wp.array(ptr=buf_a.ptr, shape=(2,), dtype=_wp.transformf, device="cpu", copy=False) + buf_b_tf = _wp.array(ptr=buf_b.ptr, shape=(1,), dtype=_wp.transformf, device="cpu", copy=False) + b._rigid_bindings = [ + { + "pattern": "/World/envs/env_*/Cube", + "pose": SimpleNamespace(read=fake_read_a, prim_paths=["/Cube0", "/Cube1"]), + "pose_buf": buf_a, + "pose_buf_transformf": buf_a_tf, + "row_offset": 0, + "row_count": 2, + }, + { + "pattern": "/World/envs/env_*/Pole", + "pose": SimpleNamespace(read=fake_read_b, prim_paths=["/Pole"]), + "pose_buf": buf_b, + "pose_buf_transformf": buf_b_tf, + "row_offset": 2, + "row_count": 1, + }, + ] + + out = b.transforms + assert out is b._scene_data + assert out.transforms is b._merged_transforms + + merged_host = out.transforms.numpy() # (3,) of transformf -> view as float32 (3, 7) for assertion + # Each transformf is 7 floats (pos.xyz + quat.xyzw). Verify row 0 / 1 / 2 contents. + flat = merged_host.view(" Date: Mon, 18 May 2026 08:52:46 -0700 Subject: [PATCH 100/133] Add warp environment docs and timer alignment (#4995) ## Summary * Add warp environment overview doc (`warp-environments.rst`) * Add stable-to-warp migration guide (`warp-env-migration.rst`) * Align step timer setup across all 4 env base classes (stable + warp, direct + manager) ## Dependencies Must be merged **after** (validated against develop at `9720047`): 1. https://github.com/isaac-sim/IsaacLab/pull/4829 2. https://github.com/isaac-sim/IsaacLab/pull/4945 ## Status Performance comparison data included in docs. --- .../newton-physics-integration/index.rst | 2 + .../warp-env-migration.rst | 280 +++++++++++++++ .../warp-environments.rst | 331 ++++++++++++++++++ 3 files changed, 613 insertions(+) create mode 100644 docs/source/experimental-features/newton-physics-integration/warp-env-migration.rst create mode 100644 docs/source/experimental-features/newton-physics-integration/warp-environments.rst diff --git a/docs/source/experimental-features/newton-physics-integration/index.rst b/docs/source/experimental-features/newton-physics-integration/index.rst index afe783cc8716..b93c5a3fd2c0 100644 --- a/docs/source/experimental-features/newton-physics-integration/index.rst +++ b/docs/source/experimental-features/newton-physics-integration/index.rst @@ -38,6 +38,8 @@ For an overview of how the multi-backend architecture works, including how to ad :titlesonly: installation + warp-environments + warp-env-migration limitations-and-known-bugs solver-transitioning using-kamino diff --git a/docs/source/experimental-features/newton-physics-integration/warp-env-migration.rst b/docs/source/experimental-features/newton-physics-integration/warp-env-migration.rst new file mode 100644 index 000000000000..468ced739b4a --- /dev/null +++ b/docs/source/experimental-features/newton-physics-integration/warp-env-migration.rst @@ -0,0 +1,280 @@ +.. _warp-env-migration: + +Warp Environment Migration Guide +================================ + +This guide covers the key conventions and patterns used by the warp-first environment +infrastructure, useful for migrating existing torch environments or creating new ones +natively. For an overview of the warp env path itself (workflows, available envs, +performance, limitations, benchmarking), see :doc:`warp-environments`. + + +Design Rationale +~~~~~~~~~~~~~~~~ + +The warp environment path is built around `CUDA graph capture +`_. +A CUDA graph records a sequence of GPU operations (kernel launches, memory copies) during a +capture phase, then replays the entire sequence with a single launch. This eliminates per-kernel +CPU overhead — the parameter validation, kernel selection, and buffer setup that normally costs +20–200 μs per operation is performed once during graph instantiation and reused on every replay +(~10 μs total). All CPU-side code (Python logic, torch dispatching) executed during capture is +completely bypassed during replay. See the `Warp concurrency documentation +`_ for Warp's graph capture API +(``wp.ScopedCapture``). + +All design decisions in the warp infrastructure follow from this constraint: every operation in the +step loop must be a GPU kernel launch with stable memory pointers so that the captured graph can +be replayed without modification. + +Key consequences: + +- All buffers are **pre-allocated** — no dynamic allocation inside the step loop +- Data flows through **persistent ``wp.array`` pointers** — never replaced, only overwritten +- MDP terms are **pure ``@wp.kernel`` functions** — no Python branching on GPU data +- Reset uses **boolean masks** (``env_mask``) instead of index lists (``env_ids``) to avoid + variable-length indexing that changes graph topology + + +Project Structure +~~~~~~~~~~~~~~~~~ + +Warp-specific implementations that diverge from the torch-based managers and env classes live in the ``_experimental`` packages: + +- ``isaaclab_experimental`` — warp managers, base env classes, warp MDP terms +- ``isaaclab_tasks_experimental`` — warp task configs and task-specific MDP terms + +Any new warp implementation that differs from the torch-based managers or env classes belongs in these packages. +Warp task configs reference Newton physics directly (no ``PresetCfg``) since the warp path +is Newton-only. + + +Writing Warp MDP Terms +~~~~~~~~~~~~~~~~~~~~~~ + +Imports +^^^^^^^ + +Warp task configs import from the experimental packages: + +.. code-block:: python + + # Warp + from isaaclab_experimental.managers import ObservationTermCfg, RewardTermCfg, SceneEntityCfg + import isaaclab_experimental.envs.mdp as mdp + +The term config classes have the same interface — only the import path changes. + + +Common Pattern +^^^^^^^^^^^^^^ + +All warp MDP terms (observations, rewards, terminations, events, actions) follow the same +**kernel + launch** pattern. Torch terms use torch tensors and return results; warp terms +write into pre-allocated ``wp.array`` output buffers via ``@wp.kernel`` functions: + +.. code-block:: python + + # Torch — returns a tensor + def lin_vel_z_l2(env, asset_cfg) -> torch.Tensor: + return torch.square(asset.data.root_lin_vel_b[:, 2]) + + # Warp — writes into pre-allocated output + @wp.kernel + def _lin_vel_z_l2_kernel(vel: wp.array(...), out: wp.array(dtype=wp.float32)): + i = wp.tid() + out[i] = vel[i][2] * vel[i][2] + + def lin_vel_z_l2(env, out, asset_cfg) -> None: + wp.launch(_lin_vel_z_l2_kernel, dim=env.num_envs, inputs=[..., out]) + +The output buffer shapes differ by term type: + +- **Observations**: ``(num_envs, D)`` where D is the observation dimension +- **Rewards**: ``(num_envs,)`` +- **Terminations**: ``(num_envs,)`` with dtype ``bool`` +- **Events**: ``(num_envs,)`` mask — events don't produce output, they modify sim state + + +Observation Terms +^^^^^^^^^^^^^^^^^ + +Since warp terms write into pre-allocated buffers, the observation manager must know each +term's output dimension at initialization to allocate the correct ``(num_envs, D)`` output +array. This is resolved via a fallback chain (see +``ObservationManager._infer_term_dim_scalar`` in +``isaaclab_experimental/managers/observation_manager.py``): + +1. **Explicit ``out_dim`` in decorator** (preferred): + + .. code-block:: python + + @generic_io_descriptor_warp(out_dim=3, observation_type="RootState") + def base_lin_vel(env, out, asset_cfg) -> None: ... + + ``out_dim`` can be an integer, or a string that resolves at initialization: + + - ``"joint"`` — number of selected joints from ``asset_cfg`` + - ``"body:N"`` — N components per selected body from ``asset_cfg`` + - ``"command"`` — dimension from command manager + - ``"action"`` — dimension from action manager + +2. **``axes`` metadata**: Dimension equals the number of axes listed: + + .. code-block:: python + + @generic_io_descriptor_warp(axes=["X", "Y", "Z"], observation_type="RootState") + def projected_gravity(env, out, asset_cfg) -> None: ... + # → dimension = 3 + +3. **Legacy params**: ``term_dim``, ``out_dim``, or ``obs_dim`` keys in ``term_cfg.params``. + +4. **Asset config fallback**: Count of ``asset_cfg.joint_ids`` (or ``joint_ids_wp``) for + joint-level terms. + + +Event Terms +^^^^^^^^^^^ + +Events use ``env_mask`` (boolean ``wp.array``) instead of ``env_ids``, and each kernel +checks the mask to skip non-selected environments: + +.. code-block:: python + + def reset_joints_by_offset(env, env_mask, ...): + wp.launch(_kernel, dim=env.num_envs, inputs=[env_mask, ...]) + + @wp.kernel + def _kernel(env_mask: wp.array(dtype=wp.bool), ...): + i = wp.tid() + if not env_mask[i]: + return + # ... modify state for selected envs only + +- RNG uses per-env ``env.rng_state_wp`` (``wp.uint32``) instead of ``torch.rand`` +- **Startup/prestartup** events use the torch convention ``(env, env_ids, **params)`` +- **Reset/interval** events use the warp convention ``(env, env_mask, **params)`` + + +Action Terms +^^^^^^^^^^^^ + +Actions follow a **two-stage execution**: ``process_actions`` (called once per env step) scales +and clips raw actions, and ``apply_actions`` (called once per sim step) writes targets to the +asset. Both stages use warp kernels with pre-allocated ``_raw_actions`` and ``_processed_actions`` +buffers. + + +Capture Safety +^^^^^^^^^^^^^^ + +When writing terms that run inside the captured step loop, keep in mind: + +- **No ``wp.to_torch``** or torch arithmetic — stay in warp throughout +- **No lazy-evaluated properties** — use sim-bound (Tier 1) data directly; if a derived + quantity is needed, compute it inline in the kernel +- **No dynamic allocation** — all buffers must be pre-allocated in ``__init__`` + + +Parity Testing +~~~~~~~~~~~~~~ + +Two levels of parity testing are used to validate warp terms: + +**1. Implementation parity (torch vs warp)** — verifies that the warp kernel produces the +same result as the torch implementation. This is optional for terms that have no torch +counterpart (e.g. new terms written directly in warp). + +.. code-block:: python + + import isaaclab.envs.mdp.observations as torch_obs + import isaaclab_experimental.envs.mdp.observations as warp_obs + + # Torch baseline + expected = torch_obs.joint_pos(torch_env, asset_cfg=cfg) + + # Warp (uncaptured) + out = wp.zeros((num_envs, num_joints), dtype=wp.float32, device=device) + warp_obs.joint_pos(warp_env, out, asset_cfg=cfg) + actual = wp.to_torch(out) + + torch.testing.assert_close(actual, expected) + +**2. Capture parity (warp vs warp-captured)** — verifies that the term produces identical +results when replayed from a CUDA graph vs launched directly. A mismatch here indicates capture-unsafe +code (e.g. stale pointers, dynamic allocation, or lazy property access that doesn't replay). +This test should always be run, even for terms without a torch counterpart. + +.. code-block:: python + + # Warp uncaptured + out_uncaptured = wp.zeros((num_envs, num_joints), dtype=wp.float32, device=device) + warp_obs.joint_pos(warp_env, out_uncaptured, asset_cfg=cfg) + + # Warp captured (graph replay) + out_captured = wp.zeros((num_envs, num_joints), dtype=wp.float32, device=device) + with wp.ScopedCapture() as cap: + warp_obs.joint_pos(warp_env, out_captured, asset_cfg=cfg) + wp.capture_launch(cap.graph) + + torch.testing.assert_close(wp.to_torch(out_captured), wp.to_torch(out_uncaptured)) + +See ``source/isaaclab_experimental/test/envs/mdp/`` for complete parity test examples. + + +Available Warp MDP Terms +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Category + - Available Terms + * - Observations (11) + - | ``base_pos_z`` + | ``base_lin_vel`` + | ``base_ang_vel`` + | ``projected_gravity`` + | ``joint_pos`` + | ``joint_pos_rel`` + | ``joint_pos_limit_normalized`` + | ``joint_vel`` + | ``joint_vel_rel`` + | ``last_action`` + | ``generated_commands`` + * - Rewards (16) + - | ``is_alive`` + | ``is_terminated`` + | ``lin_vel_z_l2`` + | ``ang_vel_xy_l2`` + | ``flat_orientation_l2`` + | ``joint_torques_l2`` + | ``joint_vel_l1`` + | ``joint_vel_l2`` + | ``joint_acc_l2`` + | ``joint_deviation_l1`` + | ``joint_pos_limits`` + | ``action_rate_l2`` + | ``action_l2`` + | ``undesired_contacts`` + | ``track_lin_vel_xy_exp`` + | ``track_ang_vel_z_exp`` + * - Events (6) + - | ``reset_joints_by_offset`` + | ``reset_joints_by_scale`` + | ``reset_root_state_uniform`` + | ``push_by_setting_velocity`` + | ``apply_external_force_torque`` + | ``randomize_rigid_body_com`` + * - Terminations (4) + - | ``time_out`` + | ``root_height_below_minimum`` + | ``joint_pos_out_of_manual_limit`` + | ``illegal_contact`` + * - Actions (2) + - | ``JointPositionAction`` + | ``JointEffortAction`` + +Terms not listed here remain in torch only. When using an env that requires unlisted terms, +those terms must be implemented in warp first. diff --git a/docs/source/experimental-features/newton-physics-integration/warp-environments.rst b/docs/source/experimental-features/newton-physics-integration/warp-environments.rst new file mode 100644 index 000000000000..c1107741239b --- /dev/null +++ b/docs/source/experimental-features/newton-physics-integration/warp-environments.rst @@ -0,0 +1,331 @@ +.. _warp-environments: + +Warp Experimental Environments +============================== + +.. note:: + + The warp environment infrastructure lives in ``isaaclab_experimental`` and + ``isaaclab_tasks_experimental``. It's an experimental feature. + +The experimental extensions introduce **warp-first** environment infrastructure with CUDA graph capture +support. All environment-side computation (observations, rewards, resets, actions) runs as pure Warp +kernels, eliminating Python overhead and enabling CUDA graph capture for maximum throughput. + + +Workflows +~~~~~~~~~ + +Two environment workflows are supported: + +**Direct workflow** — ``DirectRLEnvWarp`` base class. You implement the step loop, observations, +rewards, and resets directly in your env class using Warp kernels. + +**Manager-based workflow** — ``ManagerBasedRLEnvWarp`` base class. You define MDP terms as +standalone Warp-kernel functions and compose them via configuration. + + +Available Environments +~~~~~~~~~~~~~~~~~~~~~~ + +Direct Warp Environments +^^^^^^^^^^^^^^^^^^^^^^^^ + +- ``Isaac-Cartpole-Direct-Warp-v0`` — Cartpole balance +- ``Isaac-Ant-Direct-Warp-v0`` — Ant locomotion +- ``Isaac-Humanoid-Direct-Warp-v0`` — Humanoid locomotion +- ``Isaac-Repose-Cube-Allegro-Direct-Warp-v0`` — Allegro hand cube repose + + +Manager-Based Warp Environments +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Classic** + +- ``Isaac-Cartpole-Warp-v0`` +- ``Isaac-Ant-Warp-v0`` +- ``Isaac-Humanoid-Warp-v0`` + +**Locomotion (Flat)** + +- ``Isaac-Velocity-Flat-Anymal-B-Warp-v0`` +- ``Isaac-Velocity-Flat-Anymal-C-Warp-v0`` +- ``Isaac-Velocity-Flat-Anymal-D-Warp-v0`` +- ``Isaac-Velocity-Flat-Cassie-Warp-v0`` +- ``Isaac-Velocity-Flat-G1-Warp-v0`` +- ``Isaac-Velocity-Flat-G1-Warp-v1`` +- ``Isaac-Velocity-Flat-H1-Warp-v0`` +- ``Isaac-Velocity-Flat-Unitree-A1-Warp-v0`` +- ``Isaac-Velocity-Flat-Unitree-Go1-Warp-v0`` +- ``Isaac-Velocity-Flat-Unitree-Go2-Warp-v0`` + +**Manipulation** + +- ``Isaac-Reach-Franka-Warp-v0`` +- ``Isaac-Reach-UR10-Warp-v0`` + + +Quick Start +~~~~~~~~~~~ + +.. code-block:: bash + + # Direct workflow + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Cartpole-Direct-Warp-v0 --num_envs 4096 --headless + + # Manager-based workflow + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Velocity-Flat-Anymal-C-Warp-v0 --num_envs 4096 --headless + +All RL libraries with warp-compatible wrappers are supported: RSL-RL, RL Games, SKRL, and +Stable-Baselines3. + + +Performance Comparison +~~~~~~~~~~~~~~~~~~~~~~ + +Step time comparison between the stable (torch/manager) and warp (CUDA graph captured) variants, +both running on the Newton physics backend. Measured over 300 iterations with 4096 environments. + +.. note:: + + The warp migration is an ongoing effort. Several components (e.g. scene write, actuator models) + have not yet been migrated to Warp kernels and still run through torch. Further performance + improvements are expected as these components are migrated. + +.. list-table:: + :header-rows: 1 + :widths: 30 12 15 15 12 + + * - Env + - Type + - Stable Step (us) + - Warp Step (us) + - Change + * - Cartpole-Direct + - Direct + - 5,274 + - 4,331 + - -17.88% + * - Ant-Direct + - Direct + - 6,368 + - 3,128 + - -50.88% + * - Humanoid-Direct + - Direct + - 13,937 + - 10,783 + - -22.63% + * - Allegro-Direct + - Direct + - 82,950 + - 74,570 + - -10.10% + * - Cartpole + - Manager + - 7,971 + - 3,642 + - -54.31% + * - Ant + - Manager + - 9,781 + - 4,672 + - -52.23% + * - Humanoid + - Manager + - 17,653 + - 12,505 + - -29.16% + * - Reach-Franka + - Manager + - 11,458 + - 7,813 + - -31.83% + * - Anymal-B + - Manager + - 29,188 + - 21,781 + - -25.38% + * - Anymal-C + - Manager + - 30,938 + - 22,228 + - -28.15% + * - Anymal-D + - Manager + - 32,294 + - 23,977 + - -25.75% + * - Cassie + - Manager + - 17,320 + - 10,706 + - -38.19% + * - G1 + - Manager + - 34,487 + - 27,300 + - -20.84% + * - H1 + - Manager + - 22,202 + - 15,864 + - -28.55% + * - A1 + - Manager + - 15,257 + - 9,907 + - -35.07% + * - Go1 + - Manager + - 16,515 + - 11,869 + - -28.13% + * - Go2 + - Manager + - 15,221 + - 9,966 + - -34.52% + + +Which Workflows Benefit Most +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The savings come from eliminating Python / torch overhead in the env's step loop, so envs +gain in proportion to how much of their step time was previously dominated by per-kernel CPU +overhead. Reading the table above: + +- **Manager-based classic RL** (Cartpole, Ant) — biggest gains (-52% to -54%). Many small + reward / observation terms with low compute per term, so per-launch CPU overhead dominated + the stable baseline. +- **Manager-based locomotion** (Anymal, G1, H1, Cassie, Unitree) — consistent -25% to -38% + range. The MDP has more terms but the underlying physics step is heavier, so the relative + Python savings shrink. +- **Direct workflow** — gains scale with how much the env's step body was Python (Ant -51%, + Cartpole -18%, Allegro hand -10%). Direct envs that already wrote most of their work as + GPU kernels see modest gains; ones with substantial Python state machinery see large ones. +- **Compute-heavy / scene-write-heavy envs** (Allegro hand, large humanoids) — see smaller + relative gains because the warp-side savings are amortised over a heavier step. Components + that still go through torch (scene write, actuator models) currently bound the floor; this + is expected to improve as remaining components migrate to warp. + +If your env's step time is dominated by physics or scene I/O, expect modest gains. If it has +many small MDP terms or a lot of Python in the step loop, expect large ones. Use the +benchmarking workflow below to measure on your task before committing to a migration. + + +Limitations +~~~~~~~~~~~ + +The warp env path is experimental and has the following known constraints. These are +specific to warp envs; for Newton physics limitations see :doc:`limitations-and-known-bugs`. + +**Physics backend** + +- **Newton only.** PhysX is not supported under the warp env path. Asset and sensor + ``class_type`` fields resolve to ``isaaclab_physx.*`` classes that depend on + ``omni.physics.tensors`` (a Kit module the warp runtime does not initialise), and several + warp APIs (env-mask reset, CUDA graph capture) require the Newton articulation. Configure + the cfg with a Newton physics block (or ``presets=newton``). + +**MDP coverage** + +- Only the terms listed under :ref:`Available Warp MDP Terms ` are + implemented. Stable envs that depend on un-migrated terms cannot be run on the warp path + until those terms are ported. +- Some scene-side operations (asset write, actuator models, certain sensor types) still go + through torch. They participate in the step but are not yet captured into the graph; they + set the lower bound on observed step time. +- Sensors that depend on the Kit RTX renderer (camera-based observations) cannot be combined + with the warp env path — they need Kit, which the warp runtime does not initialise. + +**API differences vs stable** + +- Reset events use a boolean ``env_mask`` (``wp.array(dtype=wp.bool)``) instead of an + ``env_ids`` list. This is required for capture safety: variable-length indexing changes + graph topology and breaks replay. +- All buffers must be pre-allocated in ``__init__``. There is no dynamic allocation inside + the captured step loop, so observation / reward / termination output dimensions must be + known at env init. +- Term functions write into a pre-allocated ``out`` buffer rather than returning a tensor. + See :doc:`warp-env-migration` for the kernel + launch pattern. +- Code inside the captured step loop must follow capture-safety rules (no + ``wp.to_torch``, no torch arithmetic, no lazy-evaluated properties, no Python branching + on GPU data). See the *Capture Safety* section in :doc:`warp-env-migration` for the + full set of rules. + + +Benchmarking Your Environment +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The performance table above was produced with ``scripts/benchmarks/benchmark_rsl_rl.py``, +which runs a fixed iteration count and reports step-time statistics. Use the same script +to estimate the gain for your own task before committing to a migration. + +**Single-task A/B** + +.. code-block:: bash + + # Stable variant + ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + --task -v0 \ + --num_envs 4096 \ + --max_iterations 500 \ + --headless \ + --benchmark_backend summary \ + --output_path benchmarks/stable + + # Warp variant — same task with -Warp- suffix + ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + --task -Warp-v0 \ + --num_envs 4096 \ + --max_iterations 500 \ + --headless \ + --benchmark_backend summary \ + --output_path benchmarks/warp + +The ``summary`` backend prints step time (mean / p50 / p99) and total throughput. Compare +"step time" between the two runs to estimate the gain per env step. + +**Sweep across all available tasks** + +``scripts/benchmarks/run_training_benchmarks.sh`` runs the full set of stable tasks listed +in the script (cartpole, ant, humanoid, locomotion, manipulation). Pair it with a +warp-tasks variant (substitute the ``-Warp-`` suffixed task ids) and diff the two outputs. + +**What to look at in the output** + +- *Step time (mean / p99)*: the headline number — what each env step costs. +- *Iteration time*: includes policy update; useful for end-to-end training throughput. +- *Capture overhead*: for warp runs, the first few iterations include CUDA graph capture + cost; exclude those when comparing steady-state numbers. + +**Estimating before you migrate** + +If you can't run the warp variant yet (e.g. the task isn't ported), measure the stable +step time and look at where it's spent: + +- ``num_envs * step_time`` dominated by physics → expect modest warp gains. +- ``step_time`` dominated by ``manager.compute_*`` calls → expect large gains, since those + are exactly what the warp managers replace with captured kernel launches. + +Use ``--num_frames`` on ``benchmark_non_rl.py`` for a no-policy step-time microbenchmark +when you want to isolate env overhead from policy compute. + + +Migrating Existing Environments +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For step-by-step instructions on porting an existing stable env (or writing a new warp +env from scratch) — covering project layout, the kernel + launch pattern shared by +observations / rewards / events / terminations / actions, capture-safety rules, and +parity testing — see :doc:`warp-env-migration` below. + + +.. toctree:: + :maxdepth: 2 + :hidden: + + warp-env-migration From 6fbce9e62747ba461e3f326c37383ae0fbab08b7 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Mon, 18 May 2026 08:55:05 -0700 Subject: [PATCH 101/133] Update DexSuite camera mini-batches (#5668) ## Summary Since Heterogeneous support #5024 for newton, the base camera training for dexsuite became more difficult to get decent success rate within 2000 iterations, increasing minibatch size from 2 to 8 helps recover the sample efficiency ``` sha | description | SR | verdict ------------------+--------------------------------+--------+-------- 6621d49b | parent commit | 0.7122 | PASS 965136dc | PR #5024 default | 0.0051 | FAIL 965136dc_minib8 | PR #5024 + num_mini_batches=8 | 0.6911 | PASS ``` - Updated DexSuite Kuka-Allegro single-camera and duo-camera RSL-RL PPO examples to use 8 mini-batches per update. - Added an isaaclab_tasks changelog fragment for the example config change. --- .../changelog.d/zhengyuz-dexsuite-camera-minibatches.rst | 4 ++++ .../dexsuite/config/kuka_allegro/agents/rsl_rl_ppo_cfg.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/zhengyuz-dexsuite-camera-minibatches.rst diff --git a/source/isaaclab_tasks/changelog.d/zhengyuz-dexsuite-camera-minibatches.rst b/source/isaaclab_tasks/changelog.d/zhengyuz-dexsuite-camera-minibatches.rst new file mode 100644 index 000000000000..b5a86a062ef3 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/zhengyuz-dexsuite-camera-minibatches.rst @@ -0,0 +1,4 @@ +Changed +^^^^^^^ + +* Changed DexSuite Kuka-Allegro camera RSL-RL PPO examples to use 8 mini-batches per update. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/agents/rsl_rl_ppo_cfg.py index b1b5196859cf..dfc61d677d58 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/agents/rsl_rl_ppo_cfg.py @@ -86,7 +86,7 @@ class DexsuiteKukaAllegroPPORunnerCfg(PresetCfg): obs_groups={"actor": ["policy", "proprio", "base_image"], "critic": ["policy", "proprio", "perception"]}, actor=CNN_POLICY_CFG, critic=STATE_CRITIC_CFG, - algorithm=ALGO_CFG.replace(num_mini_batches=2), + algorithm=ALGO_CFG.replace(num_mini_batches=8), ) duo_camera = DexsuiteKukaAllegroPPOBaseRunnerCfg().replace( @@ -97,5 +97,5 @@ class DexsuiteKukaAllegroPPORunnerCfg(PresetCfg): }, actor=CNN_POLICY_CFG, critic=STATE_CRITIC_CFG, - algorithm=ALGO_CFG.replace(num_mini_batches=2), + algorithm=ALGO_CFG.replace(num_mini_batches=8), ) From c348f017b445fdd485950354d4d0984ce3a0cb49 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 18 May 2026 14:53:01 -0700 Subject: [PATCH 102/133] Update locomanipulation SDG docs with clarification on converting to lerobot dataset (#5648) # Description - Documents that `convert_dataset.py`'s `` must contain only SDG `.hdf5` outputs from `generate_data.py`; mixing in earlier-step datasets (e.g. `dataset_annotated_g1_locomanip.hdf5`, `generated_dataset_g1_locomanip.hdf5`) causes the converter to fail. - Updates the example to `mkdir`+`mv` the SDG output into `./datasets/locomanip_sdg/` before running the converter. ## 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 Co-authored-by: Kelly Guo --- .../imitation-learning/humanoids_imitation.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/source/overview/imitation-learning/humanoids_imitation.rst b/docs/source/overview/imitation-learning/humanoids_imitation.rst index d593fdfcab70..2ea9c6542b7e 100644 --- a/docs/source/overview/imitation-learning/humanoids_imitation.rst +++ b/docs/source/overview/imitation-learning/humanoids_imitation.rst @@ -608,7 +608,7 @@ Finetune GR00T N1.5 policy for locomanipulation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ **Prerequisites:** Generate the locomanipulation dataset using the command in the previous section (e.g. ``generated_dataset_g1_locomanipulation_sdg.hdf5``). -You may place one or more such HDF5 files in a single directory for the conversion step. +The conversion step accepts a directory of SDG HDF5 files, so you may group multiple ``generate_data.py`` outputs together — but the directory must contain **only** SDG outputs, not other HDF5 files from earlier steps (e.g. ``dataset_annotated_g1_locomanip.hdf5`` or ``generated_dataset_g1_locomanip.hdf5``). Install GR00T with Isaac Lab (uv) """"""""""""""""""""""""""""""""" @@ -655,17 +655,19 @@ Then, from the **Isaac-GR00T** directory, install GR00T N1.5 and its dependencie Convert dataset to LeRobot format """"""""""""""""""""""""""""""""" -GR00T N1.5 expects data in LeRobot format. From the **IsaacLab** repository root, run the conversion script. ```` is a directory containing one or more ``.hdf5`` files (e.g. the output directory where you saved files from ``generate_data.py``). ```` is the LeRobot-format output directory (e.g. ``./datasets/datasets_train_200_lerobot``). Episodes with very low object displacement are skipped. +GR00T N1.5 expects data in LeRobot format. From the **IsaacLab** repository root, run the conversion script. ```` is a directory containing one or more SDG ``.hdf5`` files produced by ``generate_data.py`` (and **no other HDF5 files**). ```` is the LeRobot-format output directory (e.g. ``./datasets/datasets_train_200_lerobot``). Episodes with very low object displacement are skipped. .. code:: bash ./isaaclab.sh -p scripts/imitation_learning/locomanipulation_sdg/gr00t/convert_dataset.py -Example: +Example — move the SDG output into its own directory first so the converter only sees SDG files: .. code:: bash - ./isaaclab.sh -p scripts/imitation_learning/locomanipulation_sdg/gr00t/convert_dataset.py ./datasets ./datasets/datasets_train_200_lerobot + mkdir -p ./datasets/locomanip_sdg + mv ./datasets/generated_dataset_g1_locomanipulation_sdg.hdf5 ./datasets/locomanip_sdg/ + ./isaaclab.sh -p scripts/imitation_learning/locomanipulation_sdg/gr00t/convert_dataset.py ./datasets/locomanip_sdg ./datasets/datasets_train_200_lerobot Finetune the policy """"""""""""""""""" From c97bedda31a09918c42f65ad401caea47dae654f Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Tue, 19 May 2026 09:50:05 +0800 Subject: [PATCH 103/133] Pin OVRTX to 0.3 (#5568) # Description Pin OVRTX to 0.3. ## 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 --- .../warp-environments.rst | 2 +- .../huidongc-pin-ovrtx-0-3.minor.rst | 6 ++++++ source/isaaclab_ov/pyproject.toml | 3 +++ source/isaaclab_ov/setup.py | 18 +++++++++++++----- .../huidongc-ovrtx-0-3-golden-images.skip | 0 .../cartpole/newton-ovrtx_renderer-rgb.png | 4 ++-- .../cartpole/newton-ovrtx_renderer-rgba.png | 4 ++-- ...enderer-simple_shading_constant_diffuse.png | 2 +- ...rtx_renderer-simple_shading_diffuse_mdl.png | 4 ++-- ...-ovrtx_renderer-simple_shading_full_mdl.png | 4 ++-- .../newton-ovrtx_renderer-albedo.png | 4 ++-- .../newton-ovrtx_renderer-rgb.png | 4 ++-- .../newton-ovrtx_renderer-rgba.png | 4 ++-- ...enderer-simple_shading_constant_diffuse.png | 4 ++-- ...rtx_renderer-simple_shading_diffuse_mdl.png | 4 ++-- ...-ovrtx_renderer-simple_shading_full_mdl.png | 4 ++-- .../newton-ovrtx_renderer-albedo.png | 4 ++-- .../shadow_hand/newton-ovrtx_renderer-rgb.png | 4 ++-- .../shadow_hand/newton-ovrtx_renderer-rgba.png | 4 ++-- ...enderer-simple_shading_constant_diffuse.png | 4 ++-- ...rtx_renderer-simple_shading_diffuse_mdl.png | 4 ++-- ...-ovrtx_renderer-simple_shading_full_mdl.png | 4 ++-- 22 files changed, 56 insertions(+), 39 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/huidongc-pin-ovrtx-0-3.minor.rst create mode 100644 source/isaaclab_ov/pyproject.toml create mode 100644 source/isaaclab_tasks/changelog.d/huidongc-ovrtx-0-3-golden-images.skip diff --git a/docs/source/experimental-features/newton-physics-integration/warp-environments.rst b/docs/source/experimental-features/newton-physics-integration/warp-environments.rst index c1107741239b..6af7d934dafe 100644 --- a/docs/source/experimental-features/newton-physics-integration/warp-environments.rst +++ b/docs/source/experimental-features/newton-physics-integration/warp-environments.rst @@ -228,7 +228,7 @@ specific to warp envs; for Newton physics limitations see :doc:`limitations-and- ``class_type`` fields resolve to ``isaaclab_physx.*`` classes that depend on ``omni.physics.tensors`` (a Kit module the warp runtime does not initialise), and several warp APIs (env-mask reset, CUDA graph capture) require the Newton articulation. Configure - the cfg with a Newton physics block (or ``presets=newton``). + the cfg with a Newton physics block (or ``presets=newton_mjwarp``). **MDP coverage** diff --git a/source/isaaclab_ov/changelog.d/huidongc-pin-ovrtx-0-3.minor.rst b/source/isaaclab_ov/changelog.d/huidongc-pin-ovrtx-0-3.minor.rst new file mode 100644 index 000000000000..9c0aef6c2751 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/huidongc-pin-ovrtx-0-3.minor.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Updated the ``[ovrtx]`` optional extra for :mod:`isaaclab_ov` to ``ovrtx>=0.3.0,<0.4.0`` + (previously ``>=0.2.0,<0.3.0``). The renderer remains compatible with ``ovrtx`` 0.2.x when + that version is installed separately; the extra now tracks the supported 0.3.x line by default. diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml new file mode 100644 index 000000000000..31dce8d230ec --- /dev/null +++ b/source/isaaclab_ov/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools<82.0.0", "wheel", "toml"] +build-backend = "setuptools.build_meta" diff --git a/source/isaaclab_ov/setup.py b/source/isaaclab_ov/setup.py index c955c54bfa15..038eab3324a4 100644 --- a/source/isaaclab_ov/setup.py +++ b/source/isaaclab_ov/setup.py @@ -5,11 +5,19 @@ """Installation script for the 'isaaclab_ov' python package.""" +import os + +import toml from setuptools import setup +# Obtain the extension data from the extension.toml file +EXTENSION_PATH = os.path.dirname(os.path.realpath(__file__)) +# Read the extension.toml file +EXTENSION_TOML_DATA = toml.load(os.path.join(EXTENSION_PATH, "config", "extension.toml")) + EXTRAS_REQUIRE = { "ovrtx": [ - "ovrtx>=0.2.0,<0.3.0", + "ovrtx>=0.3.0,<0.4.0", ], } @@ -18,12 +26,12 @@ setup( name="isaaclab_ov", - version="0.1.1", author="Isaac Lab Project Developers", maintainer="Isaac Lab Project Developers", - url="https://github.com/isaac-sim/IsaacLab", - description="Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for IsaacLab.", - keywords=["robotics", "simulation", "rendering", "ovrtx", "omniverse"], + url=EXTENSION_TOML_DATA["package"]["repository"], + version=EXTENSION_TOML_DATA["package"]["version"], + description=EXTENSION_TOML_DATA["package"]["description"], + keywords=EXTENSION_TOML_DATA["package"]["keywords"], license="BSD-3-Clause", include_package_data=True, python_requires=">=3.12", diff --git a/source/isaaclab_tasks/changelog.d/huidongc-ovrtx-0-3-golden-images.skip b/source/isaaclab_tasks/changelog.d/huidongc-ovrtx-0-3-golden-images.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png index f35e82ae6582..e47c06e2ca7c 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d0e2d1f537f42cb34ed7a0616802193e3ae0bcef43fbc0dc0b015d8af8aa5c8 -size 2685 +oid sha256:4029eb71d2361c9fa12d255415bb9edcf1caaeb9d230ca2e6c4e67596c037dd1 +size 2580 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png index 5a53a6f517a6..791497af827c 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d985f4de8667d57b0ba2f44b8181541463c888becb0f38c8716c65c343658dfb -size 2999 +oid sha256:4e1fed2c618875f9f9b4520c52308b0831cf637835e7a62e7a84e96f914c1e83 +size 2882 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png index 583746565afb..87104cb87161 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:11db8a198a6ccae0a7cdbce0e996eb74eab1a13dca65bf0e590752a95389a3dd +oid sha256:47b5b15d79d0b61d00c0538caa0012172753a481ad6efb45df2888402be2f407 size 391 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png index b33b4e8fd830..7d05e4a7adbd 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bfce56fc89bb014ecc876c4302344085fd2ad1cd6685d2295141256c375b1fc3 -size 436 +oid sha256:f2ba382c0804ea49b55fc5216c9f1e28c34d5cae95b33d9982fd55763df178cc +size 435 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png index 27d490c4b23f..6b4f8389da06 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2b846bae771345dc6b5bea0c6148d3942a55cb012b079589ed7104a8357c9e2 -size 776 +oid sha256:d7af4ef2afca01d0bf4f9c069c5c3778fa07050bcd8091486541ab11f14e8227 +size 742 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png index 8e51c396efae..5199099a7587 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04c9b2668a6e544403f00850a1d12f2cc5d661c4b2038a786607880ebc768cb9 -size 2768 +oid sha256:7cf76622f5f5cc7e7889fe6032ba4cda22516248fa7bb5e957f181c92c86b42b +size 3054 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png index 0395c5f3d11c..544e2ffd450b 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4add42d2a43cad3e1bbc17d9f3e190fb6f480c4ace605de42dfdbccf5e02680d -size 14894 +oid sha256:3445142682c88dc5ce11c5d749c7754bf2d54bf0f1aab6420513a733bf3cf645 +size 14919 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png index ca7d07531895..c3b229d34871 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:71375f209fb2cd8c2e0b7ea463f45ab662bb21b44aec8dcb74994e5969b6aecc -size 17747 +oid sha256:179a5acba0a763fcc2317cd784f8c347ef48f0d81f8028a1a64996ade67f8706 +size 17836 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png index 7fefafde048e..46ce5933fb8b 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dde0d7363cc8550dfa985178d26bf72b6a7f84157ab8503aa36889e652f7e061 -size 1509 +oid sha256:8e1d94f0c6ae2e40a1b0ff9cf27a0f2f9b756ebcebd9ddf27b0da31c89e3a57f +size 1485 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png index b5d197da550d..2e2f6cb257a7 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7608f7f5846d6c78f9c0cf9d19b1eaaed0f79715de26243b8ae20f24ae063617 -size 1465 +oid sha256:83ca3d8f55f971d473409c73e77582175906670f3962b56723cccd28d062a868 +size 3513 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png index 90a2440d093b..16b6b73ec7f4 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1563003686040d979ddfe51acdf803f8a46bb268ca569a3fcd757f6e02befcbf -size 3810 +oid sha256:3afc4f214e51a1ccc1b0341448f64b82773bcc4bba3d913ad4f3052dcf497032 +size 4513 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png index 0a6d3e09769e..b0a81304d1b4 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4ab0a216128aef68cfc0ebdb94c90f35c74df7283d46e331632c4f5dcc3cb586 -size 1900 +oid sha256:d6ea478eb0b63ac6e9b19c66fabc9944a9cdd1d3131aa0d480ba645151765f41 +size 2150 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png index e8d16133a54b..d6e87b16a116 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b827b0e3fc8f009db74351a8535b4c3b2fa0be6274cbd192144dc39d2b40126f -size 20205 +oid sha256:87b927816b29714d92113b2fcab01569c60372f017119b37ced9a12f72b01cd7 +size 19717 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png index 97cf4a8487f5..ddffaebf0722 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:901061fc36ba049999e52d742a952340ffed23105e4861f6354d4eb63523d42c -size 22380 +oid sha256:7fb6696c895cb07a86897002e434be6c8c67a9d50f15615c2fd16f5038eee209 +size 21761 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png index 39a18ee2dc1e..a25340e96b0f 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:993f13cd9fe6970af68e98f72a66d221c4bd1325256f3dd7a6ea602dbcffbceb -size 7097 +oid sha256:3122340f40be0b24e9d7f2d262bc7285607536c9cf82151750d2691f05d8950d +size 6840 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png index 5b972abb61c5..572a1759a30c 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2421be77a60117829b0043448599569e16f8c1f3dcf8ecab5c50125c6838a18 -size 7468 +oid sha256:3f0ececab1b4b385c54352d02c9b07d6572f8a4f4069eea1afe2089343a164d6 +size 7429 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png index a5a024b8a9dc..687917f13e3b 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7448f0ffd54581488a9fd99a24a0f8304bc1c623122db10f07233094bb2264ff -size 9241 +oid sha256:88ca19937bbf54a87c40f476c395f678a39d443df3899eb3c74b4e3e854866fc +size 9192 From 51abd7f5bac8947921ff36c21fd0363c39fc6d29 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Mon, 18 May 2026 22:46:36 -0700 Subject: [PATCH 104/133] Moves h5py import to be lazy to avoid DLL issues in windows (#5682) # Description Observation: Facing error ImportError: DLL load failed while importing defs: The specified procedure could not be found. while inferencing for task Isaac-Velocity-Rough-v0 Training for the same task seems to go fine Command: isaaclab.bat -p -u code_coverage.py scripts/reinforcement_learning/skrl/play.py --task Isaac-Velocity-Rough-Anymal-C-Direct-v0 --visualizer kit Error Logs: ``` File "c:/automation/isaac_lab/source/isaaclab/isaaclab/utils/datasets/hdf5_dataset_file_handler.py", line 12, in import h5py File "C:\automation\uv\sim_6.0.0rc47_lab_0609d56_uv\Lib\site-packages\h5py\__init__.py", line 33, in from . import version File "C:\automation\uv\sim_6.0.0rc47_lab_0609d56_uv\Lib\site-packages\h5py\version.py", line 15, in from . import h5 as _h5 File "h5py/h5.pyx", line 1, in init h5py.h5 ImportError: DLL load failed while importing defs: The specified procedure could not be found. 2026-05-18T09:52:22Z [479,504ms] [Warning] [omni.usd] Unexpected reference count of 2 for UsdStage 'anon:000001ECC7B47500:World0.usd' while being closed in UsdContext (this may indicate it is still resident in memory). [479.997s] Simulation App Shutting Down ``` ## 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 - [ ] 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 --- source/isaaclab/changelog.d/fix-h5py-eager-import.rst | 9 +++++++++ .../isaaclab/utils/datasets/hdf5_dataset_file_handler.py | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/fix-h5py-eager-import.rst diff --git a/source/isaaclab/changelog.d/fix-h5py-eager-import.rst b/source/isaaclab/changelog.d/fix-h5py-eager-import.rst new file mode 100644 index 000000000000..a2ef2b6aa53b --- /dev/null +++ b/source/isaaclab/changelog.d/fix-h5py-eager-import.rst @@ -0,0 +1,9 @@ +Fixed +^^^^^ + +* Fixed a Windows fatal exception (``0xc0000139``) that crashed the process on startup when + running any :class:`~isaaclab.envs.ManagerBasedRLEnv`-based script (e.g. ``skrl/play.py``) + on machines where ``h5py``'s native DLL could not be loaded. The ``import h5py`` in + :class:`~isaaclab.utils.datasets.HDF5DatasetFileHandler` was a top-level statement that + executed unconditionally at import time. It is now deferred to the individual methods that + open or create HDF5 files, so the DLL is only loaded when dataset recording is actually used. diff --git a/source/isaaclab/isaaclab/utils/datasets/hdf5_dataset_file_handler.py b/source/isaaclab/isaaclab/utils/datasets/hdf5_dataset_file_handler.py index 1977527b2cdf..2c67870947e9 100644 --- a/source/isaaclab/isaaclab/utils/datasets/hdf5_dataset_file_handler.py +++ b/source/isaaclab/isaaclab/utils/datasets/hdf5_dataset_file_handler.py @@ -9,7 +9,6 @@ import os from collections.abc import Iterable -import h5py import numpy as np import torch @@ -54,6 +53,8 @@ def __init__(self): def open(self, file_path: str, mode: str = "r"): """Open an existing dataset file.""" + import h5py + if self._hdf5_file_stream is not None: raise RuntimeError("HDF5 dataset file stream is already in use") self._hdf5_file_stream = h5py.File(file_path, mode) @@ -62,6 +63,8 @@ def open(self, file_path: str, mode: str = "r"): def create(self, file_path: str, env_name: str = None): """Create a new dataset file.""" + import h5py + if self._hdf5_file_stream is not None: raise RuntimeError("HDF5 dataset file stream is already in use") if not file_path.endswith(".hdf5"): @@ -163,6 +166,8 @@ def load_episode( Returns: The loaded episode data, or None if the episode doesn't exist. """ + import h5py + self._raise_if_not_initialized() if episode_name not in self._hdf5_data_group: return None @@ -297,6 +302,8 @@ def convert_dataset_to_xyzw(input_path: str, output_path: str | None = None) -> FileNotFoundError: If the input file does not exist. ValueError: If the dataset is already in XYZW format. """ + import h5py + if not os.path.exists(input_path): raise FileNotFoundError(f"Input dataset file not found: {input_path}") From a03c503a506ba817f1fc7c38f2a4d94408971f91 Mon Sep 17 00:00:00 2001 From: shauryadNv Date: Mon, 18 May 2026 23:15:27 -0700 Subject: [PATCH 105/133] Updates Flexiv reach policy with ROS inference (#5683) # Description Updates Rizon4sReachROSInferenceEnvCfg so the Flexiv Rizon 4s ROS / Isaac Manipulator inference setup matches the robot mount/setup in the Hubble Lab for the release candidate. ## Type of change - 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` - [ ] 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 - [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: Kelly Guo --- .../flexiv-reach-ros-mount.minor.rst | 5 ++ .../config/rizon_4s/ros_inference_env_cfg.py | 54 ------------------- 2 files changed, 5 insertions(+), 54 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/flexiv-reach-ros-mount.minor.rst diff --git a/source/isaaclab_tasks/changelog.d/flexiv-reach-ros-mount.minor.rst b/source/isaaclab_tasks/changelog.d/flexiv-reach-ros-mount.minor.rst new file mode 100644 index 000000000000..c22a7f1ae0fb --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/flexiv-reach-ros-mount.minor.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed the robot setup and mount configuration for the Flexiv reach policy + training environment with ROS inference. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/ros_inference_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/ros_inference_env_cfg.py index 25e37d44a2f7..96b17a7c7e4d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/ros_inference_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/rizon_4s/ros_inference_env_cfg.py @@ -3,7 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -import math from isaaclab.utils.configclass import configclass @@ -21,59 +20,6 @@ def __post_init__(self): # post init of parent super().__post_init__() - # --- NVIDIA Hubble Lab: Flexiv Rizon 4s mount and workspace --- - # Remove vertical mount stand since Hubble deployment does not use the sim stand asset - self.scene.table = None - - # Lab home joint pose (radians); aligns sim defaults / reset with the physical stand - self.scene.robot.init_state.joint_pos = { - "joint1": math.radians(-90.0), - "joint2": math.radians(90.0), - "joint3": 0.0, - "joint4": math.radians(90.0), - "joint5": 0.0, - "joint6": 0.0, - "joint7": 0.0, - } - - # Orientation of robot is based on the Flexiv Rizon 4s mount in the Hubble Lab - self.scene.robot.init_state.pos = (0.0, 0.0, 0.0) - self.scene.robot.init_state.rot = (0.5, 0.5, 0.5, 0.5) - - # end-effector is along z-direction for Rizon 4s - # target_pos_centre and target_rot_centre are approximately the end effector pose when - # the robot is in the self.scene.robot.init_state.joint_pos pose - self.target_pos_centre = (0.0, 0.3, 0.9) - self.target_pos_range = (0.4, 0.4, 0.35) - self.commands.ee_pose.body_name = "flange" - self.commands.ee_pose.ranges.pos_x = ( - self.target_pos_centre[0] - self.target_pos_range[0], - self.target_pos_centre[0] + self.target_pos_range[0], - ) - self.commands.ee_pose.ranges.pos_y = ( - self.target_pos_centre[1] - self.target_pos_range[1], - self.target_pos_centre[1] + self.target_pos_range[1], - ) - self.commands.ee_pose.ranges.pos_z = ( - self.target_pos_centre[2] - self.target_pos_range[2], - self.target_pos_centre[2] + self.target_pos_range[2], - ) - - self.target_rot_centre = (math.pi / 2, math.pi / 2, 0.0) # end-effector facing down - self.target_rot_range = (math.pi / 2, math.pi / 2, math.pi) - self.commands.ee_pose.ranges.roll = ( - self.target_rot_centre[0] - self.target_rot_range[0], - self.target_rot_centre[0] + self.target_rot_range[0], - ) - self.commands.ee_pose.ranges.pitch = ( - self.target_rot_centre[1] - self.target_rot_range[1], - self.target_rot_centre[1] + self.target_rot_range[1], - ) - self.commands.ee_pose.ranges.yaw = ( - self.target_rot_centre[2] - self.target_rot_range[2], - self.target_rot_centre[2] + self.target_rot_range[2], - ) - # Variables used by Isaac Manipulator for on robot inference # TODO: @ashwinvk: Remove these from env cfg once the generic inference node has been implemented self.obs_order = ["arm_dof_pos", "arm_dof_vel", "target_pos", "target_quat"] From cb199d54b5f02bb80ca6258b73ae75d7af67448a Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 06:26:20 +0000 Subject: [PATCH 106/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 5.5.0 → 5.5.1 - isaaclab_ov: 0.2.1 → 0.3.0 - isaaclab_ovphysx: 2.0.0 → 2.1.0 - isaaclab_tasks: 1.8.0 → 1.9.0 --- .../changelog.d/fix-h5py-eager-import.rst | 9 -------- .../isaaclab/changelog.d/mh-warp_cam_2.skip | 0 source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 14 +++++++++++++ .../huidongc-pin-ovrtx-0-3.minor.rst | 6 ------ source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 11 ++++++++++ ...-feat-ovphysx-scene-data-backend.minor.rst | 16 -------------- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 21 +++++++++++++++++++ .../flexiv-reach-ros-mount.minor.rst | 5 ----- .../huidongc-ovrtx-0-3-golden-images.skip | 0 .../zhengyuz-dexsuite-camera-minibatches.rst | 4 ---- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 11 ++++++++++ 15 files changed, 61 insertions(+), 44 deletions(-) delete mode 100644 source/isaaclab/changelog.d/fix-h5py-eager-import.rst delete mode 100644 source/isaaclab/changelog.d/mh-warp_cam_2.skip delete mode 100644 source/isaaclab_ov/changelog.d/huidongc-pin-ovrtx-0-3.minor.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-scene-data-backend.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/flexiv-reach-ros-mount.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/huidongc-ovrtx-0-3-golden-images.skip delete mode 100644 source/isaaclab_tasks/changelog.d/zhengyuz-dexsuite-camera-minibatches.rst diff --git a/source/isaaclab/changelog.d/fix-h5py-eager-import.rst b/source/isaaclab/changelog.d/fix-h5py-eager-import.rst deleted file mode 100644 index a2ef2b6aa53b..000000000000 --- a/source/isaaclab/changelog.d/fix-h5py-eager-import.rst +++ /dev/null @@ -1,9 +0,0 @@ -Fixed -^^^^^ - -* Fixed a Windows fatal exception (``0xc0000139``) that crashed the process on startup when - running any :class:`~isaaclab.envs.ManagerBasedRLEnv`-based script (e.g. ``skrl/play.py``) - on machines where ``h5py``'s native DLL could not be loaded. The ``import h5py`` in - :class:`~isaaclab.utils.datasets.HDF5DatasetFileHandler` was a top-level statement that - executed unconditionally at import time. It is now deferred to the individual methods that - open or create HDF5 files, so the DLL is only loaded when dataset recording is actually used. diff --git a/source/isaaclab/changelog.d/mh-warp_cam_2.skip b/source/isaaclab/changelog.d/mh-warp_cam_2.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 922af835cb71..eb347d2de601 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 = "5.5.0" +version = "5.5.1" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 400b5dfadfe3..93d4c67b9497 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,20 @@ Changelog --------- +5.5.1 (2026-05-19) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed a Windows fatal exception (``0xc0000139``) that crashed the process on startup when + running any :class:`~isaaclab.envs.ManagerBasedRLEnv`-based script (e.g. ``skrl/play.py``) + on machines where ``h5py``'s native DLL could not be loaded. The ``import h5py`` in + :class:`~isaaclab.utils.datasets.HDF5DatasetFileHandler` was a top-level statement that + executed unconditionally at import time. It is now deferred to the individual methods that + open or create HDF5 files, so the DLL is only loaded when dataset recording is actually used. + + 5.5.0 (2026-05-18) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/changelog.d/huidongc-pin-ovrtx-0-3.minor.rst b/source/isaaclab_ov/changelog.d/huidongc-pin-ovrtx-0-3.minor.rst deleted file mode 100644 index 9c0aef6c2751..000000000000 --- a/source/isaaclab_ov/changelog.d/huidongc-pin-ovrtx-0-3.minor.rst +++ /dev/null @@ -1,6 +0,0 @@ -Changed -^^^^^^^ - -* Updated the ``[ovrtx]`` optional extra for :mod:`isaaclab_ov` to ``ovrtx>=0.3.0,<0.4.0`` - (previously ``>=0.2.0,<0.3.0``). The renderer remains compatible with ``ovrtx`` 0.2.x when - that version is installed separately; the extra now tracks the supported 0.3.x line by default. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 7eb14c8bb34d..8ae9b76654ed 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.2.1" +version = "0.3.0" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index 6aa027b6eeec..e1caaeda8ba8 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog --------- +0.3.0 (2026-05-19) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Updated the ``[ovrtx]`` optional extra for :mod:`isaaclab_ov` to ``ovrtx>=0.3.0,<0.4.0`` + (previously ``>=0.2.0,<0.3.0``). The renderer remains compatible with ``ovrtx`` 0.2.x when + that version is installed separately; the extra now tracks the supported 0.3.x line by default. + + 0.2.1 (2026-05-17) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-scene-data-backend.minor.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-scene-data-backend.minor.rst deleted file mode 100644 index 9612b5dcac1b..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-scene-data-backend.minor.rst +++ /dev/null @@ -1,16 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_ovphysx.physics.OvPhysxSceneDataBackend` and - :meth:`~isaaclab_ovphysx.physics.OvPhysxManager.get_scene_data_backend` - so the central - :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` - (introduced in #5128) can expose OVPhysX rigid-body transforms to - Rerun, Viser, and the native Newton viewport. The backend creates one - ovphysx ``TT.RIGID_BODY_POSE`` binding per distinct env-wildcard - rigid-body prim path (cartpole produces 2 bindings, Allegro hand ~17, - each covering all envs), reads each binding into a pre-allocated - ``wp.float32`` staging buffer via ``TensorBinding.read(dst)``, and - concatenates the per-binding reads into a single ``wp.transformf`` - merged buffer that the central provider consumes as - :class:`~isaaclab.physics.scene_data_backend.SceneDataFormat.Transform`. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 87311dcc04c1..8b0ccad725d6 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 = "2.0.0" +version = "2.1.0" # 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 19d5499b3823..2081986bd4d9 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,27 @@ Changelog --------- +2.1.0 (2026-05-19) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.physics.OvPhysxSceneDataBackend` and + :meth:`~isaaclab_ovphysx.physics.OvPhysxManager.get_scene_data_backend` + so the central + :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` + (introduced in #5128) can expose OVPhysX rigid-body transforms to + Rerun, Viser, and the native Newton viewport. The backend creates one + ovphysx ``TT.RIGID_BODY_POSE`` binding per distinct env-wildcard + rigid-body prim path (cartpole produces 2 bindings, Allegro hand ~17, + each covering all envs), reads each binding into a pre-allocated + ``wp.float32`` staging buffer via ``TensorBinding.read(dst)``, and + concatenates the per-binding reads into a single ``wp.transformf`` + merged buffer that the central provider consumes as + :class:`~isaaclab.physics.scene_data_backend.SceneDataFormat.Transform`. + + 2.0.0 (2026-05-17) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/changelog.d/flexiv-reach-ros-mount.minor.rst b/source/isaaclab_tasks/changelog.d/flexiv-reach-ros-mount.minor.rst deleted file mode 100644 index c22a7f1ae0fb..000000000000 --- a/source/isaaclab_tasks/changelog.d/flexiv-reach-ros-mount.minor.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Changed the robot setup and mount configuration for the Flexiv reach policy - training environment with ROS inference. diff --git a/source/isaaclab_tasks/changelog.d/huidongc-ovrtx-0-3-golden-images.skip b/source/isaaclab_tasks/changelog.d/huidongc-ovrtx-0-3-golden-images.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_tasks/changelog.d/zhengyuz-dexsuite-camera-minibatches.rst b/source/isaaclab_tasks/changelog.d/zhengyuz-dexsuite-camera-minibatches.rst deleted file mode 100644 index b5a86a062ef3..000000000000 --- a/source/isaaclab_tasks/changelog.d/zhengyuz-dexsuite-camera-minibatches.rst +++ /dev/null @@ -1,4 +0,0 @@ -Changed -^^^^^^^ - -* Changed DexSuite Kuka-Allegro camera RSL-RL PPO examples to use 8 mini-batches per update. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 1bd9b6ff1309..5fb0c305cbd8 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.8.0" +version = "1.9.0" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index acda085f98e3..6dd978081a3d 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog --------- +1.9.0 (2026-05-19) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed DexSuite Kuka-Allegro camera RSL-RL PPO examples to use 8 mini-batches per update. +* Changed the robot setup and mount configuration for the Flexiv reach policy + training environment with ROS inference. + + 1.8.0 (2026-05-17) ~~~~~~~~~~~~~~~~~~ From bac793f00c06f4065bf457fdf61eb36ad8c19dfb Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Tue, 19 May 2026 14:24:20 +0200 Subject: [PATCH 107/133] [OVPHYSX] RigidObjectCollection asset (#5570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Implements `RigidObjectCollection` and `RigidObjectCollectionData` for the OVPhysX backend, completing the rigid-body asset surface alongside `RigidObject` (#5426) and `Articulation` (#5459). The collection manages N distinct rigid bodies per environment with `(env, body)` dual indexing. The asset creates **one native fused TensorBinding per tensor type** via the ovphysx 0.4.3 `create_tensor_binding(prim_paths=[glob_0, …, glob_{B-1}])` API, mirroring how PhysX's `RigidBodyView` aggregates multiple body prims into a single flat view. Each binding spans `num_instances * num_bodies` prims and returns body-major flat data `(body_0_env_0, body_0_env_1, …, body_1_env_0, …)`. The data class and asset writers use strided-view reshape helpers (`_reshape_view_to_data_2d/_3d` and `reshape_data_to_view_2d/_3d`, ported from the PhysX collection) to convert between body-major view layout and the instance-major `(N, B, D)` layout exposed to users — no Warp kernels added, no per-body Python fan-out at runtime. Fixes #5317 **Stacked on:** - #5459 — OVPhysX `Articulation` - #5426 — OVPhysX `RigidObject` **Carries a one-commit cherry-pick of #5545's `ovphysx_manager.py` portion** (required for ovphysx 0.4 `active_cuda_gpus` API). Drop the cherry-pick once #5545 lands. ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots N/A — backend infrastructure, no visible behaviour 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` - [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 (fragment file under `source/isaaclab_ovphysx/changelog.d/`) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there ## Test plan Tested in a Docker container built from `docker/Dockerfile.base` (IsaacSim `6.0.0-dev2`) with the ovphysx 0.4.3 wheel installed: - `./scripts/run_ovphysx.sh -m pytest source/isaaclab/test/assets/test_rigid_object_collection_iface.py -k ovphysx` → **636 passed** - `./scripts/run_ovphysx.sh -m pytest source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py` → **72 passed, 76 skipped (device-mode lock — a second invocation with `-k 'cpu'` covers CPU), 4 xfailed** (material-properties gap shared with `RigidObject` until the wheel exposes `RIGID_BODY_MATERIAL`) - `./isaaclab.sh -f` → clean --- ...er-feat-ovphysx_rigidobjectcollection.skip | 0 .../test_rigid_object_collection_iface.py | 114 +- ...at-ovphysx_rigidobjectcollection.major.rst | 13 + .../isaaclab_ovphysx/assets/__init__.pyi | 3 + .../isaaclab_ovphysx/assets/kernels.py | 25 + .../rigid_object_collection/__init__.py | 10 + .../rigid_object_collection/__init__.pyi | 12 + .../rigid_object_collection.py | 1525 +++++++++++++++++ .../rigid_object_collection_data.py | 1132 ++++++++++++ source/isaaclab_ovphysx/setup.py | 1 + .../assets/test_rigid_object_collection.py | 963 +++++++++++ 11 files changed, 3791 insertions(+), 7 deletions(-) create mode 100644 source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.skip create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.major.rst create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/__init__.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/__init__.pyi create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/rigid_object_collection.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/rigid_object_collection_data.py create mode 100644 source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py diff --git a/source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.skip b/source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py index 42c2e8d731b5..fb848baf34c8 100644 --- a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py @@ -14,16 +14,42 @@ The setup is a bit convoluted so that we can run these tests without requiring Isaac Sim or GPU simulation. """ -"""Launch Isaac Sim Simulator first.""" +"""Launch Isaac Sim Simulator first (when available).""" -from isaaclab.app import AppLauncher - -HEADLESS = True +import os +import sys +from unittest.mock import MagicMock -# launch omniverse app -simulation_app = AppLauncher(headless=True).app +# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher +# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets +# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless +# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is +# set (bare Python, no Kit at all). +_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( + os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ +) -from unittest.mock import MagicMock +if not _kitless: + from isaaclab.app import AppLauncher + + simulation_app = AppLauncher(headless=True).app +else: + simulation_app = None + # Stub out the Kit/Omniverse modules that are not present under + # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). + # ``omni`` is a real namespace package, so missing submodules also need + # to be installed as attributes on it -- ``sys.modules`` alone is not + # enough because attribute access on the real ``omni`` won't fall + # through to ``sys.modules``. + import omni as _omni + + for _mod in ("physics", "physics.tensors", "physx", "timeline", "usd"): + _stub = MagicMock() + sys.modules[f"omni.{_mod}"] = _stub + # Bind the leaf attribute so that ``omni.`` resolves. + setattr(_omni, _mod.split(".", 1)[0], _stub) + for _mod in ("isaacsim.core", "isaacsim.core.simulation_manager"): + sys.modules.setdefault(_mod, MagicMock()) import numpy as np import pytest @@ -75,6 +101,23 @@ except ImportError: pass +try: + from isaaclab_ovphysx.assets.rigid_object_collection.rigid_object_collection import ( + RigidObjectCollection as OvPhysxRigidObjectCollection, + ) + from isaaclab_ovphysx.assets.rigid_object_collection.rigid_object_collection_data import ( + RigidObjectCollectionData as OvPhysxRigidObjectCollectionData, + ) + from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet + + # Guard against stub implementations (not yet functional). + if not hasattr(OvPhysxRigidObjectCollection, "_create_buffers"): + raise AttributeError("OvPhysxRigidObjectCollection is a stub; skipping ovphysx backend") + + BACKENDS.append("ovphysx") +except (ImportError, AttributeError): + pass + def create_physx_rigid_object_collection( num_instances: int = 2, @@ -209,6 +252,61 @@ def create_newton_rigid_object_collection( return collection, mock_view +def create_ovphysx_rigid_object_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + """Create a test OVPhysX RigidObjectCollection instance with mocked tensor bindings.""" + body_names = [f"object_{i}" for i in range(num_bodies)] + + collection = object.__new__(OvPhysxRigidObjectCollection) + + rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} + collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) + + # Use articulation-mode bindings with num_joints=0 to get (N, B, ...) shaped tensors. + mock_bindings = MockOvPhysxBindingSet( + num_instances=num_instances, + num_joints=0, + num_bodies=num_bodies, + body_names=body_names, + asset_kind="articulation", + ) + mock_bindings.set_random_data() + + object.__setattr__(collection, "_device", device) + object.__setattr__(collection, "_ovphysx", MagicMock()) + object.__setattr__(collection, "_bindings", mock_bindings.bindings) + object.__setattr__(collection, "_num_instances", num_instances) + object.__setattr__(collection, "_num_bodies", num_bodies) + object.__setattr__(collection, "_body_names_list", body_names) + + # Create RigidObjectCollectionData + data = OvPhysxRigidObjectCollectionData(mock_bindings.bindings, num_bodies, device) + data.num_instances = num_instances + data.num_bodies = num_bodies + data._is_primed = True + object.__setattr__(collection, "_data", data) + + # Allocate the buffers that RigidObjectCollection normally allocates in _initialize_impl. + collection._create_buffers() + + # Replace the real wrench composers with mocks for iface coverage. + mock_inst_wrench = MockWrenchComposer(collection) + mock_perm_wrench = MockWrenchComposer(collection) + object.__setattr__(collection, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(collection, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising + object.__setattr__(collection, "_initialize_handle", None) + object.__setattr__(collection, "_invalidate_initialize_handle", None) + object.__setattr__(collection, "_prim_deletion_handle", None) + object.__setattr__(collection, "_debug_vis_handle", None) + + return collection, mock_bindings + + def create_mock_rigid_object_collection( num_instances: int = 2, num_bodies: int = 3, @@ -232,6 +330,8 @@ def get_rigid_object_collection( ): if backend == "physx": return create_physx_rigid_object_collection(num_instances, num_bodies, device) + elif backend == "ovphysx": + return create_ovphysx_rigid_object_collection(num_instances, num_bodies, device) elif backend == "newton": return create_newton_rigid_object_collection(num_instances, num_bodies, device) elif backend.lower() == "mock": diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.major.rst new file mode 100644 index 000000000000..460fdd789f28 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.major.rst @@ -0,0 +1,13 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.assets.RigidObjectCollection` and + :class:`~isaaclab_ovphysx.assets.RigidObjectCollectionData` for the + OVPhysX backend, completing the rigid-body asset surface alongside + :class:`~isaaclab_ovphysx.assets.RigidObject` and + :class:`~isaaclab_ovphysx.assets.Articulation`. Supports + ``(env, body)`` dual indexing and per-body property setters. Uses the + ovphysx 0.4.3+ native fused multi-prim binding API + (``create_tensor_binding(prim_paths=[...])``) so one binding spans all + ``num_instances * num_bodies`` prims per tensor type, mirroring the + strided-view reshape pattern used by the PhysX collection. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/__init__.pyi b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/__init__.pyi index 52d1a435596a..658ced63d2a0 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/__init__.pyi +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/__init__.pyi @@ -7,8 +7,11 @@ __all__ = [ "Articulation", "ArticulationData", "RigidObject", + "RigidObjectCollection", + "RigidObjectCollectionData", "RigidObjectData", ] from .articulation import Articulation, ArticulationData from .rigid_object import RigidObject, RigidObjectData +from .rigid_object_collection import RigidObjectCollection, RigidObjectCollectionData diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py index 9d1b6ca1b8aa..5951ad7be31e 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py @@ -1348,6 +1348,31 @@ def write_joint_friction_to_buffer_index( out_data[env_ids[i], joint_ids[j], 2] = val +@wp.kernel +def resolve_view_ids( + env_ids: wp.array(dtype=wp.int32), + body_ids: wp.array(dtype=wp.int32), + num_query_envs: wp.int32, + num_total_envs: wp.int32, + view_ids: wp.array(dtype=wp.int32), +) -> None: + """Resolve flat view indices from environment and body index pairs. + + Computes flat view indices from (env_id, body_id) pairs using body-major ordering: + ``view_id = body_id * num_total_envs + env_id``. The output array is laid out in + column-major order over the (env, body) grid. + + Args: + env_ids: Input environment indices. Shape is (num_query_envs,). + body_ids: Input body indices. Shape is (num_query_bodies,). + num_query_envs: Total number of queried environments. + num_total_envs: Total number of environments in the simulation. + view_ids: Output flat view indices. Shape is (num_query_bodies * num_query_envs,). + """ + i, j = wp.tid() + view_ids[j * num_query_envs + i] = body_ids[j] * num_total_envs + env_ids[i] + + @wp.kernel def write_joint_friction_to_buffer_mask( in_data: wp.array2d(dtype=wp.float32), diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/__init__.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/__init__.py new file mode 100644 index 000000000000..3fce63a6d0e0 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/__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 + +"""Sub-module for ovphysx-backed rigid object collection assets.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/__init__.pyi b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/__init__.pyi new file mode 100644 index 000000000000..8b12ec95e7a2 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/__init__.pyi @@ -0,0 +1,12 @@ +# 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__ = [ + "RigidObjectCollection", + "RigidObjectCollectionData", +] + +from .rigid_object_collection import RigidObjectCollection +from .rigid_object_collection_data import RigidObjectCollectionData diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/rigid_object_collection.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/rigid_object_collection.py new file mode 100644 index 000000000000..7a1d4e21e535 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/rigid_object_collection.py @@ -0,0 +1,1525 @@ +# 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 re +import warnings +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +import numpy as np +import torch +import warp as wp + +from pxr import UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.assets.rigid_object_collection.base_rigid_object_collection import BaseRigidObjectCollection +from isaaclab.utils.string import resolve_matching_names +from isaaclab.utils.wrench_composer import WrenchComposer + +from isaaclab_ovphysx import tensor_types as TT +from isaaclab_ovphysx.assets import kernels as shared_kernels +from isaaclab_ovphysx.assets.kernels import _body_wrench_to_world, resolve_view_ids +from isaaclab_ovphysx.physics import OvPhysxManager + +from .rigid_object_collection_data import RigidObjectCollectionData + +if TYPE_CHECKING: + from isaaclab.assets.rigid_object_collection.rigid_object_collection_cfg import RigidObjectCollectionCfg + + +class RigidObjectCollection(BaseRigidObjectCollection): + """A rigid object collection class. + + This class represents a collection of rigid objects in the simulation, where the state of the + rigid objects can be accessed and modified using a batched ``(env_ids, object_ids)`` API. + + For each rigid body in the collection, the root prim of the asset must have the `USD RigidBodyAPI`_ + applied to it. This API is used to define the simulation properties of the rigid bodies. On playing the + simulation, the physics engine will automatically register the rigid bodies and create a corresponding + rigid body handle. This handle can be accessed using the :attr:`root_view` attribute. + + Rigid objects in the collection are uniquely identified via the key of the dictionary + :attr:`~isaaclab.assets.RigidObjectCollectionCfg.rigid_objects` in the + :class:`~isaaclab.assets.RigidObjectCollectionCfg` configuration class. + This differs from the :class:`~isaaclab.assets.RigidObject` class, where a rigid object is identified by + the name of the Xform where the `USD RigidBodyAPI`_ is applied. This would not be possible for the rigid + object collection since the :attr:`~isaaclab.assets.RigidObjectCollectionCfg.rigid_objects` dictionary + could contain the same rigid object multiple times, leading to ambiguity. + + .. _`USD RigidBodyAPI`: https://openusd.org/dev/api/class_usd_physics_rigid_body_a_p_i.html + """ + + cfg: RigidObjectCollectionCfg + """Configuration instance for the rigid object.""" + + __backend_name__: str = "ovphysx" + """The name of the backend for the rigid object.""" + + def __init__(self, cfg: RigidObjectCollectionCfg): + """Initialize the rigid object. + + Args: + cfg: A configuration instance. + """ + # Note: We never call the parent constructor as it tries to call its own spawning which we don't want. + # check that the config is valid + cfg.validate() + # store inputs + self.cfg = cfg.copy() + # flag for whether the asset is initialized + self._is_initialized = False + # spawn the rigid objects + for rigid_body_cfg in self.cfg.rigid_objects.values(): + # spawn the asset + if rigid_body_cfg.spawn is not None: + rigid_body_cfg.spawn.func( + rigid_body_cfg.prim_path, + rigid_body_cfg.spawn, + translation=rigid_body_cfg.init_state.pos, + orientation=rigid_body_cfg.init_state.rot, + ) + # check that spawn was successful + matching_prims = sim_utils.find_matching_prims(rigid_body_cfg.prim_path) + if len(matching_prims) == 0: + raise RuntimeError(f"Could not find prim with path {rigid_body_cfg.prim_path}.") + # stores object names + self._body_names_list: list[str] = [] + # one fused TensorBinding per tensor type, populated in _initialize_impl + self._bindings: dict[int, Any] = {} + + # register various callback functions + self._register_callbacks() + self._debug_vis_handle = None + + """ + Properties + """ + + @property + def data(self) -> RigidObjectCollectionData: + return self._data + + @property + def num_instances(self) -> int: + return self._num_instances + + @property + def num_bodies(self) -> int: + """Number of bodies in the rigid object collection.""" + return self._num_bodies + + @property + def body_names(self) -> list[str]: + """Ordered names of bodies in the rigid object collection.""" + return list(self._body_names_list) + + @property + def root_view(self): + """Root view for the rigid object collection. + + Dictionary keyed by TensorType constant, each value a single fused + :class:`~isaaclab_ovphysx.TensorBinding` spanning all bodies in the collection. + + .. note:: + Use this view with caution. It requires handling of tensors in a specific way. + """ + return self._bindings + + @property + def instantaneous_wrench_composer(self) -> WrenchComposer: + """Instantaneous wrench composer. + + Returns a :class:`~isaaclab.utils.wrench_composer.WrenchComposer` instance. Wrenches added or set to this wrench + composer are only valid for the current simulation step. At the end of the simulation step, the wrenches set + to this object are discarded. This is useful to apply forces that change all the time, things like drag forces + for instance. + """ + return self._instantaneous_wrench_composer + + @property + def permanent_wrench_composer(self) -> WrenchComposer: + """Permanent wrench composer. + + Returns a :class:`~isaaclab.utils.wrench_composer.WrenchComposer` instance. Wrenches added or set to this wrench + composer are persistent and are applied to the simulation at every step. This is useful to apply forces that + are constant over a period of time, things like the thrust of a motor for instance. + """ + return self._permanent_wrench_composer + + """ + Operations. + """ + + def reset( + self, + env_ids: Sequence[int] | wp.array | None = None, + object_ids: slice | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Resets all internal buffers of selected environments and objects. + + Args: + env_ids: Environment indices. If None, then all indices are used. + object_ids: Object indices. If None, then all indices are used. + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + # resolve all indices + if (env_ids is None) or (env_ids == slice(None)): + env_ids = slice(None) + # reset external wrench + self._instantaneous_wrench_composer.reset(env_ids, env_mask) + self._permanent_wrench_composer.reset(env_ids, env_mask) + + def write_data_to_sim(self) -> None: + """Write external wrench to the simulation. + + .. note:: + We write external wrench to the simulation here since this function is called before the simulation step. + This ensures that the external wrench is applied at every simulation step. + """ + inst = self._instantaneous_wrench_composer + perm = self._permanent_wrench_composer + if not inst.active and not perm.active: + return + if inst.active: + if perm.active: + inst.add_raw_buffers_from(perm) + force_b = inst.out_force_b.warp + torque_b = inst.out_torque_b.warp + else: + force_b = perm.out_force_b.warp + torque_b = perm.out_torque_b.warp + + poses = self._data.body_link_pose_w.warp # (N, B) wp.transformf + wp.launch( + _body_wrench_to_world, + dim=(self._num_instances, self._num_bodies), + inputs=[force_b, torque_b, poses], + outputs=[self._wrench_buf], + device=self._device, + ) + binding = self._get_binding(TT.LINK_WRENCH) + if binding is not None: + # The articulation-mode mock used by iface tests exposes an instance-major + # ``(N, B, 9)`` view directly; the native fused binding lays elements body- + # major flat as ``(N * B, 9)``. Dispatch via the binding's exposed shape. + if len(binding.shape) >= 2 and binding.shape[1] == self._num_bodies: + binding.write(self._wrench_buf) + else: + view = self.reshape_data_to_view_3d(self._wrench_buf, 9, device=self._device) + binding.write(view) + inst.reset() + + def update(self, dt: float) -> None: + """Updates the simulation data. + + Args: + dt: The time step size in seconds. + """ + self._data.update(dt) + + """ + Operations - Finders. + """ + + def find_bodies( + self, name_keys: str | Sequence[str], preserve_order: bool = False + ) -> tuple[torch.Tensor, list[str]]: + """Find bodies in the rigid body collection based on the name keys. + + Please check the :meth:`isaaclab.utils.string_utils.resolve_matching_names` function for more + information on the name matching. + + Args: + name_keys: A regular expression or a list of regular expressions to match the body names. + preserve_order: Whether to preserve the order of the name keys in the output. Defaults to False. + + Returns: + A tuple of lists containing the body indices and names. + """ + obj_ids, obj_names = resolve_matching_names(name_keys, self.body_names, preserve_order) + return torch.tensor(obj_ids, device=self._device, dtype=torch.int32), obj_names + + """ + Operations - Write to simulation. + """ + + def write_body_pose_to_sim_index( + self, + *, + body_poses: wp.array, + body_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | wp.array | None = None, + ) -> None: + """Set the body pose over selected environment and body indices into the simulation. + + The body pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + + .. note:: + This method expects partial data. + + Args: + body_poses: Body poses in simulation frame [m, rad]. Shape is (len(env_ids), len(body_ids), 7) + or (len(env_ids), len(body_ids)) with dtype wp.transformf. + body_ids: Body indices. If None, then all indices are used. + env_ids: Environment indices. If None, then all indices are used. + """ + self.write_body_link_pose_to_sim_index(body_poses=body_poses, body_ids=body_ids, env_ids=env_ids) + + def write_body_pose_to_sim_mask( + self, + *, + body_poses: wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set the body pose over selected environment and body masks into the simulation. + + The body pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + + .. note:: + This method expects full data. + + Args: + body_poses: Body poses in simulation frame [m, rad]. Shape is (num_instances, num_bodies, 7) + or (num_instances, num_bodies) with dtype wp.transformf. + body_mask: Body mask. If None, then all bodies are updated. Shape is (num_bodies,). + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + self.write_body_link_pose_to_sim_mask(body_poses=body_poses, body_mask=body_mask, env_mask=env_mask) + + def write_body_velocity_to_sim_index( + self, + *, + body_velocities: wp.array, + body_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | wp.array | None = None, + ) -> None: + """Set the body velocity over selected environment and body indices into the simulation. + + The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + + .. note:: + This sets the velocity of the body's center of mass rather than the body's frame. + + .. note:: + This method expects partial data. + + Args: + body_velocities: Body velocities in simulation world frame [m/s, rad/s]. + Shape is (len(env_ids), len(body_ids)) with dtype wp.spatial_vectorf. + body_ids: Body indices. If None, then all indices are used. + env_ids: Environment indices. If None, then all indices are used. + """ + self.write_body_com_velocity_to_sim_index(body_velocities=body_velocities, body_ids=body_ids, env_ids=env_ids) + + def write_body_velocity_to_sim_mask( + self, + *, + body_velocities: wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set the body velocity over selected environment and body masks into the simulation. + + The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + + .. note:: + This sets the velocity of the body's center of mass rather than the body's frame. + + .. note:: + This method expects full data. + + Args: + body_velocities: Body velocities in simulation world frame [m/s, rad/s]. + Shape is (num_instances, num_bodies) with dtype wp.spatial_vectorf. + body_mask: Body mask. If None, then all bodies are updated. Shape is (num_bodies,). + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + self.write_body_com_velocity_to_sim_mask( + body_velocities=body_velocities, body_mask=body_mask, env_mask=env_mask + ) + + def write_body_link_pose_to_sim_index( + self, + *, + body_poses: wp.array, + body_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | wp.array | None = None, + ) -> None: + """Set the body link pose over selected environment and body indices into the simulation. + + The body link pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + + .. note:: + This method expects partial data. + + Args: + body_poses: Body link poses in simulation frame [m, rad]. Shape is (len(env_ids), len(body_ids), 7) + or (len(env_ids), len(body_ids)) with dtype wp.transformf. + body_ids: Body indices. If None, then all indices are used. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype(body_poses, (env_ids.shape[0], body_ids.shape[0]), wp.transformf, "body_poses") + wp.launch( + shared_kernels.set_body_link_pose_to_sim, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[body_poses, env_ids, body_ids, False], + outputs=[ + self.data._body_link_pose_w.data, + self.data._body_link_state_w.data, + self.data._body_state_w.data, + ], + device=self._device, + ) + # Mark the link pose fresh so reads within the same step return the + # kernel-written value rather than re-fetching the pre-step OVPhysX state. + self.data._body_link_pose_w.timestamp = self.data._sim_timestamp + self.data._body_com_pose_w.timestamp = -1.0 + self.data._body_com_state_w.timestamp = -1.0 + self.data._body_link_state_w.timestamp = -1.0 + self.data._body_state_w.timestamp = -1.0 + # set into simulation + self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) + + def write_body_link_pose_to_sim_mask( + self, + *, + body_poses: wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set the body link pose over selected environment and body masks into the simulation. + + The body link pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + + .. note:: + This method expects full data. + + Args: + body_poses: Body link poses in simulation frame [m, rad]. Shape is (num_instances, num_bodies, 7) + or (num_instances, num_bodies) with dtype wp.transformf. + body_mask: Body mask. If None, then all bodies are updated. Shape is (num_bodies,). + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + if env_mask is not None: + env_mask_t = wp.to_torch(env_mask) if isinstance(env_mask, wp.array) else env_mask + env_ids = self._resolve_env_ids(torch.nonzero(env_mask_t)[:, 0].to(torch.int32)) + else: + env_ids = self._ALL_ENV_INDICES + if body_mask is not None: + body_mask_t = wp.to_torch(body_mask) if isinstance(body_mask, wp.array) else body_mask + body_ids = self._resolve_body_ids(torch.nonzero(body_mask_t)[:, 0].to(torch.int32)) + else: + body_ids = self._ALL_BODY_INDICES + self.assert_shape_and_dtype(body_poses, (self._num_instances, self._num_bodies), wp.transformf, "body_poses") + wp.launch( + shared_kernels.set_body_link_pose_to_sim, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[body_poses, env_ids, body_ids, True], + outputs=[ + self.data._body_link_pose_w.data, + self.data._body_link_state_w.data, + self.data._body_state_w.data, + ], + device=self._device, + ) + # Invalidate dependent timestamps + self.data._body_com_pose_w.timestamp = -1.0 + self.data._body_com_state_w.timestamp = -1.0 + self.data._body_link_state_w.timestamp = -1.0 + self.data._body_state_w.timestamp = -1.0 + # set into simulation + self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) + + def write_body_com_pose_to_sim_index( + self, + *, + body_poses: wp.array, + body_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | wp.array | None = None, + ) -> None: + """Set the body center of mass pose over selected environment and body indices into the simulation. + + The body center of mass pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + The orientation is the orientation of the principal axes of inertia. + + .. note:: + This method expects partial data. + + Args: + body_poses: Body center of mass poses in simulation frame [m, rad]. + Shape is (len(env_ids), len(body_ids), 7) or (len(env_ids), len(body_ids)) with dtype wp.transformf. + body_ids: Body indices. If None, then all indices are used. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype(body_poses, (env_ids.shape[0], body_ids.shape[0]), wp.transformf, "body_poses") + wp.launch( + shared_kernels.set_body_com_pose_to_sim, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[body_poses, self.data.body_com_pose_b, env_ids, body_ids, False], + outputs=[ + self.data._body_com_pose_w.data, + self.data._body_link_pose_w.data, + self.data._body_com_state_w.data, + self.data._body_link_state_w.data, + self.data._body_state_w.data, + ], + device=self._device, + ) + # Invalidate dependent timestamps + self.data._body_link_state_w.timestamp = -1.0 + self.data._body_state_w.timestamp = -1.0 + self.data._body_com_state_w.timestamp = -1.0 + # set into simulation (OVPhysX only exposes the link frame) + self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) + + def write_body_com_pose_to_sim_mask( + self, + *, + body_poses: wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set the body center of mass pose over selected environment and body masks into the simulation. + + The body center of mass pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). + The orientation is the orientation of the principal axes of inertia. + + .. note:: + This method expects full data. + + Args: + body_poses: Body center of mass poses in simulation frame [m, rad]. + Shape is (num_instances, num_bodies, 7) or (num_instances, num_bodies) with dtype wp.transformf. + body_mask: Body mask. If None, then all bodies are updated. Shape is (num_bodies,). + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + if env_mask is not None: + env_mask_t = wp.to_torch(env_mask) if isinstance(env_mask, wp.array) else env_mask + env_ids = self._resolve_env_ids(torch.nonzero(env_mask_t)[:, 0].to(torch.int32)) + else: + env_ids = self._ALL_ENV_INDICES + if body_mask is not None: + body_mask_t = wp.to_torch(body_mask) if isinstance(body_mask, wp.array) else body_mask + body_ids = self._resolve_body_ids(torch.nonzero(body_mask_t)[:, 0].to(torch.int32)) + else: + body_ids = self._ALL_BODY_INDICES + self.assert_shape_and_dtype(body_poses, (self._num_instances, self._num_bodies), wp.transformf, "body_poses") + wp.launch( + shared_kernels.set_body_com_pose_to_sim, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[body_poses, self.data.body_com_pose_b, env_ids, body_ids, True], + outputs=[ + self.data._body_com_pose_w.data, + self.data._body_link_pose_w.data, + self.data._body_com_state_w.data, + self.data._body_link_state_w.data, + self.data._body_state_w.data, + ], + device=self._device, + ) + # Invalidate dependent timestamps + self.data._body_link_state_w.timestamp = -1.0 + self.data._body_state_w.timestamp = -1.0 + self.data._body_com_state_w.timestamp = -1.0 + # set into simulation (OVPhysX only exposes the link frame) + self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) + + def write_body_com_velocity_to_sim_index( + self, + *, + body_velocities: wp.array, + body_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | wp.array | None = None, + ) -> None: + """Set the body center of mass velocity over selected environment and body indices into the simulation. + + The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + + .. note:: + This sets the velocity of the body's center of mass rather than the body's frame. + + .. note:: + This method expects partial data. + + Args: + body_velocities: Body center of mass velocities in simulation world frame [m/s, rad/s]. + Shape is (len(env_ids), len(body_ids)) with dtype wp.spatial_vectorf. + body_ids: Body indices. If None, then all indices are used. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype( + body_velocities, (env_ids.shape[0], body_ids.shape[0]), wp.spatial_vectorf, "body_velocities" + ) + wp.launch( + shared_kernels.set_body_com_velocity_to_sim, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[body_velocities, env_ids, body_ids, False], + outputs=[ + self.data._body_com_vel_w.data, + self.data._body_com_acc_w.data, + self.data._body_state_w.data, + self.data._body_com_state_w.data, + ], + device=self._device, + ) + # Mark the COM velocity fresh so reads within the same step return the + # kernel-written value rather than re-fetching the pre-step OVPhysX state. + self.data._body_com_vel_w.timestamp = self.data._sim_timestamp + self.data._body_link_vel_w.timestamp = -1.0 + self.data._body_state_w.timestamp = -1.0 + self.data._body_com_state_w.timestamp = -1.0 + self.data._body_link_state_w.timestamp = -1.0 + # set into simulation + self._binding_write(TT.LINK_VELOCITY, self.data._body_com_vel_w.data, env_ids=env_ids) + + def write_body_com_velocity_to_sim_mask( + self, + *, + body_velocities: wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set the body center of mass velocity over selected environment and body masks into the simulation. + + The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + + .. note:: + This sets the velocity of the body's center of mass rather than the body's frame. + + .. note:: + This method expects full data. + + Args: + body_velocities: Body center of mass velocities in simulation world frame [m/s, rad/s]. + Shape is (num_instances, num_bodies) with dtype wp.spatial_vectorf. + body_mask: Body mask. If None, then all bodies are updated. Shape is (num_bodies,). + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + if env_mask is not None: + env_mask_t = wp.to_torch(env_mask) if isinstance(env_mask, wp.array) else env_mask + env_ids = self._resolve_env_ids(torch.nonzero(env_mask_t)[:, 0].to(torch.int32)) + else: + env_ids = self._ALL_ENV_INDICES + if body_mask is not None: + body_mask_t = wp.to_torch(body_mask) if isinstance(body_mask, wp.array) else body_mask + body_ids = self._resolve_body_ids(torch.nonzero(body_mask_t)[:, 0].to(torch.int32)) + else: + body_ids = self._ALL_BODY_INDICES + self.assert_shape_and_dtype( + body_velocities, (self._num_instances, self._num_bodies), wp.spatial_vectorf, "body_velocities" + ) + wp.launch( + shared_kernels.set_body_com_velocity_to_sim, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[body_velocities, env_ids, body_ids, True], + outputs=[ + self.data._body_com_vel_w.data, + self.data._body_com_acc_w.data, + self.data._body_state_w.data, + self.data._body_com_state_w.data, + ], + device=self._device, + ) + # Invalidate dependent timestamps + self.data._body_link_vel_w.timestamp = -1.0 + self.data._body_state_w.timestamp = -1.0 + self.data._body_com_state_w.timestamp = -1.0 + self.data._body_link_state_w.timestamp = -1.0 + # set into simulation + self._binding_write(TT.LINK_VELOCITY, self.data._body_com_vel_w.data, env_ids=env_ids) + + def write_body_link_velocity_to_sim_index( + self, + *, + body_velocities: wp.array, + body_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | wp.array | None = None, + ) -> None: + """Set the body link velocity over selected environment and body indices into the simulation. + + The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + + .. note:: + This sets the velocity of the body's frame rather than the body's center of mass. + + .. note:: + This method expects partial data. + + Args: + body_velocities: Body link velocities in simulation world frame [m/s, rad/s]. + Shape is (len(env_ids), len(body_ids)) with dtype wp.spatial_vectorf. + body_ids: Body indices. If None, then all indices are used. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype( + body_velocities, (env_ids.shape[0], body_ids.shape[0]), wp.spatial_vectorf, "body_velocities" + ) + wp.launch( + shared_kernels.set_body_link_velocity_to_sim, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[ + body_velocities, + self.data.body_com_pose_b, + self.data.body_link_pose_w, + env_ids, + body_ids, + False, + ], + outputs=[ + self.data._body_link_vel_w.data, + self.data._body_com_vel_w.data, + self.data._body_com_acc_w.data, + self.data._body_link_state_w.data, + self.data._body_state_w.data, + self.data._body_com_state_w.data, + ], + device=self._device, + ) + # Invalidate dependent timestamps + self.data._body_link_state_w.timestamp = -1.0 + self.data._body_state_w.timestamp = -1.0 + self.data._body_com_state_w.timestamp = -1.0 + # set into simulation + self._binding_write(TT.LINK_VELOCITY, self.data._body_com_vel_w.data, env_ids=env_ids) + + def write_body_link_velocity_to_sim_mask( + self, + *, + body_velocities: wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set the body link velocity over selected environment and body masks into the simulation. + + The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + + .. note:: + This sets the velocity of the body's frame rather than the body's center of mass. + + .. note:: + This method expects full data. + + Args: + body_velocities: Body link velocities in simulation world frame [m/s, rad/s]. + Shape is (num_instances, num_bodies) with dtype wp.spatial_vectorf. + body_mask: Body mask. If None, then all bodies are updated. Shape is (num_bodies,). + env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + """ + if env_mask is not None: + env_mask_t = wp.to_torch(env_mask) if isinstance(env_mask, wp.array) else env_mask + env_ids = self._resolve_env_ids(torch.nonzero(env_mask_t)[:, 0].to(torch.int32)) + else: + env_ids = self._ALL_ENV_INDICES + if body_mask is not None: + body_mask_t = wp.to_torch(body_mask) if isinstance(body_mask, wp.array) else body_mask + body_ids = self._resolve_body_ids(torch.nonzero(body_mask_t)[:, 0].to(torch.int32)) + else: + body_ids = self._ALL_BODY_INDICES + self.assert_shape_and_dtype( + body_velocities, (self._num_instances, self._num_bodies), wp.spatial_vectorf, "body_velocities" + ) + wp.launch( + shared_kernels.set_body_link_velocity_to_sim, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[ + body_velocities, + self.data.body_com_pose_b, + self.data.body_link_pose_w, + env_ids, + body_ids, + True, + ], + outputs=[ + self.data._body_link_vel_w.data, + self.data._body_com_vel_w.data, + self.data._body_com_acc_w.data, + self.data._body_link_state_w.data, + self.data._body_state_w.data, + self.data._body_com_state_w.data, + ], + device=self._device, + ) + # Invalidate dependent timestamps + self.data._body_link_state_w.timestamp = -1.0 + self.data._body_state_w.timestamp = -1.0 + self.data._body_com_state_w.timestamp = -1.0 + # set into simulation + self._binding_write(TT.LINK_VELOCITY, self.data._body_com_vel_w.data, env_ids=env_ids) + + """ + Operations - Setters. + """ + + def set_masses_index( + self, + *, + masses: wp.array, + body_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | wp.array | None = None, + ) -> None: + """Set body masses over selected env / body indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_MASS`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + Args: + masses: Body masses [kg]. Shape is (len(env_ids), len(body_ids)) + with dtype wp.float32. + body_ids: Body indices. If None, then all indices are used. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype(masses, (env_ids.shape[0], body_ids.shape[0]), wp.float32, "masses") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[masses, env_ids, body_ids], + outputs=[self.data._body_mass.data], + device=self._device, + ) + wp.copy(self.data._cpu_body_mass, self.data._body_mass.data) + self._binding_write( + TT.BODY_MASS, self.data._cpu_body_mass, env_ids=self._get_cpu_env_ids(env_ids), device="cpu" + ) + + def set_masses_mask( + self, + *, + masses: wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set body masses over selected env / body masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_MASS`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + Args: + masses: Body masses [kg]. Shape is (num_instances, num_bodies) + with dtype wp.float32. + body_mask: Body mask. If None, all bodies are updated. + Shape is (num_bodies,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). + """ + if env_mask is not None: + env_mask_t = wp.to_torch(env_mask) if isinstance(env_mask, wp.array) else env_mask + env_ids = self._resolve_env_ids(torch.nonzero(env_mask_t)[:, 0].to(torch.int32)) + else: + env_ids = self._ALL_ENV_INDICES + self.assert_shape_and_dtype(masses, (self._num_instances, self._num_bodies), wp.float32, "masses") + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_mask, + dim=(self._num_instances, self._num_bodies), + inputs=[masses, self._resolve_env_mask(env_mask), self._resolve_body_mask(body_mask)], + outputs=[self.data._body_mass.data], + device=self._device, + ) + wp.copy(self.data._cpu_body_mass, self.data._body_mass.data) + self._binding_write( + TT.BODY_MASS, self.data._cpu_body_mass, env_ids=self._get_cpu_env_ids(env_ids), device="cpu" + ) + + def set_coms_index( + self, + *, + coms: wp.array, + body_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | wp.array | None = None, + ) -> None: + """Set body center-of-mass poses over selected env / body indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_COM_POSE`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + Args: + coms: Body center-of-mass poses [m, quaternion (w, x, y, z)]. + Shape is (len(env_ids), len(body_ids)) with dtype wp.transformf. + body_ids: Body indices. If None, then all indices are used. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype(coms, (env_ids.shape[0], body_ids.shape[0]), wp.transformf, "coms") + wp.launch( + shared_kernels.write_body_com_pose_to_buffer_index, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[coms, env_ids, body_ids], + outputs=[self.data._body_com_pose_b.data], + device=self._device, + ) + # Invalidate derived buffers that depend on body_com_pose_b. + self.data._body_com_pose_w.timestamp = -1.0 + wp.copy(self.data._cpu_body_coms, self.data._body_com_pose_b.data) + self._binding_write( + TT.BODY_COM_POSE, + self.data._cpu_body_coms, + env_ids=self._get_cpu_env_ids(env_ids), + device="cpu", + data_dim=7, + ) + + def set_coms_mask( + self, + *, + coms: wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set body center-of-mass poses over selected env / body masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_COM_POSE`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + Args: + coms: Body center-of-mass poses [m, quaternion (w, x, y, z)]. + Shape is (num_instances, num_bodies) with dtype wp.transformf. + body_mask: Body mask. If None, all bodies are updated. + Shape is (num_bodies,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). + """ + if env_mask is not None: + env_mask_t = wp.to_torch(env_mask) if isinstance(env_mask, wp.array) else env_mask + env_ids = self._resolve_env_ids(torch.nonzero(env_mask_t)[:, 0].to(torch.int32)) + else: + env_ids = self._ALL_ENV_INDICES + self.assert_shape_and_dtype(coms, (self._num_instances, self._num_bodies), wp.transformf, "coms") + wp.launch( + shared_kernels.write_body_com_pose_to_buffer_mask, + dim=(self._num_instances, self._num_bodies), + inputs=[coms, self._resolve_env_mask(env_mask), self._resolve_body_mask(body_mask)], + outputs=[self.data._body_com_pose_b.data], + device=self._device, + ) + # Invalidate derived buffers that depend on body_com_pose_b. + self.data._body_com_pose_w.timestamp = -1.0 + wp.copy(self.data._cpu_body_coms, self.data._body_com_pose_b.data) + self._binding_write( + TT.BODY_COM_POSE, + self.data._cpu_body_coms, + env_ids=self._get_cpu_env_ids(env_ids), + device="cpu", + data_dim=7, + ) + + def set_inertias_index( + self, + *, + inertias: wp.array, + body_ids: Sequence[int] | wp.array | None = None, + env_ids: Sequence[int] | wp.array | None = None, + ) -> None: + """Set body inertia tensors over selected env / body indices into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_INERTIA`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects partial data. + + Args: + inertias: Body inertia tensors [kg·m²]. Shape is + (len(env_ids), len(body_ids), 9) with dtype wp.float32. + The 9 components are the row-major flatten of the 3×3 inertia + matrix (Ixx, Ixy, Ixz, Iyx, Iyy, Iyz, Izx, Izy, Izz). + body_ids: Body indices. If None, then all indices are used. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) + self.assert_shape_and_dtype(inertias, (env_ids.shape[0], body_ids.shape[0], 9), wp.float32, "inertias") + wp.launch( + shared_kernels.write_body_inertia_to_buffer_index, + dim=(env_ids.shape[0], body_ids.shape[0]), + inputs=[inertias, env_ids, body_ids], + outputs=[self.data._body_inertia.data], + device=self._device, + ) + wp.copy(self.data._cpu_body_inertia, self.data._body_inertia.data) + self._binding_write( + TT.BODY_INERTIA, + self.data._cpu_body_inertia, + env_ids=self._get_cpu_env_ids(env_ids), + device="cpu", + data_dim=9, + ) + + def set_inertias_mask( + self, + *, + inertias: wp.array, + body_mask: wp.array | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Set body inertia tensors over selected env / body masks into the simulation. + + This is a CPU-only write routed through pinned-host staging because + ``BODY_INERTIA`` is a CPU-only OVPhysX binding. + + .. note:: + This method expects full data. + + Args: + inertias: Body inertia tensors [kg·m²]. Shape is + (num_instances, num_bodies, 9) with dtype wp.float32. + The 9 components are the row-major flatten of the 3×3 inertia + matrix (Ixx, Ixy, Ixz, Iyx, Iyy, Iyz, Izx, Izy, Izz). + body_mask: Body mask. If None, all bodies are updated. + Shape is (num_bodies,). + env_mask: Environment mask. If None, all instances are updated. + Shape is (num_instances,). + """ + if env_mask is not None: + env_mask_t = wp.to_torch(env_mask) if isinstance(env_mask, wp.array) else env_mask + env_ids = self._resolve_env_ids(torch.nonzero(env_mask_t)[:, 0].to(torch.int32)) + else: + env_ids = self._ALL_ENV_INDICES + self.assert_shape_and_dtype(inertias, (self._num_instances, self._num_bodies, 9), wp.float32, "inertias") + wp.launch( + shared_kernels.write_body_inertia_to_buffer_mask, + dim=(self._num_instances, self._num_bodies), + inputs=[inertias, self._resolve_env_mask(env_mask), self._resolve_body_mask(body_mask)], + outputs=[self.data._body_inertia.data], + device=self._device, + ) + wp.copy(self.data._cpu_body_inertia, self.data._body_inertia.data) + self._binding_write( + TT.BODY_INERTIA, + self.data._cpu_body_inertia, + env_ids=self._get_cpu_env_ids(env_ids), + device="cpu", + data_dim=9, + ) + + def _initialize_impl(self) -> None: + """Initialize the rigid object collection from the OVPhysX simulation backend. + + For each body in :attr:`cfg.rigid_objects`, validates the prim tree, + converts the IsaacLab prim path to an fnmatch glob, and eagerly creates + a single fused :class:`TensorBinding` per tensor type using the new + ``prim_paths=[...]`` API introduced in ovphysx 0.4.3. + + Then creates the :class:`RigidObjectCollectionData` container and primes + the asset-side buffers. + """ + physx_instance = OvPhysxManager.get_physx_instance() + if physx_instance is None: + raise RuntimeError("OvPhysxManager has not been initialized yet.") + self._ovphysx = physx_instance + self._device = OvPhysxManager.get_device() + + self._prim_paths: list[str] = [] + self._body_names_list: list[str] = [] + + for name, obj_cfg in self.cfg.rigid_objects.items(): + # Convert IsaacLab prim-path notation to the fnmatch-style glob that + # OVPhysX create_tensor_binding expects. Two conventions are in use: + # /World/envs/env_.*/object -- regex dot-star for any env index + # /World/envs/{ENV_REGEX_NS}/object -- explicit placeholder + pattern = re.sub(r"\{ENV_REGEX_NS\}", "*", obj_cfg.prim_path) + pattern = re.sub(r"\.\*", "*", pattern) + + # Validate the prim tree before creating tensor bindings. + # OVPhysX silently returns a zero-count binding when the pattern + # matches nothing; fail fast here with a clear message instead. + template_prim = sim_utils.find_first_matching_prim(obj_cfg.prim_path) + if template_prim is None: + raise RuntimeError(f"Failed to find prim for expression: '{obj_cfg.prim_path}' (body '{name}').") + template_prim_path = template_prim.GetPath().pathString + + root_prims = sim_utils.get_all_matching_child_prims( + template_prim_path, + predicate=lambda prim: prim.HasAPI(UsdPhysics.RigidBodyAPI), + traverse_instance_prims=False, + ) + if len(root_prims) == 0: + raise RuntimeError( + f"Failed to find a rigid body when resolving '{obj_cfg.prim_path}' (body '{name}')." + " Please ensure that the prim has 'USD RigidBodyAPI' applied." + ) + if len(root_prims) > 1: + raise RuntimeError( + f"Failed to find a single rigid body when resolving '{obj_cfg.prim_path}' (body '{name}')." + f" Found multiple '{root_prims}' under '{template_prim_path}'." + " Please ensure that there is only one rigid body in the prim path tree." + ) + + articulation_prims = sim_utils.get_all_matching_child_prims( + template_prim_path, + predicate=lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), + traverse_instance_prims=False, + ) + if len(articulation_prims) != 0: + if articulation_prims[0].GetAttribute("physxArticulation:articulationEnabled").Get(): + raise RuntimeError( + f"Found an articulation root when resolving '{obj_cfg.prim_path}' (body '{name}') in the" + f" rigid object collection. These are located at: '{articulation_prims}' under" + f" '{template_prim_path}'. Please disable the articulation root in the USD or from code by" + " setting the parameter 'ArticulationRootPropertiesCfg.articulation_enabled' to False in the" + " spawn configuration." + ) + + # resolve root prim back into the regex expression + root_prim_path = root_prims[0].GetPath().pathString + suffix = root_prim_path[len(template_prim_path) :] + if suffix: + pattern = pattern + suffix + + self._prim_paths.append(pattern) + self._body_names_list.append(name) + + self._num_bodies = len(self._prim_paths) + + # ovphysx 0.4.3+ accepts ``prim_paths=[g0, ..., g_{B-1}]`` and returns a single + # binding spanning N*B prims with shape ``(N*B, D)`` in body-major order + # ``(body0_env0, body0_env1, ..., body1_env0, ...)``. Bindings are stored under + # the ``LINK_*``/``BODY_*`` data-class keys so the same key works with the + # articulation-mode mock used by iface tests. + _TT_MAP = ( + (TT.LINK_POSE, TT.RIGID_BODY_POSE), + (TT.LINK_VELOCITY, TT.RIGID_BODY_VELOCITY), + (TT.LINK_WRENCH, TT.RIGID_BODY_WRENCH), + (TT.BODY_MASS, TT.RIGID_BODY_MASS), + (TT.BODY_COM_POSE, TT.RIGID_BODY_COM_POSE), + (TT.BODY_INERTIA, TT.RIGID_BODY_INERTIA), + ) + for store_key, rb_tt in _TT_MAP: + try: + self._bindings[store_key] = self._ovphysx.create_tensor_binding( + prim_paths=self._prim_paths, tensor_type=rb_tt + ) + except Exception as e: + raise RuntimeError( + f"OVPhysX could not create fused RIGID_BODY binding {rb_tt!r} for" + f" prim_paths={self._prim_paths!r}." + f" Check that each prim path matches at least one" + f" UsdPhysics.RigidBodyAPI prim." + ) from e + + # Native fused binding has ``count == N * num_bodies`` (body-major flat). + pose_count = self._bindings[TT.LINK_POSE].count + if pose_count % self._num_bodies != 0: + raise RuntimeError( + f"Fused LINK_POSE binding count {pose_count} is not divisible by" + f" num_bodies {self._num_bodies}. prim_paths={self._prim_paths!r}." + ) + self._num_instances = pose_count // self._num_bodies + + self._data = RigidObjectCollectionData( + root_view=self._bindings, + num_bodies=self._num_bodies, + device=self._device, + ) + + self._create_buffers() + self._process_cfg() + self.update(0.0) + self._data.is_primed = True + + def _create_buffers(self) -> None: + """Pre-allocate asset-side index arrays and CPU staging buffers.""" + N = self._num_instances + B = self._num_bodies + + self._ALL_ENV_INDICES = wp.array(np.arange(N), dtype=wp.int32, device=self._device) + self._ALL_BODY_INDICES = wp.array(np.arange(B), dtype=wp.int32, device=self._device) + + # CPU copy of all-env indices used when calling CPU-only binding.write(). + self._cpu_all_env_ids = wp.zeros(N, dtype=wp.int32, device="cpu", pinned=True) + wp.copy(self._cpu_all_env_ids, self._ALL_ENV_INDICES) + + # All-true boolean masks used as defaults in mask-based kernel calls. + self._ALL_TRUE_ENV_MASK = wp.array(np.ones(N, dtype=bool), dtype=wp.bool, device=self._device) + self._ALL_TRUE_BODY_MASK = wp.array(np.ones(B, dtype=bool), dtype=wp.bool, device=self._device) + + # External wrench buffer: direct (N, B, 9) contiguous allocation. + # The fused LINK_WRENCH binding writes from a single (N, B, 9) buffer. + self._wrench_buf = wp.zeros((N, B, 9), dtype=wp.float32, device=self._device) + + self._instantaneous_wrench_composer = WrenchComposer(self) + self._permanent_wrench_composer = WrenchComposer(self) + + # set information about rigid body into data + self._data.body_names = self._body_names_list + + def _process_cfg(self) -> None: + """Post-processing of configuration parameters. + + Reads the per-body initial state from :attr:`cfg.rigid_objects` and + broadcasts it across all environment instances to produce + ``(num_instances, num_bodies, data_size)`` default-state arrays. + """ + default_body_poses = [] + default_body_vels = [] + + for obj_cfg in self.cfg.rigid_objects.values(): + default_body_pose = tuple(obj_cfg.init_state.pos) + tuple(obj_cfg.init_state.rot) + default_body_vel = tuple(obj_cfg.init_state.lin_vel) + tuple(obj_cfg.init_state.ang_vel) + # Broadcast across num_instances: (data_size,) -> (num_instances, data_size) + default_body_pose = np.tile(np.array(default_body_pose, dtype=np.float32), (self._num_instances, 1)) + default_body_vel = np.tile(np.array(default_body_vel, dtype=np.float32), (self._num_instances, 1)) + default_body_poses.append(default_body_pose) + default_body_vels.append(default_body_vel) + + # Stack per-body arrays: each (num_instances, data_size) -> (num_instances, num_bodies, data_size) + default_body_poses = np.stack(default_body_poses, axis=1) + default_body_vels = np.stack(default_body_vels, axis=1) + self._data.default_body_pose = wp.array(default_body_poses, dtype=wp.transformf, device=self._device) + self._data.default_body_vel = wp.array(default_body_vels, dtype=wp.spatial_vectorf, device=self._device) + + """ + Internal simulation callbacks. + """ + + def _invalidate_initialize_callback(self, event) -> None: + """Invalidates the scene elements.""" + # call parent + super()._invalidate_initialize_callback(event) + + """ + Helper functions. + """ + + def _get_binding(self, tensor_type: int): + """Return the cached fused :class:`TensorBinding` for *tensor_type*. + + All bindings are eagerly created in :meth:`_initialize_impl` and stored + under the ``TT.LINK_*`` / ``TT.BODY_*`` keys that + :class:`RigidObjectCollectionData` uses. + + Args: + tensor_type: The TensorType constant identifying which simulation + buffer to bind (e.g. :attr:`~isaaclab_ovphysx.tensor_types.LINK_POSE`). + + Returns: + The cached :class:`TensorBinding`, or ``None`` if not found. + """ + return self._bindings.get(tensor_type) + + def reshape_data_to_view_2d(self, data: wp.array, device: str | None = None) -> wp.array: + """Reshape instance-major ``(num_instances, num_bodies)`` data to body-major view order. + + The native fused multi-prim binding lays data out as + ``(body0_env0, body0_env1, ..., body1_env0, body1_env1, ...)`` with shape + ``(num_bodies * num_instances,)``. This helper builds a strided view of the + instance-major buffer with the transposed layout and clones it into a + contiguous body-major flat array. + + Args: + data: Source buffer with shape ``(num_instances, num_bodies)`` (any single-element dtype). + device: Optional target device for the cloned output. Defaults to ``data.device``. + + Returns: + Contiguous body-major flat buffer with shape ``(num_bodies * num_instances,)``. + """ + if device is None: + device = str(data.device) + element_size = wp.types.type_size_in_bytes(data.dtype) + strided_view = wp.array( + ptr=data.ptr, + shape=(self.num_bodies, self.num_instances), + dtype=data.dtype, + strides=(element_size, self.num_bodies * element_size), + device=str(data.device), + ) + return wp.clone(strided_view, device=device).reshape((self.num_bodies * self.num_instances,)) + + def reshape_data_to_view_3d(self, data: wp.array, data_dim: int, device: str | None = None) -> wp.array: + """Reshape instance-major ``(num_instances, num_bodies, data_dim)`` data to body-major view order. + + Companion of :meth:`reshape_data_to_view_2d` for 3D buffers (e.g. inertia + tensors). Output shape is ``(num_bodies * num_instances, data_dim)``. + + Args: + data: Source buffer with shape ``(num_instances, num_bodies, data_dim)``. + data_dim: Trailing per-element dimension size. + device: Optional target device for the cloned output. Defaults to ``data.device``. + + Returns: + Contiguous body-major buffer with shape ``(num_bodies * num_instances, data_dim)``. + """ + if device is None: + device = str(data.device) + element_size = wp.types.type_size_in_bytes(data.dtype) + row_size = element_size * data_dim + strided_view = wp.array( + ptr=data.ptr, + shape=(self.num_bodies, self.num_instances, data_dim), + dtype=data.dtype, + strides=(row_size, self.num_bodies * row_size, element_size), + device=str(data.device), + ) + return wp.clone(strided_view, device=device).reshape((self.num_bodies * self.num_instances, data_dim)) + + def _binding_write( + self, + tensor_type: int, + instance_major_data: wp.array, + env_ids: wp.array, + device: str | None = None, + data_dim: int | None = None, + ) -> None: + """Write an instance-major buffer through a fused binding. + + Dispatches to one of two paths depending on the binding's layout: + + * **Native fused binding** (``count == num_instances * num_bodies``, + body-major flat layout): the instance-major buffer is reshaped via + :meth:`reshape_data_to_view_2d` / :meth:`reshape_data_to_view_3d` to a + contiguous body-major view, then written with body-major view indices + ``view_id = body_id * num_instances + env_id``. + * **Articulation-mode mock** (``count == num_instances``, instance-major + ``(N, B[, D])`` shape): the buffer is written directly with the + environment indices, matching the existing mock contract. + + Args: + tensor_type: TensorType key identifying the cached binding. + instance_major_data: Instance-major buffer of shape ``(N, B)`` or + ``(N, B, data_dim)``. May use ``wp.float32`` or a structured dtype. + env_ids: Environment indices (1D ``wp.int32`` on ``self._device`` or + ``"cpu"`` for CPU-only bindings). + device: Destination device for the body-major clone (only used on the + fused-binding path). Defaults to ``self._device``. + data_dim: When provided, treat the buffer as 3D and use + :meth:`reshape_data_to_view_3d`. When ``None`` (default), use + :meth:`reshape_data_to_view_2d`. + """ + binding = self._get_binding(tensor_type) + if binding is None: + return + if device is None: + device = self._device + # Disambiguate via the binding's exposed shape: the articulation-mode + # mock returns a directly instance-major view ``(N, B[, D])`` while the + # native fused multi-prim binding lays elements body-major-flat with + # ``shape == (N * B[, D])``. + is_mock_layout = len(binding.shape) >= 2 and binding.shape[1] == self._num_bodies + if is_mock_layout: + float32_data = ( + instance_major_data if instance_major_data.dtype == wp.float32 else instance_major_data.view(wp.float32) + ) + binding.write(float32_data, indices=env_ids) + return + # Native fused path: body-major flat (N*B[, D]); reshape and use view_ids. + if data_dim is None: + view = self.reshape_data_to_view_2d(instance_major_data, device=device).view(wp.float32) + else: + view = self.reshape_data_to_view_3d(instance_major_data, data_dim, device=device) + view_ids = self._env_body_ids_to_view_ids(env_ids, self._ALL_BODY_INDICES, device=device) + binding.write(view, indices=view_ids) + + """ + Internal helper. + """ + + def _resolve_env_ids(self, env_ids) -> wp.array: + """Resolve environment indices to a warp int32 array on ``self._device``. + + Tests sometimes hand us indices on CPU even when the sim runs on GPU; we move the + resolved array onto ``self._device`` so kernel launches don't fail on a device + mismatch. + """ + if env_ids is None or 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) + if isinstance(env_ids, torch.Tensor): + return wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) + if isinstance(env_ids, wp.array) and str(env_ids.device) != self._device: + env_ids = wp.clone(env_ids, device=self._device) + return env_ids + + def _resolve_body_ids(self, body_ids) -> wp.array: + """Resolve body indices to a warp int32 array on ``self._device``.""" + if body_ids is None or 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) + return body_ids + + def _env_body_ids_to_view_ids( + self, env_ids: torch.Tensor | wp.array, body_ids: torch.Tensor | wp.array, device: str = "cuda:0" + ) -> wp.array: + """Convert environment and body indices to flat view indices (body-major ordering). + + Computes ``view_id = body_id * num_instances + env_id`` for each + (env_id, body_id) pair. The output array is laid out column-major over + the (env, body) grid, matching the PhysX ``root_view`` ordering. + + Args: + env_ids: Environment indices. + body_ids: Body indices. + device: Target device for the returned array. + + Returns: + A :class:`wp.array` of shape ``(len(env_ids) * len(body_ids),)`` with + flat view indices on *device*. + """ + if isinstance(env_ids, torch.Tensor): + env_ids = wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) + if isinstance(body_ids, torch.Tensor): + body_ids = wp.from_torch(body_ids.to(torch.int32), dtype=wp.int32) + if str(env_ids.device) != device: + env_ids = wp.clone(env_ids, device=device) + if str(body_ids.device) != device: + body_ids = wp.clone(body_ids, device=device) + num_query_envs = env_ids.shape[0] + view_ids = wp.zeros(num_query_envs * body_ids.shape[0], dtype=wp.int32, device=device) + wp.launch( + resolve_view_ids, + dim=(num_query_envs, body_ids.shape[0]), + inputs=[env_ids, body_ids, num_query_envs, self.num_instances], + outputs=[view_ids], + device=device, + ) + return view_ids + + def _resolve_env_mask(self, env_mask: wp.array | None) -> wp.array: + """Resolve an environment mask to a ``wp.bool`` array on ``self._device``. + + ``None`` returns the pre-allocated all-true mask. + + Args: + env_mask: Boolean environment mask or None. Shape is (num_instances,). + + Returns: + A ``wp.bool`` array of shape (num_instances,) on ``self._device``. + """ + if env_mask is None: + return self._ALL_TRUE_ENV_MASK + if isinstance(env_mask, torch.Tensor): + return wp.from_torch(env_mask.to(torch.bool), dtype=wp.bool) + if isinstance(env_mask, wp.array) and str(env_mask.device) != self._device: + env_mask = wp.clone(env_mask, device=self._device) + return env_mask + + def _resolve_body_mask(self, body_mask: wp.array | None) -> wp.array: + """Resolve a body mask to a ``wp.bool`` array on ``self._device``. + + ``None`` returns the pre-allocated all-true mask. + + Args: + body_mask: Boolean body mask or None. Shape is (num_bodies,). + + Returns: + A ``wp.bool`` array of shape (num_bodies,) on ``self._device``. + """ + if body_mask is None: + return self._ALL_TRUE_BODY_MASK + if isinstance(body_mask, torch.Tensor): + return wp.from_torch(body_mask.to(torch.bool), dtype=wp.bool) + if isinstance(body_mask, wp.array) and str(body_mask.device) != self._device: + body_mask = wp.clone(body_mask, device=self._device) + return body_mask + + def _get_cpu_env_ids(self, env_ids: wp.array) -> wp.array: + """Return CPU int32 env indices for CPU-only binding writes. + + Uses the pre-allocated pinned ``_cpu_all_env_ids`` fast path when + *env_ids* covers all instances, otherwise clones to CPU. + + Args: + env_ids: A warp int32 array of environment indices on any device. + + Returns: + A warp int32 array guaranteed to live on ``"cpu"``. + """ + if env_ids.ptr == self._ALL_ENV_INDICES.ptr: + return self._cpu_all_env_ids + return wp.clone(env_ids, device="cpu") + + """ + Deprecated properties and methods. + """ + + def write_body_state_to_sim( + self, + body_states: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + body_ids: slice | torch.Tensor | None = None, + ) -> None: + """Deprecated, same as :meth:`write_body_link_pose_to_sim_index` and + :meth:`write_body_com_velocity_to_sim_index`.""" + warnings.warn( + "The function 'write_body_state_to_sim' will be deprecated in a future release. Please" + " use 'write_body_link_pose_to_sim_index' and 'write_body_com_velocity_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + if isinstance(body_states, wp.array): + body_states = wp.to_torch(body_states) + self.write_body_link_pose_to_sim_index(body_poses=body_states[:, :, :7], env_ids=env_ids, body_ids=body_ids) + self.write_body_com_velocity_to_sim_index( + body_velocities=body_states[:, :, 7:], env_ids=env_ids, body_ids=body_ids + ) + + def write_body_com_state_to_sim( + self, + body_states: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + body_ids: slice | torch.Tensor | None = None, + ) -> None: + """Deprecated, same as :meth:`write_body_com_pose_to_sim_index` and + :meth:`write_body_com_velocity_to_sim_index`.""" + warnings.warn( + "The function 'write_body_com_state_to_sim' will be deprecated in a future release. Please" + " use 'write_body_com_pose_to_sim_index' and 'write_body_com_velocity_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + if isinstance(body_states, wp.array): + body_states = wp.to_torch(body_states) + self.write_body_com_pose_to_sim_index(body_poses=body_states[:, :, :7], env_ids=env_ids, body_ids=body_ids) + self.write_body_com_velocity_to_sim_index( + body_velocities=body_states[:, :, 7:], env_ids=env_ids, body_ids=body_ids + ) + + def write_body_link_state_to_sim( + self, + body_states: torch.Tensor | wp.array, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + body_ids: slice | torch.Tensor | None = None, + ) -> None: + """Deprecated, same as :meth:`write_body_link_pose_to_sim_index` and + :meth:`write_body_link_velocity_to_sim_index`.""" + warnings.warn( + "The function 'write_body_link_state_to_sim' will be deprecated in a future release. Please" + " use 'write_body_link_pose_to_sim_index' and 'write_body_link_velocity_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + if isinstance(body_states, wp.array): + body_states = wp.to_torch(body_states) + self.write_body_link_pose_to_sim_index(body_poses=body_states[:, :, :7], env_ids=env_ids, body_ids=body_ids) + self.write_body_link_velocity_to_sim_index( + body_velocities=body_states[:, :, 7:], env_ids=env_ids, body_ids=body_ids + ) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/rigid_object_collection_data.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/rigid_object_collection_data.py new file mode 100644 index 000000000000..8bce18404f89 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object_collection/rigid_object_collection_data.py @@ -0,0 +1,1132 @@ +# 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 warnings +from typing import Any + +import numpy as np +import torch +import warp as wp + +from isaaclab.assets.rigid_object_collection.base_rigid_object_collection_data import BaseRigidObjectCollectionData +from isaaclab.utils.buffers import TimestampedBufferWarp as TimestampedBuffer +from isaaclab.utils.math import normalize +from isaaclab.utils.warp import ProxyArray + +from isaaclab_ovphysx import tensor_types as TT +from isaaclab_ovphysx.assets import kernels as shared_kernels +from isaaclab_ovphysx.physics import OvPhysxManager as SimulationManager + + +class RigidObjectCollectionData(BaseRigidObjectCollectionData): + """Data container for a rigid object collection. + + This class contains the data for a rigid object collection in the simulation. The data includes the state of + all the bodies in the collection. The data is stored in the simulation world frame unless otherwise specified. + The data is in the order ``(num_instances, num_objects, data_size)``, where data_size is the size of the data. + + For a rigid body, there are two frames of reference that are used: + + - Actor frame: The frame of reference of the rigid body prim. This typically corresponds to the Xform prim + with the rigid body schema. + - Center of mass frame: The frame of reference of the center of mass of the rigid body. + + Depending on the settings of the simulation, the actor frame and the center of mass frame may be the same. + This needs to be taken into account when interpreting the data. + + The data is lazily updated, meaning that the data is only updated when it is accessed. This is useful + when the data is expensive to compute or retrieve. The data is updated when the timestamp of the buffer + is older than the current simulation timestamp. The timestamp is updated whenever the data is updated. + + .. note:: + **Pull-to-refresh model.** Properties pull fresh data from the OVPhysX tensor API on first access + per timestamp and cache the result. This differs from Newton, where buffers are refreshed + automatically by the simulation. + + .. note:: + **ProxyArray pointer stability.** Each :class:`ProxyArray` wrapper is created once and reused + because the OVPhysX tensor API returns views into stable, pre-allocated GPU buffers whose device + pointer does not change across simulation steps. + """ + + __backend_name__: str = "ovphysx" + """The name of the backend for the rigid object collection data.""" + + def __init__( + self, + root_view: dict[int, Any], + num_bodies: int, + device: str, + ): + """Initializes the rigid object data. + + Args: + root_view: Fused TensorBinding dict, keyed by TensorType constant. Each value is a single + :class:`TensorBinding` spanning all bodies in the collection. + num_bodies: The number of bodies in the collection. + device: The device used for processing. + """ + super().__init__(root_view, num_bodies, device) + # Store the bindings dict (the equivalent of the root view in PhysX). + self._bindings = root_view + self._binding_getter = None # may be set externally after construction + self.num_bodies = num_bodies + self._num_bodies = num_bodies + # Set initial time stamp + self._sim_timestamp = 0.0 + self._is_primed = False + # Body-major read scratch buffers (keyed by tensor_type). Allocated on the binding's own + # device — pinned host for CPU-only bindings, GPU for the rest — so ``binding.read(scratch)`` + # never crosses devices. + self._cpu_staging_buffers: dict[int, wp.array] = {} + + # Read num_instances from the LINK_POSE binding. The native fused multi-prim binding lays + # elements out body-major-flat with ``shape == (N * B, 7)`` and ``count == N * B``. The + # articulation-mode mock used by iface tests exposes an instance-major view directly with + # ``shape == (N, B, 7)`` and ``count == N``. Dispatch via the binding's exposed shape. + pose_binding = self._bindings[TT.LINK_POSE] + if len(pose_binding.shape) >= 2 and pose_binding.shape[1] == num_bodies: + self.num_instances = pose_binding.count + else: + self.num_instances = pose_binding.count // num_bodies + self._num_instances = self.num_instances + + if SimulationManager._sim is not None and hasattr(SimulationManager._sim, "cfg"): + gravity = SimulationManager._sim.cfg.gravity + else: + gravity = (0.0, 0.0, -9.81) + + gravity_dir = torch.tensor((gravity[0], gravity[1], gravity[2]), device=self.device) + if torch.linalg.norm(gravity_dir) > 0.0: + gravity_dir = normalize(gravity_dir.unsqueeze(0)).squeeze(0) + gravity_dir = gravity_dir.repeat(self.num_instances, self.num_bodies, 1) + forward_vec = torch.tensor((1.0, 0.0, 0.0), device=self.device).repeat(self.num_instances, self.num_bodies, 1) + + # Initialize constants + self.GRAVITY_VEC_W = ProxyArray(wp.from_torch(gravity_dir, dtype=wp.vec3f)) + self.FORWARD_VEC_B = ProxyArray(wp.from_torch(forward_vec, dtype=wp.vec3f)) + + self._create_buffers() + + @property + def is_primed(self) -> bool: + """Whether the rigid object collection data is fully instantiated and ready to use.""" + return self._is_primed + + @is_primed.setter + def is_primed(self, value: bool) -> None: + """Set whether the rigid object collection data is fully instantiated and ready to use. + + .. note:: + Once this quantity is set to True, it cannot be changed. + + Args: + value: The primed state. + + Raises: + ValueError: If the rigid object collection data is already primed. + """ + if self._is_primed: + raise ValueError("The rigid object collection data is already primed.") + self._is_primed = value + + def update(self, dt: float) -> None: + """Updates the data for the rigid object collection. + + Args: + dt: The time step for the update [s]. This must be a positive value. + """ + # update the simulation timestamp + self._sim_timestamp += dt + # Prime the FD-dependent COM acceleration so the first read returns a sensible (zero) value. + _ = self.body_com_acc_w + + """ + Names. + """ + + body_names: list[str] = None + """Body names in the order parsed by the simulation view.""" + + """ + Defaults. + """ + + @property + def default_body_pose(self) -> ProxyArray: + """Default body pose ``[pos, quat]`` in local environment frame. + + Shape is (num_instances, num_bodies), dtype = ``wp.transformf``. + In torch this resolves to (num_instances, num_bodies, 7). + Set by :meth:`~RigidObjectCollection._process_cfg` during initialization. + """ + if self._default_body_pose_ta is None: + self._default_body_pose_ta = ProxyArray(self._default_body_pose) + return self._default_body_pose_ta + + @default_body_pose.setter + def default_body_pose(self, value: wp.array) -> None: + self._default_body_pose.assign(value) + + @property + def default_body_vel(self) -> ProxyArray: + """Default body velocity ``[lin_vel, ang_vel]`` in local environment frame. + + Shape is (num_instances, num_bodies), dtype = ``wp.spatial_vectorf``. + In torch this resolves to (num_instances, num_bodies, 6). + Set by :meth:`~RigidObjectCollection._process_cfg` during initialization. + """ + if self._default_body_vel_ta is None: + self._default_body_vel_ta = ProxyArray(self._default_body_vel) + return self._default_body_vel_ta + + @default_body_vel.setter + def default_body_vel(self, value: wp.array) -> None: + self._default_body_vel.assign(value) + + @property + def default_body_state(self) -> ProxyArray: + """Default root state ``[pos, quat, lin_vel, ang_vel]`` in local environment frame. + + Deprecated. Use :attr:`default_body_pose` and :attr:`default_body_vel` instead. + + Shape is (num_instances, num_bodies), dtype = ``vec13f``. + In torch this resolves to (num_instances, num_bodies, 13). + """ + warnings.warn( + "Reading the body state directly is deprecated since IsaacLab 3.0 and will be removed in a future version. " + "Please use the default_body_pose and default_body_vel properties instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._default_body_state is None: + self._default_body_state = wp.zeros( + (self.num_instances, self.num_bodies), dtype=shared_kernels.vec13f, device=self.device + ) + self._default_body_state_ta = ProxyArray(self._default_body_state) + wp.launch( + shared_kernels.concat_body_pose_and_vel_to_state, + dim=(self.num_instances, self.num_bodies), + inputs=[ + self._default_body_pose, + self._default_body_vel, + ], + outputs=[ + self._default_body_state, + ], + device=self.device, + ) + return self._default_body_state_ta + + """ + Body state properties. + """ + + @property + def body_link_pose_w(self) -> ProxyArray: + """Body link pose ``[pos, quat]`` in simulation world frame [m, -]. + + Shape is (num_instances, num_bodies), dtype = ``wp.transformf``. + In torch this resolves to (num_instances, num_bodies, 7). + This quantity is the pose of the actor frame of the rigid body relative to + the world. The orientation is provided in (x, y, z, w) format. + """ + if self._body_link_pose_w.timestamp < self._sim_timestamp: + self._read_transform_binding(TT.LINK_POSE, self._body_link_pose_w) + # Invalidate sliced sub-component proxies so they are rebuilt from the + # updated buffer on next access. + self._body_link_pos_w_ta = None + self._body_link_quat_w_ta = None + if self._body_link_pose_w_ta is None: + self._body_link_pose_w_ta = ProxyArray(self._body_link_pose_w.data) + return self._body_link_pose_w_ta + + @property + def body_link_vel_w(self) -> ProxyArray: + """Body link velocity ``[lin_vel, ang_vel]`` in simulation world frame [m/s, rad/s]. + + Shape is (num_instances, num_bodies), dtype = ``wp.spatial_vectorf``. + In torch this resolves to (num_instances, num_bodies, 6). + This quantity contains the linear and angular velocities of the actor frame + of the rigid body relative to the world. + """ + if self._body_link_vel_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.get_body_link_vel_from_body_com_vel, + dim=(self.num_instances, self.num_bodies), + inputs=[ + self.body_com_vel_w, + self.body_link_pose_w, + self.body_com_pose_b, + ], + outputs=[self._body_link_vel_w.data], + device=self.device, + ) + self._body_link_vel_w.timestamp = self._sim_timestamp + self._body_link_lin_vel_w_ta = None + self._body_link_ang_vel_w_ta = None + if self._body_link_vel_w_ta is None: + self._body_link_vel_w_ta = ProxyArray(self._body_link_vel_w.data) + return self._body_link_vel_w_ta + + @property + def body_com_pose_w(self) -> ProxyArray: + """Body center of mass pose ``[pos, quat]`` in simulation world frame [m, -]. + + Shape is (num_instances, num_bodies), dtype = ``wp.transformf``. + In torch this resolves to (num_instances, num_bodies, 7). + This quantity is the pose of the center of mass frame of the rigid body + relative to the world. The orientation is provided in (x, y, z, w) format. + """ + if self._body_com_pose_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.get_body_com_pose_from_body_link_pose, + dim=(self.num_instances, self.num_bodies), + inputs=[ + self.body_link_pose_w, + self.body_com_pose_b, + ], + outputs=[self._body_com_pose_w.data], + device=self.device, + ) + self._body_com_pose_w.timestamp = self._sim_timestamp + self._body_com_pos_w_ta = None + self._body_com_quat_w_ta = None + if self._body_com_pose_w_ta is None: + self._body_com_pose_w_ta = ProxyArray(self._body_com_pose_w.data) + return self._body_com_pose_w_ta + + @property + def body_com_vel_w(self) -> ProxyArray: + """Body center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame [m/s, rad/s]. + + Shape is (num_instances, num_bodies), dtype = ``wp.spatial_vectorf``. + In torch this resolves to (num_instances, num_bodies, 6). + This quantity contains the linear and angular velocities of the rigid body's + center of mass frame relative to the world. + """ + if self._body_com_vel_w.timestamp < self._sim_timestamp: + self._read_spatial_vector_binding(TT.LINK_VELOCITY, self._body_com_vel_w) + self._body_com_lin_vel_w_ta = None + self._body_com_ang_vel_w_ta = None + if self._body_com_vel_w_ta is None: + self._body_com_vel_w_ta = ProxyArray(self._body_com_vel_w.data) + return self._body_com_vel_w_ta + + @property + def body_com_acc_w(self) -> ProxyArray: + """Acceleration of all bodies ``[lin_acc, ang_acc]`` in the simulation world frame [m/s², rad/s²]. + + Shape is (num_instances, num_bodies), dtype = ``wp.spatial_vectorf``. + In torch this resolves to (num_instances, num_bodies, 6). + This quantity is the acceleration of the rigid bodies' center of mass frame relative + to the world, derived by finite differencing consecutive COM velocities. + """ + if self._body_com_acc_w.timestamp < self._sim_timestamp: + if self._previous_body_com_vel is None: + self._previous_body_com_vel = wp.clone(self.body_com_vel_w.warp) + wp.launch( + shared_kernels.derive_body_acceleration_from_body_com_velocities, + dim=(self.num_instances, self.num_bodies), + device=self.device, + inputs=[ + self.body_com_vel_w.warp, + SimulationManager.get_physics_dt(), + self._previous_body_com_vel, + ], + outputs=[ + self._body_com_acc_w.data, + ], + ) + self._body_com_acc_w.timestamp = self._sim_timestamp + self._body_com_lin_acc_w_ta = None + self._body_com_ang_acc_w_ta = None + if self._body_com_acc_w_ta is None: + self._body_com_acc_w_ta = ProxyArray(self._body_com_acc_w.data) + return self._body_com_acc_w_ta + + @property + def body_com_pose_b(self) -> ProxyArray: + """Center of mass pose ``[pos, quat]`` of all bodies in their respective body link frames [m, -]. + + Shape is (num_instances, num_bodies), dtype = ``wp.transformf``. + In torch this resolves to (num_instances, num_bodies, 7). + This quantity is the pose of the center of mass frame of the rigid body + relative to the body's link frame. The orientation is provided in + (x, y, z, w) format. + """ + if self._body_com_pose_b.timestamp < self._sim_timestamp: + self._read_transform_binding(TT.BODY_COM_POSE, self._body_com_pose_b) + self._body_com_pos_b_ta = None + self._body_com_quat_b_ta = None + if self._body_com_pose_b_ta is None: + self._body_com_pose_b_ta = ProxyArray(self._body_com_pose_b.data) + return self._body_com_pose_b_ta + + @property + def body_mass(self) -> ProxyArray: + """Mass of all bodies [kg]. + + Shape is (num_instances, num_bodies), dtype = ``wp.float32``. + In torch this resolves to (num_instances, num_bodies). + """ + if self._body_mass_ta is None: + self._body_mass_ta = ProxyArray(self._body_mass.data) + return self._body_mass_ta + + @property + def body_inertia(self) -> ProxyArray: + """Inertia tensor of all bodies, expressed at the center of mass [kg·m²]. + + Shape is (num_instances, num_bodies, 9), dtype = ``wp.float32``. + The 9 components are the row-major flatten of the 3×3 inertia matrix + ``(Ixx, Ixy, Ixz, Iyx, Iyy, Iyz, Izx, Izy, Izz)``. + In torch this resolves to (num_instances, num_bodies, 9). + """ + if self._body_inertia_ta is None: + self._body_inertia_ta = ProxyArray(self._body_inertia.data) + return self._body_inertia_ta + + """ + Deprecated state-concat properties. + """ + + @property + def body_state_w(self) -> ProxyArray: + """Deprecated, same as :attr:`body_link_pose_w` and :attr:`body_com_vel_w`.""" + warnings.warn( + "The `body_state_w` property will be deprecated in IsaacLab 4.0. Please use `body_link_pose_w` and " + "`body_com_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._body_state_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.concat_body_pose_and_vel_to_state, + dim=(self.num_instances, self.num_bodies), + inputs=[self.body_link_pose_w, self.body_com_vel_w], + outputs=[self._body_state_w.data], + device=self.device, + ) + self._body_state_w.timestamp = self._sim_timestamp + if self._body_state_w_ta is None: + self._body_state_w_ta = ProxyArray(self._body_state_w.data) + return self._body_state_w_ta + + @property + def body_link_state_w(self) -> ProxyArray: + """Deprecated, same as :attr:`body_link_pose_w` and :attr:`body_link_vel_w`.""" + warnings.warn( + "The `body_link_state_w` property will be deprecated in IsaacLab 4.0. Please use `body_link_pose_w` and " + "`body_link_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._body_link_state_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.concat_body_pose_and_vel_to_state, + dim=(self.num_instances, self.num_bodies), + inputs=[self.body_link_pose_w, self.body_link_vel_w], + outputs=[self._body_link_state_w.data], + device=self.device, + ) + self._body_link_state_w.timestamp = self._sim_timestamp + if self._body_link_state_w_ta is None: + self._body_link_state_w_ta = ProxyArray(self._body_link_state_w.data) + return self._body_link_state_w_ta + + @property + def body_com_state_w(self) -> ProxyArray: + """Deprecated, same as :attr:`body_com_pose_w` and :attr:`body_com_vel_w`.""" + warnings.warn( + "The `body_com_state_w` property will be deprecated in IsaacLab 4.0. Please use `body_com_pose_w` and " + "`body_com_vel_w` instead.", + DeprecationWarning, + stacklevel=2, + ) + if self._body_com_state_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.concat_body_pose_and_vel_to_state, + dim=(self.num_instances, self.num_bodies), + inputs=[self.body_com_pose_w, self.body_com_vel_w], + outputs=[self._body_com_state_w.data], + device=self.device, + ) + self._body_com_state_w.timestamp = self._sim_timestamp + if self._body_com_state_w_ta is None: + self._body_com_state_w_ta = ProxyArray(self._body_com_state_w.data) + return self._body_com_state_w_ta + + """ + Sliced properties. + """ + + @property + def body_link_pos_w(self) -> ProxyArray: + """Positions of all bodies in simulation world frame [m]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + This quantity is the position of the rigid bodies' actor frame relative to + the world. + """ + parent = self.body_link_pose_w + if self._body_link_pos_w_ta is None: + self._body_link_pos_w_ta = ProxyArray(self._get_pos_from_transform(parent.warp)) + return self._body_link_pos_w_ta + + @property + def body_link_quat_w(self) -> ProxyArray: + """Orientation (x, y, z, w) of all bodies in simulation world frame. + + Shape is (num_instances, num_bodies), dtype = ``wp.quatf``. + In torch this resolves to (num_instances, num_bodies, 4). + This quantity is the orientation of the rigid bodies' actor frame relative + to the world. + """ + parent = self.body_link_pose_w + if self._body_link_quat_w_ta is None: + self._body_link_quat_w_ta = ProxyArray(self._get_quat_from_transform(parent.warp)) + return self._body_link_quat_w_ta + + @property + def body_link_lin_vel_w(self) -> ProxyArray: + """Linear velocity of all bodies in simulation world frame [m/s]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + This quantity is the linear velocity of the rigid bodies' actor frame + relative to the world. + """ + parent = self.body_link_vel_w + if self._body_link_lin_vel_w_ta is None: + self._body_link_lin_vel_w_ta = ProxyArray(self._get_lin_vel_from_spatial_vector(parent.warp)) + return self._body_link_lin_vel_w_ta + + @property + def body_link_ang_vel_w(self) -> ProxyArray: + """Angular velocity of all bodies in simulation world frame [rad/s]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + This quantity is the angular velocity of the rigid bodies' actor frame + relative to the world. + """ + parent = self.body_link_vel_w + if self._body_link_ang_vel_w_ta is None: + self._body_link_ang_vel_w_ta = ProxyArray(self._get_ang_vel_from_spatial_vector(parent.warp)) + return self._body_link_ang_vel_w_ta + + @property + def body_link_lin_vel_b(self) -> ProxyArray: + """Linear velocity of all bodies in their respective body (actor) frames [m/s]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + """ + if self._body_link_lin_vel_b.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.quat_apply_inverse_2D_kernel, + dim=(self.num_instances, self.num_bodies), + inputs=[self.body_link_lin_vel_w, self.body_link_quat_w], + outputs=[self._body_link_lin_vel_b.data], + device=self.device, + ) + self._body_link_lin_vel_b.timestamp = self._sim_timestamp + if self._body_link_lin_vel_b_ta is None: + self._body_link_lin_vel_b_ta = ProxyArray(self._body_link_lin_vel_b.data) + return self._body_link_lin_vel_b_ta + + @property + def body_link_ang_vel_b(self) -> ProxyArray: + """Angular velocity of all bodies in their respective body (actor) frames [rad/s]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + """ + if self._body_link_ang_vel_b.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.quat_apply_inverse_2D_kernel, + dim=(self.num_instances, self.num_bodies), + inputs=[self.body_link_ang_vel_w, self.body_link_quat_w], + outputs=[self._body_link_ang_vel_b.data], + device=self.device, + ) + self._body_link_ang_vel_b.timestamp = self._sim_timestamp + if self._body_link_ang_vel_b_ta is None: + self._body_link_ang_vel_b_ta = ProxyArray(self._body_link_ang_vel_b.data) + return self._body_link_ang_vel_b_ta + + @property + def body_com_pos_w(self) -> ProxyArray: + """Positions of all bodies' center of mass in simulation world frame [m]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + """ + parent = self.body_com_pose_w + if self._body_com_pos_w_ta is None: + self._body_com_pos_w_ta = ProxyArray(self._get_pos_from_transform(parent.warp)) + return self._body_com_pos_w_ta + + @property + def body_com_quat_w(self) -> ProxyArray: + """Orientation (x, y, z, w) of the principal axes of inertia of all bodies in simulation world frame. + + Shape is (num_instances, num_bodies), dtype = ``wp.quatf``. + In torch this resolves to (num_instances, num_bodies, 4). + """ + parent = self.body_com_pose_w + if self._body_com_quat_w_ta is None: + self._body_com_quat_w_ta = ProxyArray(self._get_quat_from_transform(parent.warp)) + return self._body_com_quat_w_ta + + @property + def body_com_lin_vel_w(self) -> ProxyArray: + """Linear velocity of all bodies' center of mass in simulation world frame [m/s]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + """ + parent = self.body_com_vel_w + if self._body_com_lin_vel_w_ta is None: + self._body_com_lin_vel_w_ta = ProxyArray(self._get_lin_vel_from_spatial_vector(parent.warp)) + return self._body_com_lin_vel_w_ta + + @property + def body_com_ang_vel_w(self) -> ProxyArray: + """Angular velocity of all bodies' center of mass in simulation world frame [rad/s]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + """ + parent = self.body_com_vel_w + if self._body_com_ang_vel_w_ta is None: + self._body_com_ang_vel_w_ta = ProxyArray(self._get_ang_vel_from_spatial_vector(parent.warp)) + return self._body_com_ang_vel_w_ta + + @property + def body_com_lin_vel_b(self) -> ProxyArray: + """Linear velocity of all bodies' center of mass in their respective body (actor) frames [m/s]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + """ + if self._body_com_lin_vel_b.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.quat_apply_inverse_2D_kernel, + dim=(self.num_instances, self.num_bodies), + inputs=[self.body_com_lin_vel_w, self.body_link_quat_w], + outputs=[self._body_com_lin_vel_b.data], + device=self.device, + ) + self._body_com_lin_vel_b.timestamp = self._sim_timestamp + if self._body_com_lin_vel_b_ta is None: + self._body_com_lin_vel_b_ta = ProxyArray(self._body_com_lin_vel_b.data) + return self._body_com_lin_vel_b_ta + + @property + def body_com_ang_vel_b(self) -> ProxyArray: + """Angular velocity of all bodies' center of mass in their respective body (actor) frames [rad/s]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + """ + if self._body_com_ang_vel_b.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.quat_apply_inverse_2D_kernel, + dim=(self.num_instances, self.num_bodies), + inputs=[self.body_com_ang_vel_w, self.body_link_quat_w], + outputs=[self._body_com_ang_vel_b.data], + device=self.device, + ) + self._body_com_ang_vel_b.timestamp = self._sim_timestamp + if self._body_com_ang_vel_b_ta is None: + self._body_com_ang_vel_b_ta = ProxyArray(self._body_com_ang_vel_b.data) + return self._body_com_ang_vel_b_ta + + @property + def body_com_lin_acc_w(self) -> ProxyArray: + """Linear acceleration of all bodies' center of mass in simulation world frame [m/s²]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + """ + parent = self.body_com_acc_w + if self._body_com_lin_acc_w_ta is None: + self._body_com_lin_acc_w_ta = ProxyArray(self._get_lin_vel_from_spatial_vector(parent.warp)) + return self._body_com_lin_acc_w_ta + + @property + def body_com_ang_acc_w(self) -> ProxyArray: + """Angular acceleration of all bodies' center of mass in simulation world frame [rad/s²]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + """ + parent = self.body_com_acc_w + if self._body_com_ang_acc_w_ta is None: + self._body_com_ang_acc_w_ta = ProxyArray(self._get_ang_vel_from_spatial_vector(parent.warp)) + return self._body_com_ang_acc_w_ta + + @property + def body_com_pos_b(self) -> ProxyArray: + """Center of mass position of all of the bodies in their respective link frames [m]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + """ + parent = self.body_com_pose_b + if self._body_com_pos_b_ta is None: + self._body_com_pos_b_ta = ProxyArray(self._get_pos_from_transform(parent.warp)) + return self._body_com_pos_b_ta + + @property + def body_com_quat_b(self) -> ProxyArray: + """Orientation (x, y, z, w) of the principal axes of inertia of all of the bodies + in their respective link frames. + + Shape is (num_instances, num_bodies), dtype = ``wp.quatf``. + In torch this resolves to (num_instances, num_bodies, 4). + """ + parent = self.body_com_pose_b + if self._body_com_quat_b_ta is None: + self._body_com_quat_b_ta = ProxyArray(self._get_quat_from_transform(parent.warp)) + return self._body_com_quat_b_ta + + """ + Derived Properties. + """ + + @property + def projected_gravity_b(self) -> ProxyArray: + """Projection of the gravity direction onto each body frame [-]. + + Shape is (num_instances, num_bodies), dtype = ``wp.vec3f``. + In torch this resolves to (num_instances, num_bodies, 3). + """ + if self._projected_gravity_b.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.quat_apply_inverse_2D_kernel, + dim=(self.num_instances, self.num_bodies), + inputs=[self.GRAVITY_VEC_W, self.body_link_quat_w], + outputs=[self._projected_gravity_b.data], + device=self.device, + ) + self._projected_gravity_b.timestamp = self._sim_timestamp + if self._projected_gravity_b_ta is None: + self._projected_gravity_b_ta = ProxyArray(self._projected_gravity_b.data) + return self._projected_gravity_b_ta + + @property + def heading_w(self) -> ProxyArray: + """Yaw heading of each body frame [rad]. + + Shape is (num_instances, num_bodies), dtype = ``wp.float32``. + In torch this resolves to (num_instances, num_bodies). + + .. note:: + This quantity is computed by assuming that the forward-direction of each + body frame is along the x-direction, i.e. :math:`(1, 0, 0)`. + """ + if self._heading_w.timestamp < self._sim_timestamp: + wp.launch( + shared_kernels.body_heading_w, + dim=(self.num_instances, self.num_bodies), + inputs=[self.FORWARD_VEC_B, self.body_link_quat_w], + outputs=[self._heading_w.data], + device=self.device, + ) + self._heading_w.timestamp = self._sim_timestamp + if self._heading_w_ta is None: + self._heading_w_ta = ProxyArray(self._heading_w.data) + return self._heading_w_ta + + def _create_buffers(self) -> None: + """Eagerly allocate every per-body TimestampedBuffer and the slots for + cached :class:`ProxyArray` wrappers. + + Buffers use direct ``(num_instances, num_bodies, D)`` shapes, matching + the fused binding output. No flat+strided tricks are needed because the + fused binding returns a contiguous ``(N, B, D)`` array directly. + """ + super()._create_buffers() + + N = self.num_instances + B = self.num_bodies + + # -- link frame w.r.t. world frame + self._body_link_pose_w = TimestampedBuffer((N, B), self.device, wp.transformf) + self._body_link_vel_w = TimestampedBuffer((N, B), self.device, wp.spatial_vectorf) + # -- com frame w.r.t. link frame + self._body_com_pose_b = TimestampedBuffer((N, B), self.device, wp.transformf) + # -- com frame w.r.t. world frame + self._body_com_pose_w = TimestampedBuffer((N, B), self.device, wp.transformf) + self._body_com_vel_w = TimestampedBuffer((N, B), self.device, wp.spatial_vectorf) + # -- combined state (cached, used by deprecated concat properties) + self._body_state_w = TimestampedBuffer((N, B), self.device, shared_kernels.vec13f) + self._body_link_state_w = TimestampedBuffer((N, B), self.device, shared_kernels.vec13f) + self._body_com_state_w = TimestampedBuffer((N, B), self.device, shared_kernels.vec13f) + # -- derived properties (in-body-frame velocities) + self._body_link_lin_vel_b = TimestampedBuffer((N, B), self.device, wp.vec3f) + self._body_link_ang_vel_b = TimestampedBuffer((N, B), self.device, wp.vec3f) + self._body_com_lin_vel_b = TimestampedBuffer((N, B), self.device, wp.vec3f) + self._body_com_ang_vel_b = TimestampedBuffer((N, B), self.device, wp.vec3f) + # -- derived properties (acceleration via finite differencing) + self._body_com_acc_w = TimestampedBuffer((N, B), self.device, wp.spatial_vectorf) + # Holds the previous-step COM velocity for FD; initialised lazily on first access. + self._previous_body_com_vel: wp.array | None = None + # -- derived properties (projected gravity and heading) + self._projected_gravity_b = TimestampedBuffer((N, B), self.device, wp.vec3f) + self._heading_w = TimestampedBuffer((N, B), self.device, wp.float32) + + # -- Body properties: mass (N, B) and inertia (N, B, 9). + # Initialised eagerly from the CPU-only bindings. + self._body_mass = TimestampedBuffer((N, B), self.device, wp.float32) + self._body_inertia = TimestampedBuffer((N, B, 9), self.device, wp.float32) + + # Pinned CPU staging buffers used by mass/com/inertia setters. + pinned = self.device != "cpu" + self._cpu_body_mass = wp.zeros((N, B), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_body_coms = wp.zeros((N, B, 7), dtype=wp.float32, device="cpu", pinned=pinned) + self._cpu_body_inertia = wp.zeros((N, B, 9), dtype=wp.float32, device="cpu", pinned=pinned) + + # Eagerly read mass and inertia (CPU-only bindings) at construction time. + # The native fused binding returns body-major flat data ``(N*B[, D])``; + # the articulation-mode mock returns instance-major ``(N, B[, D])``. + # In either case we reshape on the CPU into instance-major numpy arrays. + def _read_cpu(tensor_type, trailing_dim=None): + binding = self._get_binding(tensor_type) + if binding is None: + return None + np_buf = np.zeros(binding.shape, dtype=np.float32) + binding.read(np_buf) + if binding.count == N: + # Mock fast-path: already (N, B[, D]). + return np_buf + # Native fused path: body-major flat -> instance-major via reshape+transpose. + if trailing_dim is None: + return np_buf.reshape(B, N).T.copy() + return np_buf.reshape(B, N, trailing_dim).transpose(1, 0, 2).copy() + + np_mass = _read_cpu(TT.BODY_MASS) + if np_mass is not None: + wp.copy(self._body_mass.data, wp.from_numpy(np_mass, dtype=wp.float32, device=self.device)) + self._body_mass.timestamp = self._sim_timestamp + + np_inertia = _read_cpu(TT.BODY_INERTIA, trailing_dim=9) + if np_inertia is not None: + wp.copy( + self._body_inertia.data, + wp.from_numpy(np_inertia, dtype=wp.float32, device=self.device), + ) + self._body_inertia.timestamp = self._sim_timestamp + + # -- Defaults (allocated here, filled by _process_cfg after __init__). + # Zero-initialized buffers; populated by RigidObjectCollection._process_cfg. + self._default_body_pose = wp.zeros((N, B), dtype=wp.transformf, device=self.device) + self._default_body_vel = wp.zeros((N, B), dtype=wp.spatial_vectorf, device=self.device) + + # Initialize ProxyArray wrappers. + self._pin_proxy_arrays() + + def _pin_proxy_arrays(self) -> None: + """Create pinned :class:`ProxyArray` wrappers for all data buffers. + + This is called once from :meth:`_create_buffers` during initialization. + OVPhysX tensor API buffers have stable GPU pointers across simulation steps, + so no rebinding is needed (unlike Newton). + """ + # Defaults + self._default_body_pose_ta: ProxyArray | None = None + self._default_body_vel_ta: ProxyArray | None = None + # Body state (timestamped) + self._body_link_pose_w_ta: ProxyArray | None = None + self._body_link_vel_w_ta: ProxyArray | None = None + self._body_com_pose_w_ta: ProxyArray | None = None + self._body_com_vel_w_ta: ProxyArray | None = None + self._body_com_pose_b_ta: ProxyArray | None = None + # Body properties + self._body_mass_ta: ProxyArray | None = None + self._body_inertia_ta: ProxyArray | None = None + # Derived properties (in-body-frame velocities) + self._body_link_lin_vel_b_ta: ProxyArray | None = None + self._body_link_ang_vel_b_ta: ProxyArray | None = None + self._body_com_lin_vel_b_ta: ProxyArray | None = None + self._body_com_ang_vel_b_ta: ProxyArray | None = None + # Derived properties (FD acceleration) + self._body_com_acc_w_ta: ProxyArray | None = None + self._body_com_lin_acc_w_ta: ProxyArray | None = None + self._body_com_ang_acc_w_ta: ProxyArray | None = None + # Derived properties (projected gravity and heading) + self._projected_gravity_b_ta: ProxyArray | None = None + self._heading_w_ta: ProxyArray | None = None + # Sliced properties (body link) + self._body_link_pos_w_ta: ProxyArray | None = None + self._body_link_quat_w_ta: ProxyArray | None = None + self._body_link_lin_vel_w_ta: ProxyArray | None = None + self._body_link_ang_vel_w_ta: ProxyArray | None = None + # Sliced properties (body com) + self._body_com_pos_w_ta: ProxyArray | None = None + self._body_com_quat_w_ta: ProxyArray | None = None + self._body_com_lin_vel_w_ta: ProxyArray | None = None + self._body_com_ang_vel_w_ta: ProxyArray | None = None + # Sliced properties (body com in body frame) + self._body_com_pos_b_ta: ProxyArray | None = None + self._body_com_quat_b_ta: ProxyArray | None = None + # Deprecated state-concat properties + self._default_body_state: wp.array | None = None + self._default_body_state_ta: ProxyArray | None = None + self._body_state_w_ta: ProxyArray | None = None + self._body_link_state_w_ta: ProxyArray | None = None + self._body_com_state_w_ta: ProxyArray | None = None + + """ + Helpers. + """ + + def _get_binding(self, tensor_type: int): + """Return the binding for the given tensor type, or None. + + Args: + tensor_type: The TensorType constant identifying which simulation buffer. + + Returns: + The cached :class:`TensorBinding`, or ``None`` if not available. + """ + b = self._bindings.get(tensor_type) + if b is not None: + return b + if self._binding_getter is not None: + b = self._binding_getter(tensor_type) + if b is not None: + self._bindings[tensor_type] = b + return b + return None + + def _read_view_scratch(self, tensor_type: int, binding) -> wp.array: + """Return a cached body-major scratch float32 buffer matching ``binding.shape``. + + Allocated on the binding's own device (GPU bindings → GPU, CPU-only + bindings → pinned host) so that ``binding.read(scratch)`` never crosses + devices. The scratch buffer is body-major flat + ``(num_bodies * num_instances[, D])`` and is reshaped into instance-major + ``(N, B[, D])`` by :meth:`_reshape_view_to_data_2d` / + :meth:`_reshape_view_to_data_3d` before being copied into the user-facing + timestamped buffer. + + Args: + tensor_type: TensorType key (used as cache key). + binding: The fused :class:`TensorBinding` whose shape the scratch + must match. + + Returns: + Cached body-major scratch buffer for reads. + """ + scratch = self._cpu_staging_buffers.get(tensor_type) + if scratch is not None: + return scratch + binding_device = "cpu" if tensor_type in TT._CPU_ONLY_TYPES else self.device + pinned = binding_device == "cpu" and self.device != "cpu" + if pinned: + scratch = wp.zeros(binding.shape, dtype=wp.float32, device="cpu", pinned=True) + else: + scratch = wp.zeros(binding.shape, dtype=wp.float32, device=binding_device) + self._cpu_staging_buffers[tensor_type] = scratch + return scratch + + def _read_binding_into_instance_major(self, tensor_type: int, buf: TimestampedBuffer, floats_per_elem: int) -> None: + """Read a fused binding into the instance-major ``buf.data``. + + The native fused multi-prim binding returns data in body-major flat order + ``(body0_env0, body0_env1, ..., body1_env0, ...)`` with + ``binding.count == num_instances * num_bodies``. The articulation-mode + mock used by iface tests instead exposes a directly instance-major view + with ``binding.count == num_instances`` and shape ``(N, B[, D])``. + + This method dispatches to the right path: + + * **Mock fast-path** (``binding.count == num_instances``): a float32 view + of the destination buffer is filled directly via ``binding.read()``. + * **Native fused path** (``binding.count == num_instances * num_bodies``): + ``binding.read()`` fills a body-major scratch, which is then reshaped + into instance-major order via :meth:`_reshape_view_to_data_2d` (for + single-element-per-body fields like mass) or + :meth:`_reshape_view_to_data_3d` (for fields with a trailing + ``floats_per_elem`` dimension), and the result is copied into the + destination buffer. + + Args: + tensor_type: TensorType key identifying the binding. + buf: Timestamped buffer to refresh. ``buf.data`` is + ``(num_instances, num_bodies)`` for single-element-per-body + fields (e.g. mass), or ``(num_instances, num_bodies, ...)`` for + multi-component fields. + floats_per_elem: Number of trailing ``float32`` elements per body + (e.g. 7 for transformf, 6 for spatial_vectorf, 9 for inertia). + Pass 1 for plain scalar fields like mass. + """ + if buf.timestamp >= self._sim_timestamp: + return + binding = self._get_binding(tensor_type) + if binding is None: + return + + B = self.num_bodies + + # Disambiguate via the binding's exposed shape: the articulation-mode + # mock returns a directly instance-major view ``(N, B[, D])`` while the + # native fused multi-prim binding lays elements body-major-flat with + # ``shape == (N * B[, D])``. + is_mock_layout = len(binding.shape) >= 2 and binding.shape[1] == B + + if is_mock_layout: + if buf.data.dtype == wp.float32: + view = buf.data + else: + view = wp.array( + ptr=buf.data.ptr, + shape=binding.shape, + dtype=wp.float32, + device=str(buf.data.device), + copy=False, + ) + binding.read(view) + buf.timestamp = self._sim_timestamp + return + + # Native fused path: read body-major scratch then strided-view reshape. + scratch = self._read_view_scratch(tensor_type, binding) + binding.read(scratch) + if floats_per_elem <= 1: + reshaped = self._reshape_view_to_data_2d(scratch) + else: + reshaped = self._reshape_view_to_data_3d(scratch, floats_per_elem) + # Copy into buf.data, reinterpreting structured-dtype buffers as float32. + if buf.data.dtype == wp.float32: + dst_view = buf.data + else: + dst_view = wp.array( + ptr=buf.data.ptr, + shape=reshaped.shape, + dtype=wp.float32, + device=str(buf.data.device), + copy=False, + ) + wp.copy(dst_view, reshaped) + buf.timestamp = self._sim_timestamp + + def _read_transform_binding(self, tensor_type: int, buf: TimestampedBuffer) -> None: + """Read a pose binding (``wp.transformf`` buffer), skipping if fresh. + + Args: + tensor_type: TensorType key. + buf: Timestamped :class:`wp.transformf` buffer to refresh. + """ + self._read_binding_into_instance_major(tensor_type, buf, floats_per_elem=7) + + def _read_spatial_vector_binding(self, tensor_type: int, buf: TimestampedBuffer) -> None: + """Read a velocity binding (``wp.spatial_vectorf`` buffer), skipping if fresh. + + Args: + tensor_type: TensorType key. + buf: Timestamped :class:`wp.spatial_vectorf` buffer to refresh. + """ + self._read_binding_into_instance_major(tensor_type, buf, floats_per_elem=6) + + def _reshape_view_to_data_2d(self, data: wp.array) -> wp.array: + """Reshape body-major flat data into instance-major ``(num_instances, num_bodies)``. + + The native fused binding lays elements out body-major: + ``(body0_env0, body0_env1, ..., body1_env0, body1_env1, ...)``. This helper + constructs a strided view that traverses the data in instance-major order + ``(env0_body0, env0_body1, ..., env1_body0, ...)``, then clones it onto + :attr:`device` for contiguity and (when needed) cross-device transfer. + + Args: + data: Body-major flat buffer. Shape is ``(num_bodies * num_instances,)`` + with any single-element dtype. + + Returns: + Contiguous instance-major buffer with shape ``(num_instances, num_bodies)`` + on :attr:`device`. + """ + element_size = wp.types.type_size_in_bytes(data.dtype) + strided_view = wp.array( + ptr=data.ptr, + shape=(self.num_instances, self.num_bodies), + dtype=data.dtype, + strides=(element_size, self.num_instances * element_size), + device=str(data.device), + ) + return wp.clone(strided_view, self.device) + + def _reshape_view_to_data_3d(self, data: wp.array, data_dim: int) -> wp.array: + """Reshape body-major flat data into instance-major ``(num_instances, num_bodies, data_dim)``. + + Companion of :meth:`_reshape_view_to_data_2d` for fields with a trailing + per-body dimension (e.g. pose has 7, spatial velocity has 6, inertia has 9). + + Args: + data: Body-major flat buffer with shape ``(num_bodies * num_instances, data_dim)`` + or ``(num_bodies * num_instances,)`` reinterpreted as ``data_dim``-wide rows. + data_dim: Trailing per-body dimension size. + + Returns: + Contiguous instance-major buffer with shape + ``(num_instances, num_bodies, data_dim)`` on :attr:`device`. + """ + element_size = wp.types.type_size_in_bytes(wp.float32) + row_size = element_size * data_dim + strided_view = wp.array( + ptr=data.ptr, + shape=(self.num_instances, self.num_bodies, data_dim), + dtype=wp.float32, + strides=(row_size, self.num_instances * row_size, element_size), + device=str(data.device), + ) + return wp.clone(strided_view, self.device) + + def _get_pos_from_transform(self, transform: wp.array) -> wp.array: + """Generates a position array from a transform array.""" + return wp.array( + ptr=transform.ptr, + shape=transform.shape, + dtype=wp.vec3f, + strides=transform.strides, + device=self.device, + ) + + def _get_quat_from_transform(self, transform: wp.array) -> wp.array: + """Generates a quaternion array from a transform array.""" + return wp.array( + ptr=transform.ptr + 3 * 4, + shape=transform.shape, + dtype=wp.quatf, + strides=transform.strides, + device=self.device, + ) + + def _get_lin_vel_from_spatial_vector(self, sv: wp.array) -> wp.array: + """Generates a linear velocity array from a spatial vector array.""" + return wp.array( + ptr=sv.ptr, + shape=sv.shape, + dtype=wp.vec3f, + strides=sv.strides, + device=self.device, + ) + + def _get_ang_vel_from_spatial_vector(self, sv: wp.array) -> wp.array: + """Generates an angular velocity array from a spatial vector array.""" + return wp.array( + ptr=sv.ptr + 3 * 4, + shape=sv.shape, + dtype=wp.vec3f, + strides=sv.strides, + device=self.device, + ) diff --git a/source/isaaclab_ovphysx/setup.py b/source/isaaclab_ovphysx/setup.py index 4898806285dd..f09519155eb8 100644 --- a/source/isaaclab_ovphysx/setup.py +++ b/source/isaaclab_ovphysx/setup.py @@ -37,6 +37,7 @@ "isaaclab_ovphysx", "isaaclab_ovphysx.assets", "isaaclab_ovphysx.assets.articulation", + "isaaclab_ovphysx.assets.rigid_object_collection", "isaaclab_ovphysx.cloner", "isaaclab_ovphysx.physics", "isaaclab_ovphysx.test", diff --git a/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py b/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py new file mode 100644 index 000000000000..07ec860d6ec6 --- /dev/null +++ b/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py @@ -0,0 +1,963 @@ +# 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 + +# ignore private usage of variables warning +# pyright: reportPrivateUsage=none + + +"""Real-backend tests for the OVPhysX RigidObjectCollection. + +Run via ``./scripts/run_ovphysx.sh -m pytest`` (kitless, no ``AppLauncher``). + +``ovphysx<=0.3.7`` binds device mode (CPU vs GPU) at the C++ layer on the +first ``ovphysx.PhysX(device=...)`` construction and cannot swap it without a +process restart. Full coverage therefore requires two separate pytest +invocations -- once with ``-k 'cpu'`` and once with ``-k 'cuda:0'``. The +``_ovphysx_skip_other_device`` autouse fixture below preempts the manager's +:exc:`RuntimeError` by ``pytest.skip``-ing on the unlocked device so +single-device runs finish cleanly. +""" + +from __future__ import annotations + +import sys + +import pytest +import torch +import warp as wp + +# The CI isaaclab_ov* pattern unintentionally collects isaaclab_ovphysx tests, +# but the ovphysx wheel is not installed in that environment. Skip gracefully +# so the isaaclab_ov CI pipeline is not blocked by an unrelated dependency. +pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") + +from isaaclab_ovphysx.assets import RigidObjectCollection # noqa: E402 +from isaaclab_ovphysx.physics import OvPhysxCfg # noqa: E402 + +import isaaclab.sim as sim_utils # noqa: E402 +from isaaclab.assets import RigidObjectCfg, RigidObjectCollectionCfg # noqa: E402 +from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR # noqa: E402 +from isaaclab.utils.math import ( # noqa: E402 + combine_frame_transforms, + default_orientation, + quat_apply_inverse, + quat_inv, + quat_mul, + quat_rotate, + random_orientation, + subtract_frame_transforms, +) + +wp.init() + + +_LOCKED_DEVICE: list[str | None] = [None] +"""Device the session pins to on the first parametrized test that runs.""" + + +@pytest.fixture(autouse=True) +def _ovphysx_skip_other_device(request): + """Skip parametrized tests on the device the session is not pinned to. + + See the module docstring for the wheel's process-global device-mode lock. + """ + callspec = getattr(request.node, "callspec", None) + device = callspec.params.get("device") if callspec is not None else None + if device is None: + # Test does not parametrize on device. + return + locked = _LOCKED_DEVICE[0] + if locked is None: + _LOCKED_DEVICE[0] = device + return + if device != locked: + pytest.skip( + f"ovphysx process-global device lock is held by '{locked}'; cannot run '{device}' " + "tests in the same session. Run pytest twice (once per device) for full coverage." + ) + + +def _ovphysx_sim_context(device: str, **kwargs): + """Wrapper around :func:`build_simulation_context` that injects OVPhysX cfg. + + PhysX tests pass ``device=device`` directly and let + :func:`build_simulation_context` build a default :class:`SimulationCfg`. + OVPhysX needs ``physics=OvPhysxCfg()`` set on the cfg so the manager + dispatches to OVPhysX rather than PhysX, so we build the cfg here and + pass it through. ``gravity_enabled`` is consumed locally (it is ignored + by ``build_simulation_context`` once a ``sim_cfg`` is provided). + ``add_ground_plane``, ``auto_add_lighting``, and other kwargs continue + to flow through ``build_simulation_context`` as before. + """ + dt = kwargs.pop("dt", 1.0 / 60.0) + gravity_enabled = kwargs.pop("gravity_enabled", True) + gravity = (0.0, 0.0, -9.81) if gravity_enabled else (0.0, 0.0, 0.0) + sim_cfg = SimulationCfg(physics=OvPhysxCfg(), device=device, dt=dt, gravity=gravity) + return build_simulation_context(device=device, sim_cfg=sim_cfg, **kwargs) + + +# --------------------------------------------------------------------------- +# Material-property gap (xfail reason shared by the test below) +# --------------------------------------------------------------------------- + +_MATERIAL_GAP_REASON = ( + "Requires RIGID_BODY_MATERIAL TensorType (or a view-helper) on the ovphysx " + "wheel side. RigidObjectCollection.root_view is a per-tensor-type bindings dict on " + "OVPhysX, so root_view.get_material_properties() / set_material_properties() " + "are not available. See " + "docs/superpowers/specs/2026-04-28-ovphysx-wheel-gaps-for-marco.md." +) + + +def generate_cubes_scene( + num_envs: int = 1, + num_cubes: int = 1, + height=1.0, + has_api: bool = True, + kinematic_enabled: bool = False, + device: str = "cuda:0", +) -> tuple[RigidObjectCollection, torch.Tensor]: + """Generate a scene with the provided number of cubes. + + Args: + num_envs: Number of envs to generate. + num_cubes: Number of cubes to generate. + height: Height of the cubes. + has_api: Whether the cubes have a rigid body API on them. + kinematic_enabled: Whether the cubes are kinematic. + device: Device to use for the simulation. + + Returns: + A tuple containing the rigid object representing the cubes and the origins of the cubes. + + """ + origins = torch.tensor([(i * 3.0, 0, height) for i in range(num_envs)]).to(device) + # Create Top-level Xforms, one for each cube + for i, origin in enumerate(origins): + sim_utils.create_prim(f"/World/Table_{i}", "Xform", translation=origin) + + # Resolve spawn configuration + if has_api: + spawn_cfg = sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/DexCube/dex_cube_instanceable.usd", + rigid_props=sim_utils.RigidBodyPropertiesCfg(kinematic_enabled=kinematic_enabled), + ) + else: + # since no rigid body properties defined, this is just a static collider + spawn_cfg = sim_utils.CuboidCfg( + size=(0.1, 0.1, 0.1), + collision_props=sim_utils.CollisionPropertiesCfg(), + ) + + # create the rigid object configs. OVPhysX matches prim paths via fnmatch globs (not regex), + # so use ``Table_*`` rather than the PhysX ``Table_.*`` form. + cube_config_dict = {} + for i in range(num_cubes): + cube_object_cfg = RigidObjectCfg( + prim_path=f"/World/Table_*/Object_{i}", + spawn=spawn_cfg, + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 3 * i, height)), + ) + cube_config_dict[f"cube_{i}"] = cube_object_cfg + # create the rigid object collection + cube_object_collection_cfg = RigidObjectCollectionCfg(rigid_objects=cube_config_dict) + cube_object_colection = RigidObjectCollection(cfg=cube_object_collection_cfg) + + return cube_object_colection, origins + + +@pytest.mark.parametrize("num_envs", [1, 2]) +@pytest.mark.parametrize("num_cubes", [1, 3]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_initialization(num_envs, num_cubes, device): + """Test initialization for prim with rigid body API at the provided prim path.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + object_collection, _ = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(object_collection) < 10 + + # Play sim + sim.reset() + + # Check if object is initialized + assert object_collection.is_initialized + assert len(object_collection.body_names) == num_cubes + + # Check buffers that exist and have correct shapes + assert object_collection.data.body_link_pos_w.torch.shape == (num_envs, num_cubes, 3) + assert object_collection.data.body_link_quat_w.torch.shape == (num_envs, num_cubes, 4) + assert object_collection.data.body_mass.torch.shape == (num_envs, num_cubes) + assert object_collection.data.body_inertia.torch.shape == (num_envs, num_cubes, 9) + + # Simulate physics + for _ in range(2): + sim.step() + object_collection.update(sim.cfg.dt) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_id_conversion(device): + """Test environment and object index conversion to physics view indices.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + object_collection, _ = generate_cubes_scene(num_envs=2, num_cubes=3, device=device) + + # Play sim + sim.reset() + + expected = [ + torch.tensor([4, 5], device=device, dtype=torch.int32), + torch.tensor([4], device=device, dtype=torch.int32), + torch.tensor([0, 2, 4], device=device, dtype=torch.int32), + torch.tensor([1, 3, 5], device=device, dtype=torch.int32), + ] + + torch_all_env_indices = wp.to_torch(object_collection._ALL_ENV_INDICES) + torch_all_body_indices = wp.to_torch(object_collection._ALL_BODY_INDICES) + + view_ids = object_collection._env_body_ids_to_view_ids( + torch_all_env_indices, torch_all_body_indices[None, 2], device=device + ) + assert (wp.to_torch(view_ids) == expected[0]).all() + view_ids = object_collection._env_body_ids_to_view_ids( + torch_all_env_indices[None, 0], torch_all_body_indices[None, 2], device=device + ) + assert (wp.to_torch(view_ids) == expected[1]).all() + view_ids = object_collection._env_body_ids_to_view_ids( + torch_all_env_indices[None, 0], torch_all_body_indices, device=device + ) + assert (wp.to_torch(view_ids) == expected[2]).all() + view_ids = object_collection._env_body_ids_to_view_ids( + torch_all_env_indices[None, 1], torch_all_body_indices, device=device + ) + assert (wp.to_torch(view_ids) == expected[3]).all() + + +@pytest.mark.parametrize("num_envs", [1, 2]) +@pytest.mark.parametrize("num_cubes", [1, 3]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_initialization_with_kinematic_enabled(num_envs, num_cubes, device): + """Test that initialization for prim with kinematic flag enabled.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + object_collection, origins = generate_cubes_scene( + num_envs=num_envs, num_cubes=num_cubes, kinematic_enabled=True, device=device + ) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(object_collection) < 10 + + # Play sim + sim.reset() + + # Check if object is initialized + assert object_collection.is_initialized + assert len(object_collection.body_names) == num_cubes + + # Check buffers that exist and have correct shapes + assert object_collection.data.body_link_pos_w.torch.shape == (num_envs, num_cubes, 3) + assert object_collection.data.body_link_quat_w.torch.shape == (num_envs, num_cubes, 4) + + # Simulate physics + for _ in range(2): + sim.step() + object_collection.update(sim.cfg.dt) + # check that the object is kinematic + default_body_pose = object_collection.data.default_body_pose.torch.clone() + default_body_vel = object_collection.data.default_body_vel.torch.clone() + default_body_pose[..., :3] += origins.unsqueeze(1) + torch.testing.assert_close(object_collection.data.body_link_pose_w.torch, default_body_pose) + torch.testing.assert_close(object_collection.data.body_link_vel_w.torch, default_body_vel) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_initialization_with_no_rigid_body(num_cubes, device): + """Test that initialization fails when no rigid body is found at the provided prim path.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + object_collection, _ = generate_cubes_scene(num_cubes=num_cubes, has_api=False, device=device) + + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(object_collection) < 10 + + # Play sim + with pytest.raises(RuntimeError): + sim.reset() + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_external_force_buffer(device): + """Test if external force buffer correctly updates in the force value is zero case.""" + num_envs = 2 + num_cubes = 1 + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + object_collection, origins = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) + sim.reset() + + # find objects to apply the force + object_ids, object_names = object_collection.find_bodies(".*") + # reset object + object_collection.reset() + + # perform simulation + for step in range(5): + # initiate force tensor + external_wrench_b = torch.zeros(object_collection.num_instances, len(object_ids), 6, device=sim.device) + + # decide if zero or non-zero force + if step == 0 or step == 3: + force = 1.0 + else: + force = 0.0 + + # apply force to the object + external_wrench_b[:, :, 0] = force + external_wrench_b[:, :, 3] = force + + object_collection.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + body_ids=object_ids, + env_ids=None, + ) + + # check if the object collection's force and torque buffers are correctly updated + for i in range(num_envs): + assert object_collection._permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force + assert object_collection._permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force + + object_collection.instantaneous_wrench_composer.add_forces_and_torques_index( + body_ids=object_ids, + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + ) + + # apply action to the object collection + object_collection.write_data_to_sim() + sim.step() + object_collection.update(sim.cfg.dt) + + +@pytest.mark.parametrize("num_envs", [1, 2]) +@pytest.mark.parametrize("num_cubes", [1, 4]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_external_force_on_single_body(num_envs, num_cubes, device): + """Test application of external force on the base of the object.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + object_collection, origins = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) + sim.reset() + + # find objects to apply the force + object_ids, object_names = object_collection.find_bodies(".*") + + # Sample a force equal to the weight of the object + external_wrench_b = torch.zeros(object_collection.num_instances, len(object_ids), 6, device=sim.device) + # Every 2nd cube should have a force applied to it + external_wrench_b[:, 0::2, 2] = 9.81 * object_collection.data.body_mass.torch[:, 0::2] + + for i in range(5): + # reset object state + body_pose = object_collection.data.default_body_pose.torch.clone() + body_vel = object_collection.data.default_body_vel.torch.clone() + # need to shift the position of the cubes otherwise they will be on top of each other + body_pose[..., :2] += origins.unsqueeze(1)[..., :2] + object_collection.write_body_link_pose_to_sim_index(body_poses=body_pose) + object_collection.write_body_com_velocity_to_sim_index(body_velocities=body_vel) + # reset object + object_collection.reset() + + is_global = False + if i % 2 == 0: + positions = object_collection.data.body_link_pos_w.torch[:, object_ids, :3] + is_global = True + else: + positions = None + + # apply force + object_collection.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + positions=positions, + body_ids=object_ids, + env_ids=None, + is_global=is_global, + ) + for _ in range(10): + # write data to sim + object_collection.write_data_to_sim() + # step sim + sim.step() + # update object collection + object_collection.update(sim.cfg.dt) + + # First object should still be at the same Z position (1.0) + torch.testing.assert_close( + object_collection.data.body_link_pos_w.torch[:, 0::2, 2], + torch.ones_like(object_collection.data.body_link_pos_w.torch[:, 0::2, 2]), + ) + # Second object should have fallen, so it's Z height should be less than initial height of 1.0 + assert torch.all(object_collection.data.body_link_pos_w.torch[:, 1::2, 2] < 1.0) + + +@pytest.mark.parametrize("num_envs", [1, 2]) +@pytest.mark.parametrize("num_cubes", [1, 4]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_external_force_on_single_body_at_position(num_envs, num_cubes, device): + """Test application of external force on the base of the object at a specific position. + + In this test, we apply a force equal to the weight of an object on the base of + one of the objects at 1m in the Y direction, we check that the object rotates around it's X axis. + For the other object, we do not apply any force and check that it falls down. + """ + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + object_collection, origins = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) + sim.reset() + + # find objects to apply the force + object_ids, object_names = object_collection.find_bodies(".*") + + # Sample a force equal to the weight of the object + external_wrench_b = torch.zeros(object_collection.num_instances, len(object_ids), 6, device=sim.device) + external_wrench_positions_b = torch.zeros( + 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_positions_b[:, 0::2, 1] = 1.0 + + # Desired force and torque + for i in range(5): + # reset object state + body_pose = object_collection.data.default_body_pose.torch.clone() + body_vel = object_collection.data.default_body_vel.torch.clone() + # need to shift the position of the cubes otherwise they will be on top of each other + body_pose[..., :2] += origins.unsqueeze(1)[..., :2] + object_collection.write_body_link_pose_to_sim_index(body_poses=body_pose) + object_collection.write_body_com_velocity_to_sim_index(body_velocities=body_vel) + # reset object + object_collection.reset() + + is_global = False + if i % 2 == 0: + body_com_pos_w = object_collection.data.body_link_pos_w.torch[:, object_ids, :3] + external_wrench_positions_b[..., 0] = 0.0 + external_wrench_positions_b[..., 1] = 1.0 + external_wrench_positions_b[..., 2] = 0.0 + external_wrench_positions_b += body_com_pos_w + is_global = True + else: + external_wrench_positions_b[..., 0] = 0.0 + external_wrench_positions_b[..., 1] = 1.0 + external_wrench_positions_b[..., 2] = 0.0 + + # apply force + object_collection.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + positions=external_wrench_positions_b, + body_ids=object_ids, + env_ids=None, + is_global=is_global, + ) + object_collection.permanent_wrench_composer.add_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + positions=external_wrench_positions_b, + body_ids=object_ids, + is_global=is_global, + ) + + for _ in range(10): + # write data to sim + object_collection.write_data_to_sim() + # step sim + sim.step() + # update object collection + object_collection.update(sim.cfg.dt) + + # First object should be rotating around it's X axis + assert torch.all(object_collection.data.body_com_ang_vel_b.torch[:, 0::2, 0] > 0.1) + # Second object should have fallen, so it's Z height should be less than initial height of 1.0 + assert torch.all(object_collection.data.body_link_pos_w.torch[:, 1::2, 2] < 1.0) + + +@pytest.mark.parametrize("num_envs", [1, 3]) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_set_object_state(num_envs, num_cubes, device, gravity_enabled): + """Test setting the state of the object. + + .. note:: + Turn off gravity for this test as we don't want any external forces acting on the object + to ensure state remains static + """ + with _ovphysx_sim_context(device=device, gravity_enabled=gravity_enabled, auto_add_lighting=True) as sim: + object_collection, origins = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) + sim.reset() + + state_types = ["body_link_pos_w", "body_link_quat_w", "body_com_lin_vel_w", "body_com_ang_vel_w"] + + # Set each state type individually as they are dependent on each other + for state_type_to_randomize in state_types: + state_dict = { + "body_link_pos_w": torch.zeros_like(object_collection.data.body_link_pos_w.torch, device=sim.device), + "body_link_quat_w": default_orientation(num=num_cubes * num_envs, device=sim.device).view( + num_envs, num_cubes, 4 + ), + "body_com_lin_vel_w": torch.zeros_like( + object_collection.data.body_com_lin_vel_w.torch, device=sim.device + ), + "body_com_ang_vel_w": torch.zeros_like( + object_collection.data.body_com_ang_vel_w.torch, device=sim.device + ), + } + + for _ in range(5): + # reset object + object_collection.reset() + + # Set random state + if state_type_to_randomize == "body_link_quat_w": + state_dict[state_type_to_randomize] = random_orientation( + num=num_cubes * num_envs, device=sim.device + ).view(num_envs, num_cubes, 4) + else: + state_dict[state_type_to_randomize] = torch.randn(num_envs, num_cubes, 3, device=sim.device) + # make sure objects do not overlap + if state_type_to_randomize == "body_link_pos_w": + state_dict[state_type_to_randomize][..., :2] += origins.unsqueeze(1)[..., :2] + + # perform simulation + for _ in range(5): + body_pose = torch.cat( + [state_dict["body_link_pos_w"], state_dict["body_link_quat_w"]], + dim=-1, + ) + body_vel = torch.cat( + [state_dict["body_com_lin_vel_w"], state_dict["body_com_ang_vel_w"]], + dim=-1, + ) + # reset object state + object_collection.write_body_link_pose_to_sim_index(body_poses=body_pose) + object_collection.write_body_com_velocity_to_sim_index(body_velocities=body_vel) + sim.step() + + # assert that set object quantities are equal to the ones set in the state_dict + for key, expected_value in state_dict.items(): + value = getattr(object_collection.data, key).torch + torch.testing.assert_close(value, expected_value, rtol=1e-5, atol=1e-5) + + object_collection.update(sim.cfg.dt) + + +@pytest.mark.parametrize("num_envs", [1, 4]) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("with_offset", [True, False]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_object_state_properties(num_envs, num_cubes, device, with_offset, gravity_enabled): + """Test the object_com_state_w and object_link_state_w properties.""" + with _ovphysx_sim_context(device=device, gravity_enabled=gravity_enabled, auto_add_lighting=True) as sim: + cube_object, env_pos = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, height=0.0, device=device) + env_ids = torch.tensor([x for x in range(num_envs)], dtype=torch.int32) + + sim.reset() + + # check if cube_object is initialized + assert cube_object.is_initialized + + # change center of mass offset from link frame + offset = ( + torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) + if with_offset + else torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) + ) + + # Read current COMs, mutate the translation, write back via the OVPhysX + # ``set_coms_index`` setter (PhysX uses ``root_view.set_coms`` + reshape helpers + # for the same operation; OVPhysX wraps the wheel RIGID_BODY_COM_POSE write in + # :meth:`set_coms_index`). + com = cube_object.data.body_com_pose_b.torch.clone() # shape (num_envs, num_cubes, 7) + com[..., :3] = offset.to(com.device) + cube_object.set_coms_index( + coms=wp.from_torch(com.contiguous(), dtype=wp.transformf), + env_ids=wp.from_torch(env_ids, dtype=wp.int32), + ) + + # check center of mass has been set + torch.testing.assert_close(cube_object.data.body_com_pose_b.torch, com) + + # random z spin velocity + spin_twist = torch.zeros(6, device=device) + spin_twist[5] = torch.randn(1, device=device) + + # initial spawn point + init_com = cube_object.data.body_com_pose_w.torch[..., :3] + + for i in range(10): + # spin the object around Z axis (com) + cube_object.write_body_com_velocity_to_sim_index(body_velocities=spin_twist.repeat(num_envs, num_cubes, 1)) + sim.step() + cube_object.update(sim.cfg.dt) + + # get state properties + object_link_pose_w = cube_object.data.body_link_pose_w.torch + object_link_vel_w = cube_object.data.body_link_vel_w.torch + object_com_pose_w = cube_object.data.body_com_pose_w.torch + object_com_vel_w = cube_object.data.body_com_vel_w.torch + + # if offset is [0,0,0] all object_state_%_w will match and all body_%_w will match + if not with_offset: + torch.testing.assert_close(object_link_pose_w, object_com_pose_w) + torch.testing.assert_close(object_com_vel_w, object_link_vel_w) + else: + # cubes are spinning around center of mass + # position will not match + # center of mass position will be constant (i.e. spinning around com) + torch.testing.assert_close(init_com, object_com_pose_w[..., :3]) + + # link position will be moving but should stay constant away from center of mass + object_link_state_pos_rel_com = quat_apply_inverse( + object_link_pose_w[..., 3:], + object_link_pose_w[..., :3] - object_com_pose_w[..., :3], + ) + + torch.testing.assert_close(-offset, object_link_state_pos_rel_com) + + # orientation of com will be a constant rotation from link orientation + com_quat_b = cube_object.data.body_com_quat_b.torch + com_quat_w = quat_mul(object_link_pose_w[..., 3:], com_quat_b) + torch.testing.assert_close(com_quat_w, object_com_pose_w[..., 3:]) + + # orientation of link will match object state will always match + torch.testing.assert_close(object_link_pose_w[..., 3:], object_link_pose_w[..., 3:]) + + # lin_vel will not match + # center of mass vel will be constant (i.e. spinning around com) + torch.testing.assert_close( + torch.zeros_like(object_com_vel_w[..., :3]), + object_com_vel_w[..., :3], + ) + + # link frame will be moving, and should be equal to input angular velocity cross offset + lin_vel_rel_object_gt = quat_apply_inverse(object_link_pose_w[..., 3:], object_link_vel_w[..., :3]) + lin_vel_rel_gt = torch.linalg.cross(spin_twist.repeat(num_envs, num_cubes, 1)[..., 3:], -offset) + torch.testing.assert_close(lin_vel_rel_gt, lin_vel_rel_object_gt, atol=1e-4, rtol=1e-3) + + # ang_vel will always match + torch.testing.assert_close(object_com_vel_w[..., 3:], object_com_vel_w[..., 3:]) + torch.testing.assert_close(object_com_vel_w[..., 3:], object_link_vel_w[..., 3:]) + + +@pytest.mark.parametrize("num_envs", [1, 3]) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("with_offset", [True, False]) +@pytest.mark.parametrize("state_location", ["com", "link"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_write_object_state(num_envs, num_cubes, device, with_offset, state_location, gravity_enabled): + """Test the setters for object_state using both the link frame and center of mass as reference frame.""" + with _ovphysx_sim_context(device=device, gravity_enabled=gravity_enabled, auto_add_lighting=True) as sim: + # Create a scene with random cubes + cube_object, env_pos = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, height=0.0, device=device) + env_ids = torch.tensor([x for x in range(num_envs)], dtype=torch.int32) + object_ids = torch.tensor([x for x in range(num_cubes)], dtype=torch.int32) + + sim.reset() + + # Check if cube_object is initialized + assert cube_object.is_initialized + + # change center of mass offset from link frame + offset = ( + torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) + if with_offset + else torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) + ) + + com = cube_object.data.body_com_pose_b.torch.clone() # shape (num_envs, num_cubes, 7) + com[..., :3] = offset.to(com.device) + cube_object.set_coms_index( + coms=wp.from_torch(com.contiguous(), dtype=wp.transformf), + env_ids=wp.from_torch(env_ids, dtype=wp.int32), + ) + # check center of mass has been set + torch.testing.assert_close(cube_object.data.body_com_pose_b.torch, com) + + rand_state = torch.zeros(num_envs, num_cubes, 13, device=device) + rand_state[..., :7] = cube_object.data.default_body_pose.torch + rand_state[..., :3] += cube_object.data.body_link_pos_w.torch + # make quaternion a unit vector + rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) + + env_ids = env_ids.to(device) + object_ids = object_ids.to(device) + for i in range(10): + sim.step() + cube_object.update(sim.cfg.dt) + + if state_location == "com": + if i % 2 == 0: + cube_object.write_body_com_pose_to_sim_index(body_poses=rand_state[..., :7]) + cube_object.write_body_com_velocity_to_sim_index(body_velocities=rand_state[..., 7:]) + else: + cube_object.write_body_com_pose_to_sim_index( + body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids + ) + cube_object.write_body_com_velocity_to_sim_index( + body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids + ) + elif state_location == "link": + if i % 2 == 0: + cube_object.write_body_link_pose_to_sim_index(body_poses=rand_state[..., :7]) + cube_object.write_body_link_velocity_to_sim_index(body_velocities=rand_state[..., 7:]) + else: + cube_object.write_body_link_pose_to_sim_index( + body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids + ) + cube_object.write_body_link_velocity_to_sim_index( + body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids + ) + + if state_location == "com": + torch.testing.assert_close(rand_state[..., :7], cube_object.data.body_com_pose_w.torch) + torch.testing.assert_close(rand_state[..., 7:], cube_object.data.body_com_vel_w.torch) + elif state_location == "link": + torch.testing.assert_close(rand_state[..., :7], cube_object.data.body_link_pose_w.torch) + torch.testing.assert_close(rand_state[..., 7:], cube_object.data.body_link_vel_w.torch) + + +@pytest.mark.parametrize("num_envs", [1, 3]) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_reset_object_collection(num_envs, num_cubes, device): + """Test resetting the state of the rigid object.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + object_collection, _ = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) + sim.reset() + + for i in range(5): + sim.step() + object_collection.update(sim.cfg.dt) + + # Move the object to a random position + body_pose = object_collection.data.default_body_pose.torch.clone() + body_pose[..., :3] = torch.randn(num_envs, num_cubes, 3, device=sim.device) + # Random orientation + body_pose[..., 3:7] = random_orientation(num=num_cubes, device=sim.device) + object_collection.write_body_link_pose_to_sim_index(body_poses=body_pose) + body_vel = object_collection.data.default_body_vel.torch.clone() + object_collection.write_body_com_velocity_to_sim_index(body_velocities=body_vel) + + if i % 2 == 0: + object_collection.reset() + + # Reset should zero external forces and torques + assert not object_collection._instantaneous_wrench_composer.active + assert not object_collection._permanent_wrench_composer.active + assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.composed_force.torch) == 0 + assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.composed_torque.torch) == 0 + assert torch.count_nonzero(object_collection._permanent_wrench_composer.composed_force.torch) == 0 + assert torch.count_nonzero(object_collection._permanent_wrench_composer.composed_torque.torch) == 0 + + +@pytest.mark.xfail(reason=_MATERIAL_GAP_REASON, strict=False) +@pytest.mark.parametrize("num_envs", [1, 3]) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_set_material_properties(num_envs, num_cubes, device): + """Test getting and setting material properties of rigid object.""" + raise NotImplementedError(_MATERIAL_GAP_REASON) + + +@pytest.mark.parametrize("num_envs", [1, 3]) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("gravity_enabled", [True, False]) +@pytest.mark.isaacsim_ci +def test_gravity_vec_w(num_envs, num_cubes, device, gravity_enabled): + """Test that gravity vector direction is set correctly for the rigid object.""" + with _ovphysx_sim_context(device=device, gravity_enabled=gravity_enabled, auto_add_lighting=True) as sim: + object_collection, _ = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) + + # Obtain gravity direction + gravity_dir = (0.0, 0.0, -1.0) if gravity_enabled else (0.0, 0.0, 0.0) + + sim.reset() + + # Check if gravity vector is set correctly + gravity_vec = object_collection.data.GRAVITY_VEC_W.torch + assert gravity_vec[0, 0, 0] == gravity_dir[0] + assert gravity_vec[0, 0, 1] == gravity_dir[1] + assert gravity_vec[0, 0, 2] == gravity_dir[2] + + # Perform simulation + for _ in range(2): + sim.step() + object_collection.update(sim.cfg.dt) + + # Expected gravity value is the acceleration of the body + gravity = torch.zeros(num_envs, num_cubes, 6, device=device) + if gravity_enabled: + gravity[..., 2] = -9.81 + + # Check the body accelerations are correct + torch.testing.assert_close(object_collection.data.body_com_acc_w.torch, gravity) + + +@pytest.mark.parametrize("num_envs", [1, 3]) +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("with_offset", [True]) +@pytest.mark.parametrize("state_location", ["com", "link", "root"]) +@pytest.mark.parametrize("gravity_enabled", [False]) +@pytest.mark.isaacsim_ci +def test_write_object_state_functions_data_consistency( + num_envs, num_cubes, device, with_offset, state_location, gravity_enabled +): + """Test the setters for object_state using both the link frame and center of mass as reference frame.""" + with _ovphysx_sim_context(device=device, gravity_enabled=gravity_enabled, auto_add_lighting=True) as sim: + # Create a scene with random cubes + cube_object, env_pos = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, height=0.0, device=device) + env_ids = torch.tensor([x for x in range(num_envs)], dtype=torch.int32) + object_ids = torch.tensor([x for x in range(num_cubes)], dtype=torch.int32) + + sim.reset() + + # Check if cube_object is initialized + assert cube_object.is_initialized + + # change center of mass offset from link frame + offset = ( + torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) + if with_offset + else torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) + ) + + com = cube_object.data.body_com_pose_b.torch.clone() # shape (num_envs, num_cubes, 7) + com[..., :3] = offset.to(com.device) + cube_object.set_coms_index( + coms=wp.from_torch(com.contiguous(), dtype=wp.transformf), + env_ids=wp.from_torch(env_ids, dtype=wp.int32), + ) + + # check center of mass has been set + torch.testing.assert_close(cube_object.data.body_com_pose_b.torch, com) + + rand_state = torch.rand(num_envs, num_cubes, 13, device=device) + rand_state[..., :3] += cube_object.data.body_link_pos_w.torch + # make quaternion a unit vector + rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) + + env_ids = env_ids.to(device) + object_ids = object_ids.to(device) + sim.step() + cube_object.update(sim.cfg.dt) + + body_link_pose_w = cube_object.data.body_link_pose_w.torch + body_com_pose_w = cube_object.data.body_com_pose_w.torch + object_link_to_com_pos, object_link_to_com_quat = subtract_frame_transforms( + body_link_pose_w[..., :3].view(-1, 3), + body_link_pose_w[..., 3:7].view(-1, 4), + body_com_pose_w[..., :3].view(-1, 3), + body_com_pose_w[..., 3:7].view(-1, 4), + ) + + if state_location == "com": + cube_object.write_body_com_pose_to_sim_index( + body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids + ) + cube_object.write_body_com_velocity_to_sim_index( + body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids + ) + elif state_location == "link": + cube_object.write_body_link_pose_to_sim_index( + body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids + ) + cube_object.write_body_link_velocity_to_sim_index( + body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids + ) + elif state_location == "root": + cube_object.write_body_link_pose_to_sim_index( + body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids + ) + cube_object.write_body_com_velocity_to_sim_index( + body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids + ) + + if state_location == "com": + com_pose_w = cube_object.data.body_com_pose_w.torch + com_vel_w = cube_object.data.body_com_vel_w.torch + expected_root_link_pos, expected_root_link_quat = combine_frame_transforms( + com_pose_w[..., :3].view(-1, 3), + com_pose_w[..., 3:].view(-1, 4), + quat_rotate(quat_inv(object_link_to_com_quat), -object_link_to_com_pos), + quat_inv(object_link_to_com_quat), + ) + expected_object_link_pose = torch.cat((expected_root_link_pos, expected_root_link_quat), dim=1).view( + num_envs, -1, 7 + ) + link_pose_w = cube_object.data.body_link_pose_w.torch + link_vel_w = cube_object.data.body_link_vel_w.torch + # test both root_pose and root_link successfully updated when root_com updates + torch.testing.assert_close(expected_object_link_pose, link_pose_w) + # skip lin_vel because it differs from link frame, this should be fine because we are only checking + # if velocity update is triggered, which can be determined by comparing angular velocity + torch.testing.assert_close(com_vel_w[..., 3:], link_vel_w[..., 3:]) + torch.testing.assert_close(expected_object_link_pose, link_pose_w) + torch.testing.assert_close(com_vel_w[..., 3:], cube_object.data.body_com_vel_w.torch[..., 3:]) + elif state_location == "link": + link_pose_w = cube_object.data.body_link_pose_w.torch + link_vel_w = cube_object.data.body_link_vel_w.torch + expected_com_pos, expected_com_quat = combine_frame_transforms( + link_pose_w[..., :3].view(-1, 3), + link_pose_w[..., 3:].view(-1, 4), + object_link_to_com_pos, + object_link_to_com_quat, + ) + expected_object_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1).view(num_envs, -1, 7) + com_pose_w = cube_object.data.body_com_pose_w.torch + com_vel_w = cube_object.data.body_com_vel_w.torch + # test both root_pose and root_com successfully updated when root_link updates + torch.testing.assert_close(expected_object_com_pose, com_pose_w) + # skip lin_vel because it differs from link frame, this should be fine because we are only checking + # if velocity update is triggered, which can be determined by comparing angular velocity + torch.testing.assert_close(link_vel_w[..., 3:], com_vel_w[..., 3:]) + torch.testing.assert_close(link_pose_w, cube_object.data.body_link_pose_w.torch) + torch.testing.assert_close(link_vel_w[..., 3:], cube_object.data.body_com_vel_w.torch[..., 3:]) + elif state_location == "root": + body_link_pose_w = cube_object.data.body_link_pose_w.torch + body_com_vel_w = cube_object.data.body_com_vel_w.torch + expected_object_com_pos, expected_object_com_quat = combine_frame_transforms( + body_link_pose_w[..., :3].view(-1, 3), + body_link_pose_w[..., 3:].view(-1, 4), + object_link_to_com_pos, + object_link_to_com_quat, + ) + expected_object_com_pose = torch.cat((expected_object_com_pos, expected_object_com_quat), dim=1).view( + num_envs, -1, 7 + ) + com_pose_w = cube_object.data.body_com_pose_w.torch + com_vel_w = cube_object.data.body_com_vel_w.torch + link_pose_w = cube_object.data.body_link_pose_w.torch + link_vel_w = cube_object.data.body_link_vel_w.torch + # test both root_com and root_link successfully updated when root_pose updates + torch.testing.assert_close(expected_object_com_pose, com_pose_w) + torch.testing.assert_close(body_com_vel_w, com_vel_w) + torch.testing.assert_close(body_link_pose_w, link_pose_w) + torch.testing.assert_close(body_com_vel_w[..., 3:], link_vel_w[..., 3:]) From dda47bc723f82e9449e967669aced2dfce487426 Mon Sep 17 00:00:00 2001 From: Julia von Muralt Date: Tue, 19 May 2026 14:56:47 +0200 Subject: [PATCH 108/133] Actuators integration (#5455) 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 --------- Co-authored-by: Antoine Richard Co-authored-by: Kelly Guo --- ...toiner-refactor-pr5455-followups.minor.rst | 22 + .../assets/articulation/articulation_cfg.py | 24 +- .../assets/articulation/base_articulation.py | 3 + source/isaaclab/isaaclab/assets/asset_base.py | 6 +- .../isaaclab/assets/asset_base_cfg.py | 16 +- .../isaaclab/isaaclab/envs/direct_marl_env.py | 36 +- .../isaaclab/isaaclab/envs/direct_rl_env.py | 36 +- .../isaaclab/envs/manager_based_env.py | 36 +- .../isaaclab/envs/manager_based_rl_env.py | 32 +- source/isaaclab/isaaclab/envs/mdp/events.py | 57 +- .../isaaclab/physics/physics_manager.py | 23 + .../isaaclab/sim/schemas/__init__.pyi | 4 + .../isaaclab/sim/schemas/schemas_actuators.py | 389 +++++ .../isaaclab/isaaclab/sim/simulation_cfg.py | 16 + .../antoiner-refactor-pr5455-followups.rst | 11 + .../isaaclab_newton/actuators/__init__.py | 36 + .../isaaclab_newton/actuators/adapter.py | 456 ++++++ .../isaaclab_newton/actuators/kernels.py | 197 +++ .../actuators/physx_wrapper.py | 60 + .../assets/articulation/articulation.py | 519 ++++-- .../assets/articulation/articulation_data.py | 2 + .../isaaclab_newton/physics/kamino_manager.py | 8 +- .../isaaclab_newton/physics/newton_manager.py | 352 +++- .../views/mock_articulation_view.py | 11 +- source/isaaclab_newton/setup.py | 1 + .../assets/test_newton_actuators_newton.py | 1438 +++++++++++++++++ .../antoiner-refactor-pr5455-followups.rst | 8 + .../assets/articulation/articulation.py | 836 ++++++---- .../assets/test_newton_actuators_physx.py | 1100 +++++++++++++ 29 files changed, 5141 insertions(+), 594 deletions(-) create mode 100644 source/isaaclab/changelog.d/antoiner-refactor-pr5455-followups.minor.rst create mode 100644 source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py create mode 100644 source/isaaclab_newton/changelog.d/antoiner-refactor-pr5455-followups.rst create mode 100644 source/isaaclab_newton/isaaclab_newton/actuators/__init__.py create mode 100644 source/isaaclab_newton/isaaclab_newton/actuators/adapter.py create mode 100644 source/isaaclab_newton/isaaclab_newton/actuators/kernels.py create mode 100644 source/isaaclab_newton/isaaclab_newton/actuators/physx_wrapper.py create mode 100644 source/isaaclab_newton/test/assets/test_newton_actuators_newton.py create mode 100644 source/isaaclab_physx/changelog.d/antoiner-refactor-pr5455-followups.rst create mode 100644 source/isaaclab_physx/test/assets/test_newton_actuators_physx.py diff --git a/source/isaaclab/changelog.d/antoiner-refactor-pr5455-followups.minor.rst b/source/isaaclab/changelog.d/antoiner-refactor-pr5455-followups.minor.rst new file mode 100644 index 000000000000..b1dff35671d8 --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-refactor-pr5455-followups.minor.rst @@ -0,0 +1,22 @@ +Added +^^^^^ + +* Added :func:`~isaaclab.sim.schemas.define_actuator_properties` to author + ``NewtonActuator`` USD prims from IsaacLab actuator configs. Lives alongside + the other ``define_*_properties`` schema writers and is invoked from the + schema-side post-spawn hook below. +* Added :meth:`~isaaclab.assets.AssetBaseCfg._post_spawn` hook (no-op by + default) invoked by :class:`~isaaclab.assets.AssetBase` after spawning the + asset. :class:`~isaaclab.assets.ArticulationCfg` overrides it to author + Newton-native actuator prims from :attr:`~isaaclab.assets.ArticulationCfg.actuators`. + +Changed +^^^^^^^ + +* :class:`~isaaclab.assets.BaseArticulation` no longer imports + ``isaaclab_newton`` from its ``__init__``. Newton-native actuator authoring + now flows through the generic ``_post_spawn`` hook on + :class:`~isaaclab.assets.AssetBaseCfg`. +* :meth:`~isaaclab.physics.PhysicsManager.set_decimation` now has an explicit + ``pass`` body so the base class is consistent with the other no-op + classmethods. diff --git a/source/isaaclab/isaaclab/assets/articulation/articulation_cfg.py b/source/isaaclab/isaaclab/assets/articulation/articulation_cfg.py index b7514fb8a91c..2124e02aee77 100644 --- a/source/isaaclab/isaaclab/assets/articulation/articulation_cfg.py +++ b/source/isaaclab/isaaclab/assets/articulation/articulation_cfg.py @@ -6,7 +6,7 @@ from __future__ import annotations from dataclasses import MISSING -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from isaaclab.actuators import ActuatorBaseCfg from isaaclab.utils.configclass import configclass @@ -74,3 +74,25 @@ class InitialStateCfg(AssetBaseCfg.InitialStateCfg): actuator_value_resolution_debug_print = False """Print the resolution of actuator final value when input cfg is different from USD value, Defaults to False """ + + def _post_spawn(self, stage: Any) -> None: + """Author ``NewtonActuator`` USD prims from :attr:`actuators` after spawn. + + Invoked by :class:`~isaaclab.assets.AssetBase` once the articulation's prims + exist on the stage. Delegates to + :func:`~isaaclab.sim.schemas.define_actuator_properties`, which gates itself + on ``sim_cfg.use_newton_actuators`` and silently no-ops when the simulation + is not configured for Newton-native actuators. + """ + if self.actuators is MISSING: + return + from isaaclab.sim.schemas.schemas_actuators import define_actuator_properties # noqa: PLC0415 + + # In InteractiveScene, articulated assets are often spawned first under + # a template path (for example ``/World/template/Robot``) and cloned + # into ``{ENV_REGEX_NS}`` later. Author NewtonActuator prims on the + # actual spawned source prim so clones inherit them. + author_prim_path = ( + self.spawn.spawn_path if self.spawn is not None and self.spawn.spawn_path is not None else self.prim_path + ) + define_actuator_properties(author_prim_path, self.actuators, stage=stage) diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py index ff3f99e8a024..76e831bdad17 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py @@ -16,6 +16,7 @@ import torch import warp as wp +from ...sim import SimulationContext from ...utils.leapp.leapp_semantics import OutputKindEnum, joint_names_resolver, leapp_tensor_semantics from ..asset_base import AssetBase @@ -95,6 +96,8 @@ def __init__(self, cfg: ArticulationCfg): cfg: A configuration instance. """ super().__init__(cfg) + sim_ctx = SimulationContext.instance() + self._sim_cfg = sim_ctx.cfg if sim_ctx is not None else None """ Properties diff --git a/source/isaaclab/isaaclab/assets/asset_base.py b/source/isaaclab/isaaclab/assets/asset_base.py index b72788083ad3..3031debbca2c 100644 --- a/source/isaaclab/isaaclab/assets/asset_base.py +++ b/source/isaaclab/isaaclab/assets/asset_base.py @@ -15,6 +15,8 @@ import torch import warp as wp +from pxr import Usd + import isaaclab.sim as sim_utils from isaaclab.physics import PhysicsEvent, PhysicsManager from isaaclab.sim.simulation_context import SimulationContext @@ -79,7 +81,7 @@ def __init__(self, cfg: AssetBaseCfg): # flag for whether the asset is initialized self._is_initialized = False # get stage handle - self.stage = get_current_stage() + self.stage: Usd.Stage = get_current_stage() # spawn the asset # determine path where prims should exist after spawn @@ -96,6 +98,8 @@ def __init__(self, cfg: AssetBaseCfg): 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}.") + # schema-side post-spawn hook (e.g. ArticulationCfg authors NewtonActuator prims here) + self.cfg._post_spawn(self.stage) else: # asset should exist at run time check_path = self.cfg.prim_path diff --git a/source/isaaclab/isaaclab/assets/asset_base_cfg.py b/source/isaaclab/isaaclab/assets/asset_base_cfg.py index 095bb6afd9cd..a284ba394125 100644 --- a/source/isaaclab/isaaclab/assets/asset_base_cfg.py +++ b/source/isaaclab/isaaclab/assets/asset_base_cfg.py @@ -6,7 +6,7 @@ from __future__ import annotations from dataclasses import MISSING -from typing import Literal +from typing import Any, Literal from isaaclab.sim import SpawnerCfg from isaaclab.utils.configclass import configclass @@ -88,3 +88,17 @@ class InitialStateCfg: When ``None`` (the default), shape checks follow Python's ``__debug__`` flag — enabled in normal mode, disabled with ``python -O``. """ + + def _post_spawn(self, stage: Any) -> None: + """Hook invoked by :class:`~isaaclab.assets.AssetBase` after the asset's prims are + spawned and verified to exist on the stage. + + The default implementation is a no-op. Subclasses that need to author additional + USD schemas tied to this asset (for example, :class:`~isaaclab.assets.ArticulationCfg` + which authors ``NewtonActuator`` prims from its ``actuators`` mapping) should + override this method. + + Args: + stage: The USD stage on which the asset was spawned. + """ + pass diff --git a/source/isaaclab/isaaclab/envs/direct_marl_env.py b/source/isaaclab/isaaclab/envs/direct_marl_env.py index 56d3a38cdd1c..824dda6d0150 100644 --- a/source/isaaclab/isaaclab/envs/direct_marl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_marl_env.py @@ -92,6 +92,7 @@ def __init__(self, cfg: DirectMARLEnvCfg, render_mode: str | None = None, **kwar self.render_mode = render_mode # initialize internal variables self._is_closed = False + self._physics_handles_decimation = False # set the seed for the environment if self.cfg.seed is not None: @@ -197,6 +198,10 @@ def _init_sim(self, render_mode: str | None = None, **kwargs): # this shouldn't cause an issue since later on, users do a reset over all the environments # so the lazy buffers would be reset. self.scene.update(dt=self.physics_dt) + # let the physics backend know about the env decimation so it can + # fold the full loop into a single step() when possible + self.sim.physics_manager.set_decimation(self.cfg.decimation) + self._physics_handles_decimation = self.sim.physics_manager.handles_decimation() # check if debug visualization is has been implemented by the environment source_code = inspect.getsource(self._set_debug_vis_impl) @@ -433,21 +438,32 @@ def step(self, actions: dict[AgentID, ActionType]) -> EnvStepReturn: is_rendering = self.sim.is_rendering # perform physics stepping - for _ in range(self.cfg.decimation): - self._sim_step_counter += 1 - # set actions into buffers + if self._physics_handles_decimation: + self._sim_step_counter += self.cfg.decimation self._apply_action() - # set actions into simulator self.scene.write_data_to_sim() - # simulate self.sim.step(render=False) - # render between steps only if the GUI or an RTX sensor needs it. - # When render_enabled is False, Kit visualizer (camera/GUI) is skipped - # but standalone visualizers (Newton, Rerun, Viser) still update. + # render only when a render_interval boundary falls within this decimation block, + # mirroring the per-sub-step check in the else branch. if self._sim_step_counter % self.cfg.sim.render_interval == 0 and is_rendering: self.sim.render(skip_app_pumping=not self.render_enabled) - # update buffers at sim dt - self.scene.update(dt=self.physics_dt) + self.scene.update(dt=self.step_dt) + else: + for _ in range(self.cfg.decimation): + self._sim_step_counter += 1 + # set actions into buffers + self._apply_action() + # set actions into simulator + self.scene.write_data_to_sim() + # simulate + self.sim.step(render=False) + # render between steps only if the GUI or an RTX sensor needs it. + # When render_enabled is False, Kit visualizer (camera/GUI) is skipped + # but standalone visualizers (Newton, Rerun, Viser) still update. + if self._sim_step_counter % self.cfg.sim.render_interval == 0 and is_rendering: + self.sim.render(skip_app_pumping=not self.render_enabled) + # update buffers at sim dt + self.scene.update(dt=self.physics_dt) # post-step: # -- update env counters (used for curriculum generation) diff --git a/source/isaaclab/isaaclab/envs/direct_rl_env.py b/source/isaaclab/isaaclab/envs/direct_rl_env.py index c03ca1f73596..650568bbb1e2 100644 --- a/source/isaaclab/isaaclab/envs/direct_rl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_rl_env.py @@ -97,6 +97,7 @@ def __init__(self, cfg: DirectRLEnvCfg, render_mode: str | None = None, **kwargs self.render_mode = render_mode # initialize internal variables self._is_closed = False + self._physics_handles_decimation = False # set the seed for the environment if self.cfg.seed is not None: @@ -203,6 +204,10 @@ def _init_sim(self, render_mode: str | None = None, **kwargs): # this shouldn't cause an issue since later on, users do a reset over all the environments # so the lazy buffers would be reset. self.scene.update(dt=self.physics_dt) + # let the physics backend know about the env decimation so it can + # fold the full loop into a single step() when possible + self.sim.physics_manager.set_decimation(self.cfg.decimation) + self._physics_handles_decimation = self.sim.physics_manager.handles_decimation() # check if debug visualization is has been implemented by the environment source_code = inspect.getsource(self._set_debug_vis_impl) @@ -424,21 +429,32 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: is_rendering = self.sim.is_rendering # perform physics stepping - for _ in range(self.cfg.decimation): - self._sim_step_counter += 1 - # set actions into buffers + if self._physics_handles_decimation: + self._sim_step_counter += self.cfg.decimation self._apply_action() - # set actions into simulator self.scene.write_data_to_sim() - # simulate self.sim.step(render=False) - # render between steps only if the GUI or an RTX sensor needs it. - # When render_enabled is False, Kit visualizer (camera/GUI) is skipped - # but standalone visualizers (Newton, Rerun, Viser) still update. + # render only when a render_interval boundary falls within this decimation block, + # mirroring the per-sub-step check in the else branch. if self._sim_step_counter % self.cfg.sim.render_interval == 0 and is_rendering: self.sim.render(skip_app_pumping=not self.render_enabled) - # update buffers at sim dt - self.scene.update(dt=self.physics_dt) + self.scene.update(dt=self.step_dt) + else: + for _ in range(self.cfg.decimation): + self._sim_step_counter += 1 + # set actions into buffers + self._apply_action() + # set actions into simulator + self.scene.write_data_to_sim() + # simulate + self.sim.step(render=False) + # render between steps only if the GUI or an RTX sensor needs it. + # When render_enabled is False, Kit visualizer (camera/GUI) is skipped + # but standalone visualizers (Newton, Rerun, Viser) still update. + if self._sim_step_counter % self.cfg.sim.render_interval == 0 and is_rendering: + self.sim.render(skip_app_pumping=not self.render_enabled) + # update buffers at sim dt + self.scene.update(dt=self.physics_dt) # post-step: # -- update env counters (used for curriculum generation) diff --git a/source/isaaclab/isaaclab/envs/manager_based_env.py b/source/isaaclab/isaaclab/envs/manager_based_env.py index 620cf2895718..f89101dae072 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env.py @@ -94,6 +94,7 @@ def __init__(self, cfg: ManagerBasedEnvCfg): self.cfg = cfg # initialize internal variables self._is_closed = False + self._physics_handles_decimation = False # set the seed for the environment if self.cfg.seed is not None: @@ -216,6 +217,10 @@ def _init_sim(self): # this shouldn't cause an issue since later on, users do a reset over all the environments # so the lazy buffers would be reset. self.scene.update(dt=self.physics_dt) + # let the physics backend know about the env decimation so it can + # fold the full loop into a single step() when possible + self.sim.physics_manager.set_decimation(self.cfg.decimation) + self._physics_handles_decimation = self.sim.physics_manager.handles_decimation() # add timeline event to load managers self.load_managers() @@ -530,21 +535,32 @@ def step(self, action: torch.Tensor) -> tuple[VecEnvObs, dict]: is_rendering = self.sim.is_rendering # perform physics stepping - for _ in range(self.cfg.decimation): - self._sim_step_counter += 1 - # set actions into buffers + if self._physics_handles_decimation: + self._sim_step_counter += self.cfg.decimation self.action_manager.apply_action() - # set actions into simulator self.scene.write_data_to_sim() - # simulate self.sim.step(render=False) - # render between steps only if the GUI or an RTX sensor needs it. - # When render_enabled is False, Kit visualizer (camera/GUI) is skipped - # but standalone visualizers (Newton, Rerun, Viser) still update. + # render only when a render_interval boundary falls within this decimation block, + # mirroring the per-sub-step check in the else branch. if self._sim_step_counter % self.cfg.sim.render_interval == 0 and is_rendering: self.sim.render(skip_app_pumping=not self.render_enabled) - # update buffers at sim dt - self.scene.update(dt=self.physics_dt) + self.scene.update(dt=self.step_dt) + else: + for _ in range(self.cfg.decimation): + self._sim_step_counter += 1 + # set actions into buffers + self.action_manager.apply_action() + # set actions into simulator + self.scene.write_data_to_sim() + # simulate + self.sim.step(render=False) + # render between steps only if the GUI or an RTX sensor needs it. + # When render_enabled is False, Kit visualizer (camera/GUI) is skipped + # but standalone visualizers (Newton, Rerun, Viser) still update. + if self._sim_step_counter % self.cfg.sim.render_interval == 0 and is_rendering: + self.sim.render(skip_app_pumping=not self.render_enabled) + # update buffers at sim dt + self.scene.update(dt=self.physics_dt) # post-step: step interval event if "interval" in self.event_manager.available_modes: diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index 9060e7874c32..48966261a577 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -199,22 +199,34 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: is_rendering = self.sim.is_rendering # perform physics stepping - for _ in range(self.cfg.decimation): - self._sim_step_counter += 1 - # set actions into buffers + if self._physics_handles_decimation: + self._sim_step_counter += self.cfg.decimation self.action_manager.apply_action() - # set actions into simulator self.scene.write_data_to_sim() - # simulate self.sim.step(render=False) self.recorder_manager.record_post_physics_decimation_step() - # render between steps only if the GUI or an RTX sensor needs it. - # When render_enabled is False, Kit visualizer (camera/GUI) is skipped - # but standalone visualizers (Newton, Rerun, Viser) still update. + # render only when a render_interval boundary falls within this decimation block, + # mirroring the per-sub-step check in the else branch. if self._sim_step_counter % self.cfg.sim.render_interval == 0 and is_rendering: self.sim.render(skip_app_pumping=not self.render_enabled) - # update buffers at sim dt - self.scene.update(dt=self.physics_dt) + self.scene.update(dt=self.step_dt) + else: + for _ in range(self.cfg.decimation): + self._sim_step_counter += 1 + # set actions into buffers + self.action_manager.apply_action() + # set actions into simulator + self.scene.write_data_to_sim() + # simulate + self.sim.step(render=False) + self.recorder_manager.record_post_physics_decimation_step() + # render between steps only if the GUI or an RTX sensor needs it. + # When render_enabled is False, Kit visualizer (camera/GUI) is skipped + # but standalone visualizers (Newton, Rerun, Viser) still update. + if self._sim_step_counter % self.cfg.sim.render_interval == 0 and is_rendering: + self.sim.render(skip_app_pumping=not self.render_enabled) + # update buffers at sim dt + self.scene.update(dt=self.physics_dt) # post-step: # -- update env counters (used for curriculum generation) diff --git a/source/isaaclab/isaaclab/envs/mdp/events.py b/source/isaaclab/isaaclab/envs/mdp/events.py index 9483d2d2d0cd..f430c105ee78 100644 --- a/source/isaaclab/isaaclab/envs/mdp/events.py +++ b/source/isaaclab/isaaclab/envs/mdp/events.py @@ -1135,13 +1135,22 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv): self.default_joint_stiffness = self.asset.data.joint_stiffness.torch.clone() self.default_joint_damping = self.asset.data.joint_damping.torch.clone() - # For explicit actuators the sim-level stiffness/damping is zeroed out, so patch - # the defaults with the actual actuator PD gains. + # For explicit Lab actuators the sim-level stiffness/damping is zeroed out, + # so patch the defaults with the actual actuator PD gains. for actuator in self.asset.actuators.values(): if not isinstance(actuator, ImplicitActuator): joint_ids = actuator.joint_indices self.default_joint_stiffness[:, joint_ids] = actuator.stiffness self.default_joint_damping[:, joint_ids] = actuator.damping + # Same for explicit Newton actuators on either backend — their kp/kd + # live on the per-actuator controller arrays (not on a Lab Actuator + # object), so the asset exposes a per-articulation snapshot taken + # at articulation init time. + newton_default_stiffness = getattr(self.asset, "newton_default_stiffness", None) + if newton_default_stiffness is not None: + joint_ids = self.asset.newton_managed_local_joints + self.default_joint_stiffness[:, joint_ids] = newton_default_stiffness[:, joint_ids] + self.default_joint_damping[:, joint_ids] = self.asset.newton_default_damping[:, joint_ids] # check for valid operation if cfg.params["operation"] == "scale": @@ -1221,6 +1230,50 @@ def randomize(data: torch.Tensor, params: tuple[float, float]) -> torch.Tensor: damping=damping, joint_ids=actuator.joint_indices, env_ids=env_ids ) + # Push DR updates to explicit Newton-actuator controllers via the asset's + # own write methods. Each backend's articulation iterates the adapter's + # actuators and propagates per actuator, using the appropriate backend + # mechanism (Newton ``ArticulationView`` on the Newton backend, an + # in-place scatter kernel on PhysX). + if not hasattr(self.asset, "write_actuator_stiffness_to_sim"): + return + + if isinstance(self.asset_cfg.joint_ids, slice): + joint_ids = torch.arange(self.asset.num_joints, device=self.asset.device, dtype=torch.long) + else: + joint_ids = torch.tensor(self.asset_cfg.joint_ids, device=self.asset.device, dtype=torch.long) + + if stiffness_distribution_params is not None: + new_stiffness = self.default_joint_stiffness[env_ids][:, joint_ids].clone() + _randomize_prop_by_op( + new_stiffness, + stiffness_distribution_params, + dim_0_ids=None, + dim_1_ids=slice(None), + operation=operation, + distribution=distribution, + ) + self.asset.write_actuator_stiffness_to_sim( + stiffness=new_stiffness, + env_ids=env_ids, + joint_ids=joint_ids, + ) + if damping_distribution_params is not None: + new_damping = self.default_joint_damping[env_ids][:, joint_ids].clone() + _randomize_prop_by_op( + new_damping, + damping_distribution_params, + dim_0_ids=None, + dim_1_ids=slice(None), + operation=operation, + distribution=distribution, + ) + self.asset.write_actuator_damping_to_sim( + damping=new_damping, + env_ids=env_ids, + joint_ids=joint_ids, + ) + class randomize_joint_parameters(ManagerTermBase): """Randomize the simulated joint parameters of an articulation by adding, scaling, or setting random values. diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 6727d87174b6..3f63dfa01c60 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -346,6 +346,29 @@ def wait_for_playing(cls) -> None: """Block until the timeline is playing. Default is no-op.""" pass + @classmethod + def set_decimation(cls, decimation: int) -> None: + """Inform the physics backend how many substeps the environment runs per policy step. + + Backends that can fold the full decimation loop into a single + :meth:`step` call (e.g. Newton with all-graphable actuators) use this + to size their internal loop / CUDA graph. The default implementation + is a no-op. + + Args: + decimation: Number of physics steps per environment step. + """ + pass + + @classmethod + def handles_decimation(cls) -> bool: + """``True`` when :meth:`step` executes the full decimation loop internally. + + When this returns ``True`` the environment should call :meth:`step` + once per policy step instead of looping ``decimation`` times. + """ + return False + @classmethod def get_backend(cls) -> str: """Get the tensor backend being used ("numpy" or "torch").""" diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index fd0c882a5f35..a9306afdf414 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -8,6 +8,7 @@ __all__ = [ "PHYSX_MESH_COLLISION_CFGS", "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", + "define_actuator_properties", "define_articulation_root_properties", "define_collision_properties", "define_mass_properties", @@ -58,6 +59,9 @@ from .schemas import ( modify_rigid_body_properties, modify_spatial_tendon_properties, ) +from .schemas_actuators import ( + define_actuator_properties, +) from .schemas_cfg import ( ArticulationRootBaseCfg, BoundingCubePropertiesCfg, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py b/source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py new file mode 100644 index 000000000000..40456384e473 --- /dev/null +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py @@ -0,0 +1,389 @@ +# 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 + +"""USD schema authoring for Newton-native actuators. + +:func:`define_actuator_properties` translates IsaacLab actuator configs +into ``NewtonActuator`` USD prims. Both the Newton ``ModelBuilder.add_usd`` +path and the PhysX adapter's +:meth:`~isaaclab_newton.actuators.adapter.NewtonActuatorAdapter.from_usd` +read the same authored prims, ensuring both backends construct +:class:`~newton.actuators.Actuator` instances with matching parameters. + +This module lives on the schema side so that authoring is a regular +``define_*_properties`` step in the spawner pipeline, alongside +:func:`define_articulation_root_properties` and friends, rather than a +side effect of asset construction. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +logger = logging.getLogger(__name__) + + +def resolve_per_dof( + value: dict[str, float | int] | float | int | None, + joint_names: list[str], + cast: type = float, +) -> dict[str, float | int]: + """Expand a scalar or regex-keyed dict cfg value into a per-joint mapping. + + Used by :func:`define_actuator_properties` to flatten the various + accepted forms of a per-DOF config field (``stiffness``, ``damping``, + ``effort_limit``, …) into a single ``{joint_name: value}`` dict that + the authoring loop can ``.get(jname, default)`` against. + + Accepted forms of *value*: + + * ``None`` → empty dict. + * scalar (``int`` / ``float``) → broadcast to every joint name. + * dict → keys are treated as regex patterns and matched against + *joint_names* via :func:`re.fullmatch`. The first matching pattern + wins per joint name. + """ + if value is None: + return {} + if isinstance(value, (int, float)): + return {name: cast(value) for name in joint_names} + if isinstance(value, dict): + result: dict[str, float | int] = {} + for name in joint_names: + for pattern, v in value.items(): + if re.fullmatch(pattern, name): + result[name] = cast(v) + break + return result + return {} + + +def define_actuator_properties( + prim_path: str, + actuator_cfgs: dict[str, Any], + stage: Any | None = None, +) -> None: + """Author ``NewtonActuator`` USD prims under an articulation root. + + For every joint covered by an explicit (non-implicit) Lab actuator + config, any existing ``NewtonActuator`` prim targeting that joint is + replaced by a new one created from the config values. Joints **not** + covered by any Lab config keep their USD-authored actuators unchanged. + + The supported config-to-schema mapping is: + + * :class:`~isaaclab.actuators.IdealPDActuatorCfg` → + ``NewtonPDControlAPI`` + ``NewtonMaxEffortClampingAPI`` + * :class:`~isaaclab.actuators.DCMotorCfg` → + ``NewtonPDControlAPI`` + ``NewtonDCMotorClampingAPI`` + * :class:`~isaaclab.actuators.DelayedPDActuatorCfg` → + same as ``IdealPDActuatorCfg`` + ``NewtonActuatorDelayAPI`` + * :class:`~isaaclab.actuators.RemotizedPDActuatorCfg` → + same as ``DelayedPDActuatorCfg`` + ``NewtonPositionBasedClampingAPI`` + * :class:`~isaaclab.actuators.ActuatorNetMLPCfg` / + :class:`~isaaclab.actuators.ActuatorNetLSTMCfg` → + ``NewtonNeuralControlAPI`` (+ ``NewtonDCMotorClampingAPI``) + + No-ops (returns immediately) when: + + * the active :class:`~isaaclab.sim.SimulationContext` was configured + with ``use_newton_actuators=False`` (or no context is active), or + * *prim_path* does not resolve to a valid prim on the stage. + + Must be called **after** the articulation is spawned (joint prims + exist on stage) and **before** the cloner / ``ModelBuilder.add_usd`` + reads the stage. + + Args: + prim_path: Root prim path of the articulation (e.g. + ``"/World/Env_0/Robot"``). May contain a regex pattern; the + first matching prim is used. + actuator_cfgs: Mapping of group name to + :class:`~isaaclab.actuators.ActuatorBaseCfg`. + stage: USD stage to author on. When ``None``, the current stage + is used. + """ + from isaaclab.sim import SimulationContext # noqa: PLC0415 + + sim_ctx = SimulationContext.instance() + sim_cfg = sim_ctx.cfg if sim_ctx is not None else None + if sim_cfg is None or not getattr(sim_cfg, "use_newton_actuators", False): + return + + from isaaclab.sim.utils.queries import find_first_matching_prim # noqa: PLC0415 + from isaaclab.sim.utils.stage import get_current_stage # noqa: PLC0415 + + if stage is None: + stage = get_current_stage() + + first_prim = find_first_matching_prim(prim_path) + if first_prim is None: + return + articulation_prim_path = str(first_prim.GetPath()) + + _author_actuator_prims(stage, articulation_prim_path, actuator_cfgs) + + +def _author_actuator_prims( + stage: Any, + articulation_prim_path: str, + actuator_cfgs: dict[str, Any], +) -> None: + """Inner authoring routine; exposed separately for test fixtures.""" + from pxr import Sdf # noqa: PLC0415 + + from isaaclab.actuators import ImplicitActuator # noqa: PLC0415 + from isaaclab.utils.string import resolve_matching_names # noqa: PLC0415 + + art_prim = stage.GetPrimAtPath(articulation_prim_path) + if not art_prim.IsValid(): + raise ValueError(f"Articulation prim not found: {articulation_prim_path}") + + joint_inventory = _collect_joint_prims(art_prim) + all_joint_names = list(joint_inventory.keys()) + + covered_joint_paths: set[str] = set() + + cfg_entries: list[tuple[str, Any, list[str]]] = [] + for group_name, cfg in actuator_cfgs.items(): + cls_type = cfg.class_type + is_implicit = ( + "ImplicitActuator" in cls_type if isinstance(cls_type, str) else issubclass(cls_type, ImplicitActuator) + ) + if is_implicit: + continue + + _ids, joint_names = resolve_matching_names(cfg.joint_names_expr, all_joint_names) + if not joint_names: + continue + + cfg_entries.append((group_name, cfg, joint_names)) + for jname in joint_names: + covered_joint_paths.add(joint_inventory[jname]) + + _remove_actuator_prims_for_joints(art_prim, covered_joint_paths) + + from isaaclab.actuators import DCMotorCfg, DelayedPDActuatorCfg # noqa: PLC0415 + from isaaclab.actuators.actuator_net_cfg import ActuatorNetLSTMCfg, ActuatorNetMLPCfg # noqa: PLC0415 + from isaaclab.actuators.actuator_pd_cfg import IdealPDActuatorCfg, RemotizedPDActuatorCfg # noqa: PLC0415 + + _SUPPORTED_CFG_TYPES = ( + IdealPDActuatorCfg, + DCMotorCfg, + DelayedPDActuatorCfg, + RemotizedPDActuatorCfg, + ActuatorNetMLPCfg, + ActuatorNetLSTMCfg, + ) + + for group_name, cfg, joint_names in cfg_entries: + if not isinstance(cfg, _SUPPORTED_CFG_TYPES): + logger.warning( + "Actuator group '%s' uses config type '%s' which is not supported by Newton-native" + " actuator authoring. The group will be skipped.", + group_name, + type(cfg).__name__, + ) + continue + stiffness_map = resolve_per_dof(getattr(cfg, "stiffness", None), joint_names) + damping_map = resolve_per_dof(getattr(cfg, "damping", None), joint_names) + effort_map = resolve_per_dof(getattr(cfg, "effort_limit", None), joint_names) + + is_neural = isinstance(cfg, (ActuatorNetMLPCfg, ActuatorNetLSTMCfg)) + is_remotized = isinstance(cfg, RemotizedPDActuatorCfg) + is_dc_motor = isinstance(cfg, DCMotorCfg) + is_delayed = isinstance(cfg, DelayedPDActuatorCfg) + + vel_limit_map = resolve_per_dof(getattr(cfg, "velocity_limit", None), joint_names) if is_dc_motor else {} + sat_effort_map = resolve_per_dof(getattr(cfg, "saturation_effort", None), joint_names) if is_dc_motor else {} + + raw_delay = getattr(cfg, "max_delay", 0) if is_delayed else 0 + delay_map = resolve_per_dof(raw_delay, joint_names, cast=int) if raw_delay else {} + + patched_model_path: str | None = None + if is_neural: + meta: dict[str, Any] = {} + if isinstance(cfg, ActuatorNetMLPCfg): + meta["model_type"] = "mlp" + meta["input_order"] = cfg.input_order + meta["input_idx"] = list(cfg.input_idx) + meta["pos_scale"] = cfg.pos_scale + meta["vel_scale"] = cfg.vel_scale + meta["torque_scale"] = cfg.torque_scale + else: + meta["model_type"] = "lstm" + patched_model_path = _resave_checkpoint_with_metadata(cfg.network_file, meta) + + for jname in joint_names: + joint_prim_path = joint_inventory[jname] + + schemas: list[str] = [] + attrs: dict[str, float | int] = {} + array_attrs: dict[str, list[float]] = {} + + if is_neural: + schemas.append("NewtonNeuralControlAPI") + else: + schemas.append("NewtonPDControlAPI") + attrs["kp"] = stiffness_map.get(jname, 0.0) + attrs["kd"] = damping_map.get(jname, 0.0) + + if is_dc_motor: + schemas.append("NewtonDCMotorClampingAPI") + attrs["saturation_effort"] = sat_effort_map.get(jname, 0.0) + if jname in vel_limit_map: + attrs["velocity_limit"] = vel_limit_map[jname] + if jname in effort_map: + attrs["max_motor_effort"] = effort_map[jname] + elif jname in effort_map: + schemas.append("NewtonMaxEffortClampingAPI") + attrs["max_effort"] = effort_map[jname] + + if is_remotized and isinstance(cfg, RemotizedPDActuatorCfg): + lookup = cfg.joint_parameter_lookup + schemas.append("NewtonPositionBasedClampingAPI") + array_attrs["lookup_positions"] = [row[0] for row in lookup] + array_attrs["lookup_efforts"] = [row[2] for row in lookup] + + delay_steps = delay_map.get(jname, 0) + if delay_steps > 0: + schemas.append("NewtonActuatorDelayAPI") + attrs["delay_steps"] = delay_steps + attrs["max_delay"] = delay_steps + + act_prim_path = f"{articulation_prim_path}/{group_name}_{jname}_actuator" + act_prim = stage.DefinePrim(act_prim_path, "NewtonActuator") + + existing = act_prim.GetMetadata("apiSchemas") or Sdf.TokenListOp() + existing.prependedItems = list(schemas) + act_prim.SetMetadata("apiSchemas", existing) + + rel = act_prim.CreateRelationship("newton:targets") + rel.SetTargets([Sdf.Path(joint_prim_path)]) + + if patched_model_path is not None: + act_prim.CreateAttribute("newton:modelPath", Sdf.ValueTypeNames.Asset).Set( + Sdf.AssetPath(patched_model_path) + ) + + for attr_name, attr_val in attrs.items(): + usd_name = f"newton:{_snake_to_camel(attr_name)}" + if isinstance(attr_val, int): + act_prim.CreateAttribute(usd_name, Sdf.ValueTypeNames.Int).Set(attr_val) + else: + act_prim.CreateAttribute(usd_name, Sdf.ValueTypeNames.Float).Set(float(attr_val)) + + for attr_name, attr_val in array_attrs.items(): + usd_name = f"newton:{_snake_to_camel(attr_name)}" + act_prim.CreateAttribute(usd_name, Sdf.ValueTypeNames.FloatArray).Set(attr_val) + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +_SNAKE_TO_CAMEL_RE = re.compile(r"_([a-z])") + + +def _snake_to_camel(name: str) -> str: + """Convert a snake_case name to camelCase.""" + return _SNAKE_TO_CAMEL_RE.sub(lambda m: m.group(1).upper(), name) + + +def _collect_joint_prims(art_prim: Any) -> dict[str, str]: + """Collect all joint prims under an articulation subtree. + + Returns: + Ordered mapping of joint name to full prim path. + """ + from pxr import Usd # noqa: PLC0415 + + _JOINT_TYPES = {"PhysicsRevoluteJoint", "PhysicsPrismaticJoint"} + + joints: dict[str, str] = {} + for prim in Usd.PrimRange(art_prim): + if prim.GetTypeName() in _JOINT_TYPES: + joints[prim.GetName()] = str(prim.GetPath()) + return joints + + +def _remove_actuator_prims_for_joints( + art_prim: Any, + joint_paths: set[str], +) -> None: + """Deactivate ``NewtonActuator`` prims whose target is in *joint_paths*. + + Deactivated prims are invisible to ``Usd.PrimRange`` and therefore + ignored by ``ModelBuilder.add_usd``. Using ``SetActive(False)`` + instead of ``RemovePrim`` works correctly when the prim originates + from a USD reference or payload. + + Only prims under the *art_prim* subtree are considered. + """ + from pxr import Usd # noqa: PLC0415 + + to_deactivate: list = [] + for prim in Usd.PrimRange(art_prim): + if prim.GetTypeName() != "NewtonActuator": + continue + rel = prim.GetRelationship("newton:targets") + if rel and rel.IsValid(): + for target in rel.GetTargets(): + if str(target) in joint_paths: + to_deactivate.append(prim) + break + + for prim in to_deactivate: + prim.SetActive(False) + + +def _resave_checkpoint_with_metadata( + original_path: str, + metadata: dict[str, Any], +) -> str: + """Re-save a neural-network checkpoint with updated metadata. + + Loads the original TorchScript or dict checkpoint, merges *metadata* + into any existing metadata (Lab config values take precedence), and + writes the result to a temporary ``.pt`` file that persists for the + lifetime of the process. + + Returns: + Path to the temporary checkpoint file. + """ + import json # noqa: PLC0415 + import tempfile # noqa: PLC0415 + + import torch # noqa: PLC0415 + + extra_files: dict[str, str] = {"metadata.json": ""} + is_torchscript = True + try: + net = torch.jit.load(original_path, map_location="cpu", _extra_files=extra_files) + existing_meta = json.loads(extra_files["metadata.json"]) if extra_files["metadata.json"] else {} + except Exception: + is_torchscript = False + checkpoint = torch.load(original_path, map_location="cpu", weights_only=False) + if not isinstance(checkpoint, dict) or "model" not in checkpoint: + raise ValueError( + f"Cannot load checkpoint at '{original_path}'; " + "expected a TorchScript archive or a dict with a 'model' key" + ) + net = checkpoint["model"] + existing_meta = checkpoint.get("metadata", {}) + + merged = {**existing_meta, **metadata} + + with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as tmp: + tmp_path = tmp.name + if is_torchscript: + extra_out = {"metadata.json": json.dumps(merged)} + torch.jit.save(net, tmp_path, _extra_files=extra_out) + else: + torch.save({"model": net, "metadata": merged}, tmp_path) + + return tmp_path diff --git a/source/isaaclab/isaaclab/sim/simulation_cfg.py b/source/isaaclab/isaaclab/sim/simulation_cfg.py index 8abed4cbfc17..2c71c724ad3d 100644 --- a/source/isaaclab/isaaclab/sim/simulation_cfg.py +++ b/source/isaaclab/isaaclab/sim/simulation_cfg.py @@ -288,6 +288,22 @@ class SimulationCfg: with the GUI enabled. This is to allow certain GUI features to work properly. """ + use_newton_actuators: bool = False + """Use Newton-native actuators instead of IsaacLab explicit actuator models. + + When ``True``, explicit actuator configs (e.g. :class:`IdealPDActuatorCfg`, + :class:`DCMotorCfg`) are translated into ``NewtonActuator`` USD prims and + stepped by the physics engine. The Lab config values (stiffness, damping, + effort_limit, etc.) take precedence: for every joint covered by a Lab + actuator config, any existing ``NewtonActuator`` prim targeting that joint + is replaced by one synthesised from the config. Joints that are *not* + covered by a Lab config keep their USD-authored actuators (if any). + + :class:`ImplicitActuatorCfg` entries are still instantiated normally and + their gains are written to the simulation, so joints that use implicit + actuation continue to work as expected. + """ + physics: PhysicsCfg | None = None """Physics manager configuration. Default is None (uses PhysxCfg()). diff --git a/source/isaaclab_newton/changelog.d/antoiner-refactor-pr5455-followups.rst b/source/isaaclab_newton/changelog.d/antoiner-refactor-pr5455-followups.rst new file mode 100644 index 000000000000..d340938c97b4 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/antoiner-refactor-pr5455-followups.rst @@ -0,0 +1,11 @@ +Changed +^^^^^^^ + +* Moved Newton-native actuator USD authoring out of + ``isaaclab_newton.actuators.authoring`` (now deleted) into + :func:`~isaaclab.sim.schemas.define_actuator_properties`. The authoring step + is now invoked via the schema-side ``_post_spawn`` hook on + :class:`~isaaclab.assets.AssetBaseCfg`. +* Grouped :attr:`~isaaclab_newton.physics.NewtonManager._decimation` next to + :attr:`~isaaclab_newton.physics.NewtonManager._num_substeps` for consistency + with related solver-stepping configuration. diff --git a/source/isaaclab_newton/isaaclab_newton/actuators/__init__.py b/source/isaaclab_newton/isaaclab_newton/actuators/__init__.py new file mode 100644 index 000000000000..aacc07f96666 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/actuators/__init__.py @@ -0,0 +1,36 @@ +# 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-native actuator integration for Isaac Lab. + +Public API surface: + +* :class:`~isaaclab_newton.actuators.adapter.NewtonActuatorAdapter` — + the actuator adapter used by both backends. The Newton backend + constructs it directly from ``model.actuators``; the PhysX backend + uses :meth:`~NewtonActuatorAdapter.from_usd` to build the actuators + from authored ``NewtonActuator`` USD prims. +* :class:`~isaaclab_newton.actuators.physx_wrapper.PhysxActuatorWrapper` + — flat-array wrapper that satisfies the Newton actuator + ``sim_state`` / ``sim_control`` protocol on the PhysX backend. +* :func:`~isaaclab_newton.actuators.kernels.build_implicit_dof_mask` — + builds the per-DOF implicit-actuator mask consumed by the in-graph + post-actuator kernel. + +USD authoring lives on the schema side as +:func:`~isaaclab.sim.schemas.define_actuator_properties`; both backends +call into it via :meth:`ArticulationCfg._post_spawn`. +""" + +from .adapter import NewtonActuatorAdapter, build_newton_actuator_defaults +from .kernels import build_implicit_dof_mask +from .physx_wrapper import PhysxActuatorWrapper + +__all__ = [ + "NewtonActuatorAdapter", + "PhysxActuatorWrapper", + "build_implicit_dof_mask", + "build_newton_actuator_defaults", +] diff --git a/source/isaaclab_newton/isaaclab_newton/actuators/adapter.py b/source/isaaclab_newton/isaaclab_newton/actuators/adapter.py new file mode 100644 index 000000000000..0d28cf576dfb --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/actuators/adapter.py @@ -0,0 +1,456 @@ +# 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-actuator adapter shared by the Newton and PhysX backends. + +Owns the actuator-state lifecycle, the pre-clamp computed-effort buffer, +and the per-step ``step`` / ``reset`` / ``finalize`` calls. The +:meth:`~NewtonActuatorAdapter.from_usd` classmethod parses +``NewtonActuator`` USD prims on the PhysX backend (Newton populates +``model.actuators`` itself). + +DR gain updates bypass the adapter — the articulation writes straight +to controller arrays. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import numpy as np +import torch +import warp as wp +from newton.actuators import Actuator, Clamping, Delay + +from .kernels import ( + build_per_dof_env_mask_kernel, + scatter_gain_kernel, + set_mask_kernel, + zero_at_indices_kernel, +) + +# --------------------------------------------------------------------------- +# Abstract base — backend-independent logic +# --------------------------------------------------------------------------- + + +class NewtonActuatorAdapter: + """Adapter that wraps a list of :class:`newton.actuators.Actuator`. + + Owns the actuator-state lifecycle, DOF-to-actuator bookkeeping, + stepping, reset, and the pre-clamp computed-effort buffer the + in-graph telemetry kernel reads on the post-actuator hook. + """ + + def __init__( + self, + actuators: list[Actuator], + num_envs: int, + num_joints: int, + dof_offset: int, + device: str, + ): + self.actuators = actuators + self.num_joints = num_joints + + self._num_envs = num_envs + self._dof_offset = dof_offset + self._device = device + + # Collect the set of local DOFs covered by some actuator. Only the + # env-0 slice of each actuator's flat ``indices`` array is needed — + # later envs are repeats with a constant ``num_joints`` stride. + managed: set[int] = set() + for act in actuators: + all_indices = act.indices.numpy() + num_per_act = len(all_indices) // num_envs + for global_dof in all_indices[:num_per_act]: + local_dof = global_dof - dof_offset + if 0 <= local_dof < num_joints: + managed.add(local_dof) + + if len(managed) == num_joints: + self.joint_indices: torch.Tensor | slice = slice(None) + else: + self.joint_indices = torch.tensor(sorted(managed), dtype=torch.int32, device=device) + + self._states_a = [act.state() for act in actuators] + self._states_b = [act.state() for act in actuators] + + # Pre-clamp computed effort buffer. Each Newton actuator scatter-adds + # its raw controller output to ``sim_control.joint_computed_f`` when + # ``control_computed_output_attr`` is set; we route that to this + # buffer so the post-actuator telemetry kernel can report the actual + # computed (pre-clamp) effort instead of mirroring ``joint_f``. The + # binding onto ``sim_control`` happens in :meth:`finalize`. + self._computed_effort = wp.zeros( + num_envs * num_joints, + dtype=wp.float32, + device=device, + ) + self.computed_effort_2d = self._computed_effort.reshape((num_envs, num_joints)) + for act in actuators: + act.control_computed_output_attr = "joint_computed_f" + + def finalize(self, sim_control: Any) -> None: + """Bind the pre-clamp computed-effort buffer onto ``sim_control``. + + Args: + sim_control: The ``sim_control`` object that will be passed + to :meth:`step` for this adapter's lifetime. Newton's + ``Control`` on the Newton backend, an + :class:`~isaaclab_newton.actuators.physx_wrapper.PhysxActuatorWrapper` + on the PhysX backend. + """ + sim_control.joint_computed_f = self._computed_effort + + def step(self, sim_state: Any, sim_control: Any, dt: float) -> None: + """Zero actuated DOFs, step all actuators, and swap state buffers. + + Args: + sim_state: Object with ``joint_q``, ``joint_qd``, etc. + Newton ``State`` on the Newton backend, + :class:`~isaaclab_newton.actuators.physx_wrapper.PhysxActuatorWrapper` + on the PhysX backend. + sim_control: Object with ``joint_f``, ``joint_target_pos``, etc. + Newton ``Control`` on the Newton backend, + :class:`~isaaclab_newton.actuators.physx_wrapper.PhysxActuatorWrapper` + on the PhysX backend. + dt: Physics timestep [s]. + """ + # Zero before scatter-add (actuators accumulate into this buffer). + self._computed_effort.zero_() + for act in self.actuators: + wp.launch( + zero_at_indices_kernel, + dim=act.indices.shape[0], + inputs=[sim_control.joint_f, act.indices], + ) + for act, sa, sb in zip(self.actuators, self._states_a, self._states_b): + act.step(sim_state, sim_control, sa, sb, dt=dt) + self._states_a, self._states_b = self._states_b, self._states_a + + def reset(self, env_ids: Sequence[int] | torch.Tensor | None = None) -> None: + """Reset actuator states for the given environments. + + Args: + env_ids: Environment indices to reset. ``None`` (or + ``slice(None)``, which IsaacLab callers sometimes pass) + resets all environments. Otherwise expects a torch tensor + or sequence of int indices. + + Newton's :meth:`Actuator.State.reset` expects a per-DOF boolean + mask of length ``num_actuators`` (= ``num_envs * dofs_per_actuator``), + not a per-env mask — each entry gates the corresponding column of + the actuator's state buffers (delay queue, controller integral, + etc.). We therefore build a per-actuator per-DOF mask from the + env mask before delegating to each state. + """ + if env_ids is None or env_ids == slice(None): + for sa, sb in zip(self._states_a, self._states_b): + if sa is not None: + sa.reset(None) + if sb is not None: + sb.reset(None) + return + + if isinstance(env_ids, torch.Tensor): + if env_ids.numel() == 0: + return + idx = wp.from_torch(env_ids.to(device=self._device).contiguous().to(torch.int32), dtype=wp.int32) + else: + if len(env_ids) == 0: + return + idx = wp.array(list(env_ids), dtype=wp.int32, device=self._device) + env_mask = wp.zeros(self._num_envs, dtype=wp.bool, device=self._device) + wp.launch(set_mask_kernel, dim=idx.shape[0], inputs=[env_mask, idx], device=self._device) + + for act, sa, sb in zip(self.actuators, self._states_a, self._states_b): + per_dof_mask = wp.zeros(act.indices.shape[0], dtype=wp.bool, device=self._device) + wp.launch( + build_per_dof_env_mask_kernel, + dim=act.indices.shape[0], + inputs=[act.indices, env_mask, self._dof_offset, self.num_joints, per_dof_mask], + device=self._device, + ) + if sa is not None: + sa.reset(per_dof_mask) + if sb is not None: + sb.reset(per_dof_mask) + + @property + def is_all_graphable(self) -> bool: + """``True`` when all actuators are CUDA-graph-safe.""" + return len(self.actuators) > 0 and all(a.is_graphable() for a in self.actuators) + + @classmethod + def from_usd( + cls, + stage: Any, + joint_names: list[str], + num_envs: int, + num_joints: int, + device: str, + articulation_prim_path: str | None = None, + ) -> NewtonActuatorAdapter: + """Build an adapter from ``NewtonActuator`` prims authored on *stage*. + + PhysX-side counterpart of Newton's ``ModelBuilder.add_usd``: reads + the same prims and constructs matching + :class:`~newton.actuators.Actuator` objects. Joints with the same + controller, gains, clamping, and delay are merged into one + Actuator with combined indices. Used on the PhysX backend only — + Newton populates ``model.actuators`` itself. + + Args: + stage: The USD stage containing ``NewtonActuator`` prims. + joint_names: All joint names in the articulation. + num_envs: Number of environments. + num_joints: Joints per environment. + device: Warp device string (e.g. ``"cuda:0"``). + articulation_prim_path: Root prim path of env 0's + articulation. When set, only prims under this subtree are + considered; otherwise the whole stage is scanned. + """ + actuators = _create_actuators_from_usd( + stage, + joint_names, + num_envs, + num_joints, + device, + articulation_prim_path=articulation_prim_path, + ) + return cls(actuators, num_envs, num_joints, dof_offset=0, device=device) + + +# --------------------------------------------------------------------------- +# Per-articulation initial-gain snapshot — consumed by +# ``randomize_actuator_gains`` to seed ``default_joint_*`` baselines. +# --------------------------------------------------------------------------- + + +def build_newton_actuator_defaults( + actuators: list[Actuator], + num_envs: int, + num_joints: int, + dof_offset: int, + device: str, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | slice]: + """Snapshot the initial kp/kd of every Newton actuator owned by one articulation. + + Filters *actuators* to those whose env-0 DOF lives in + ``[dof_offset, dof_offset + num_joints)`` (a no-op on PhysX where the + adapter is already per-articulation; meaningful on Newton where the + global adapter holds actuators from every articulation), then + scatter-gathers their ``controller.kp`` / ``controller.kd`` into + contiguous ``(num_envs, num_joints)`` torch tensors and records which + articulation-local joints they cover. + + Args: + actuators: All Newton actuators visible to this articulation. + num_envs: Number of environments. + num_joints: Articulation-local joint count. + dof_offset: Offset of this articulation's DOFs in the env-major + global index space (``0`` on PhysX, view-dependent on Newton). + device: Warp device string (e.g. ``"cuda:0"``). + + Returns: + Tuple of ``(stiffness, damping, joint_indices)``: + + * ``stiffness``: Initial kp values, ``(num_envs, num_joints)``, articulation-local. + * ``damping``: Initial kd values, ``(num_envs, num_joints)``, articulation-local. + * ``joint_indices``: Articulation-local joint positions covered by + the adapter's actuators. ``slice(None)`` when every joint is + covered, otherwise an int32 tensor of column indices. + """ + arti_actuators = [act for act in actuators if dof_offset <= int(act.indices.numpy()[0]) < dof_offset + num_joints] + + managed_local: set[int] = set() + for act in arti_actuators: + per_act = act.indices.shape[0] // num_envs + for global_dof in act.indices.numpy()[:per_act]: + local = int(global_dof) - dof_offset + if 0 <= local < num_joints: + managed_local.add(local) + joint_indices: torch.Tensor | slice + if len(managed_local) == num_joints: + joint_indices = slice(None) + else: + joint_indices = torch.tensor(sorted(managed_local), dtype=torch.int32, device=device) + + wp_device = wp.get_device(device) + flat_stiffness = wp.zeros(num_envs * num_joints, dtype=wp.float32, device=wp_device) + flat_damping = wp.zeros(num_envs * num_joints, dtype=wp.float32, device=wp_device) + for act in arti_actuators: + ctrl = act.controller + if hasattr(ctrl, "kp"): + wp.launch( + scatter_gain_kernel, + dim=act.indices.shape[0], + inputs=[ctrl.kp, flat_stiffness, act.indices, dof_offset, num_joints], + device=wp_device, + ) + if hasattr(ctrl, "kd"): + wp.launch( + scatter_gain_kernel, + dim=act.indices.shape[0], + inputs=[ctrl.kd, flat_damping, act.indices, dof_offset, num_joints], + device=wp_device, + ) + stiffness = wp.to_torch(flat_stiffness.reshape((num_envs, num_joints))) + damping = wp.to_torch(flat_damping.reshape((num_envs, num_joints))) + return stiffness, damping, joint_indices + + +# --------------------------------------------------------------------------- +# PhysX-only USD parsing +# --------------------------------------------------------------------------- + + +def _actuator_signature(parsed: Any) -> tuple: + """Build a hashable key from a parsed actuator spec for grouping. + + Joints whose prims resolve to the same signature share identical + controller type, gains, clamping chain, and delay configuration and + can therefore be merged into a single :class:`~newton.actuators.Actuator` + with combined index arrays. + """ + ctrl_resolved = parsed.controller_class.resolve_arguments( + dict(parsed.controller_kwargs), + ) + ctrl_key = (parsed.controller_class, tuple(sorted(ctrl_resolved.items()))) + + comp_keys: list[tuple] = [] + for comp_cls, comp_kwargs in parsed.component_specs: + resolved = comp_cls.resolve_arguments(comp_kwargs) + comp_keys.append((comp_cls, tuple(sorted(resolved.items())))) + comp_keys.sort(key=lambda t: t[0].__name__) + + return (ctrl_key, tuple(comp_keys)) + + +def _create_actuators_from_usd( + stage: Any, + joint_names: list[str], + num_envs: int, + num_total_joints: int, + device: str, + articulation_prim_path: str | None = None, +) -> list[Actuator]: + """Parse ``NewtonActuator`` prims and instantiate standalone actuators. + + This mirrors the actuator construction that Newton's + ``ModelBuilder.add_usd`` performs, but operates independently of a + Newton ``Model``. It is used on the PhysX backend where there is no + Newton simulation — actuators are stepped manually via the adapter. + + Because PhysX articulations have no free or ball joints, every + joint's coordinate count equals its DOF count. A single + ``indices`` array is therefore sufficient for all index roles + (``indices``, ``pos_indices``, ``target_pos_indices``). + + Joints with identical controller type, gains, clamping chain, and + delay are merged into one :class:`Actuator` with combined indices. + + Each per-DOF scalar parameter (``kp``, ``kd``, ``saturation_effort``, + etc.) is broadcast via :func:`wp.full` to match the group size. + Parameters marked as ``SHARED_PARAMS`` on the controller or clamping + class (e.g. ``model_path``, ``lookup_positions``) are passed through + directly without broadcast. + """ + from collections import defaultdict # noqa: PLC0415 + + from newton.actuators import parse_actuator_prim # noqa: PLC0415 + + from pxr import Usd # noqa: PLC0415 + + wp_device = wp.get_device(device) + + joint_name_to_idx: dict[str, int] = {name: i for i, name in enumerate(joint_names)} + + if articulation_prim_path is not None: + root_prim = stage.GetPrimAtPath(articulation_prim_path) + else: + root_prim = stage.GetPseudoRoot() + + parsed_per_joint: dict[int, Any] = {} + for prim in Usd.PrimRange(root_prim): + parsed = parse_actuator_prim(prim) + if parsed is None: + continue + target_name = parsed.target_path.rsplit("/", 1)[-1] + if target_name in joint_name_to_idx: + parsed_per_joint[joint_name_to_idx[target_name]] = parsed + + if not parsed_per_joint: + raise ValueError(f"No NewtonActuator prims found targeting any of: {joint_names}") + + groups: dict[tuple, list[int]] = defaultdict(list) + sig_to_parsed: dict[tuple, Any] = {} + for local_idx, parsed in sorted(parsed_per_joint.items()): + sig = _actuator_signature(parsed) + groups[sig].append(local_idx) + if sig not in sig_to_parsed: + sig_to_parsed[sig] = parsed + + actuators = [] + for sig, local_indices in groups.items(): + parsed = sig_to_parsed[sig] + + flat_indices = np.array( + [idx + e * num_total_joints for e in range(num_envs) for idx in local_indices], + dtype=np.uint32, + ) + indices = wp.array(flat_indices, device=wp_device) + num_dofs_in_group = len(local_indices) * num_envs + + # Controller + ctrl_kwargs = dict(parsed.controller_kwargs) + resolved = parsed.controller_class.resolve_arguments(ctrl_kwargs) + shared_ctrl = getattr(parsed.controller_class, "SHARED_PARAMS", set()) + ctrl_arrays = {} + for key, val in resolved.items(): + if key in shared_ctrl: + ctrl_arrays[key] = val + else: + ctrl_arrays[key] = wp.full(num_dofs_in_group, float(val), dtype=wp.float32, device=wp_device) + controller = parsed.controller_class(**ctrl_arrays) + + # Components (delay + clampings) + clampings = [] + delay = None + for comp_cls, comp_kwargs in parsed.component_specs: + if issubclass(comp_cls, Delay): + resolved_kw = Delay.resolve_arguments(comp_kwargs) + delay_steps = int(resolved_kw.get("delay_steps", 0)) + if delay_steps > 0: + delay_arr = wp.full(num_dofs_in_group, delay_steps, dtype=wp.int32, device=wp_device) + delay = Delay(delay_steps=delay_arr, max_delay=delay_steps) + elif issubclass(comp_cls, Clamping): + resolved_kw = comp_cls.resolve_arguments(comp_kwargs) + shared_clamp = getattr(comp_cls, "SHARED_PARAMS", set()) + clamp_arrays = {} + for k, v in resolved_kw.items(): + if k in shared_clamp: + clamp_arrays[k] = v + else: + clamp_arrays[k] = wp.full( + num_dofs_in_group, + float(v), + dtype=wp.float32, + device=wp_device, + ) + clampings.append(comp_cls(**clamp_arrays)) + + actuator = Actuator( + indices=indices, + controller=controller, + delay=delay, + clamping=clampings if clampings else None, + ) + actuators.append(actuator) + + return actuators diff --git a/source/isaaclab_newton/isaaclab_newton/actuators/kernels.py b/source/isaaclab_newton/isaaclab_newton/actuators/kernels.py new file mode 100644 index 000000000000..8205773b640a --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/actuators/kernels.py @@ -0,0 +1,197 @@ +# 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 Warp kernels for the Newton actuator fast path.""" + +import torch +import warp as wp + +from isaaclab.actuators import ActuatorBase, ImplicitActuator + +# --------------------------------------------------------------------------- +# Adapter / per-actuator helper kernels: per-DOF zeroing, env-mask building, +# per-DOF env-mask projection (used by :meth:`NewtonActuatorAdapter.reset`), +# and a partial scatter for DR gain updates that overwrites only the cells +# in a (env_ids × joint_ids) sub-grid of a Newton ``Actuator``'s controller +# parameter array. Used on the PhysX backend (no Newton view available); +# the Newton backend uses ``ArticulationView.set_actuator_parameter`` instead. +# --------------------------------------------------------------------------- + + +@wp.kernel(enable_backward=False) +def zero_at_indices_kernel(data: wp.array(dtype=wp.float32), indices: wp.array(dtype=wp.uint32)): + """Zero a flat ``data`` buffer at the given flat ``indices``.""" + i = wp.tid() + data[indices[i]] = 0.0 + + +@wp.kernel(enable_backward=False) +def set_mask_kernel(mask: wp.array(dtype=wp.bool), indices: wp.array(dtype=wp.int32)): + """Set ``mask[indices[i]] = True`` for each ``i``. The mask must be pre-zeroed.""" + i = wp.tid() + mask[indices[i]] = True + + +@wp.kernel(enable_backward=False) +def build_per_dof_env_mask_kernel( + indices: wp.array(dtype=wp.uint32), + env_mask: wp.array(dtype=wp.bool), + dof_offset: int, + num_joints: int, + out_mask: wp.array(dtype=wp.bool), +): + """Build a per-DOF mask from a per-env mask, for one Newton actuator. + + Newton's :meth:`Actuator.State.reset` expects a mask of length + ``num_actuators`` (= ``num_envs * dofs_per_actuator``). Each entry + gates the corresponding column of the actuator's state buffers. This + kernel maps a per-env boolean mask onto that per-DOF layout via the + actuator's flat ``indices``. + """ + i = wp.tid() + global_dof = int(indices[i]) - dof_offset + env = global_dof // num_joints + out_mask[i] = env_mask[env] + + +@wp.kernel(enable_backward=False) +def scatter_gain_kernel( + src: wp.array(dtype=wp.float32), + dst: wp.array(dtype=wp.float32), + indices: wp.array(dtype=wp.uint32), + dof_offset: int, + num_joints: int, +): + """Scatter per-actuator ``src`` values into a flat per-env-per-DOF ``dst``. + + Used at adapter finalize to snapshot each ``controller.kp`` / + ``controller.kd`` into the ``(num_envs, num_joints)`` torch tensor + that ``randomize_actuator_gains`` reads as + ``actuator.stiffness`` / ``.damping`` for its + ``default_joint_stiffness`` / ``default_joint_damping`` baseline. + """ + i = wp.tid() + global_dof = int(indices[i]) - dof_offset + env = global_dof // num_joints + local_dof = global_dof % num_joints + dst[env * num_joints + local_dof] = src[i] + + +@wp.kernel(enable_backward=False) +def patch_actuator_param_kernel( + indices: wp.array(dtype=wp.uint32), + env_id_pos: wp.array(dtype=wp.int32), + joint_id_pos: wp.array(dtype=wp.int32), + values: wp.array2d(dtype=wp.float32), + dof_offset: int, + num_joints: int, + dst: wp.array(dtype=wp.float32), +): + """Per-actuator scatter for partial DR gain updates. + + For each slot ``i`` in the actuator's flat env-major ``indices``, derive + the (env, local-joint) pair, look it up against the dense position + arrays, and — when both axes are in the DR sub-grid — overwrite + ``dst[i]`` (the controller parameter) with ``values[e_pos, j_pos]``. + Cells outside the sub-grid are left untouched. + + Args: + indices: Actuator's flat indices into the (env-major) DOF layout. + env_id_pos: ``env_id_pos[env]`` gives the row in ``values`` for + envs being updated, ``-1`` otherwise. Length ``num_envs``. + joint_id_pos: ``joint_id_pos[joint]`` gives the column in + ``values`` for joints being updated, ``-1`` otherwise. + Length ``num_joints`` (articulation-local). + values: New parameter values shaped ``(len(env_ids), len(joint_ids))``. + dof_offset: Offset of this articulation's DOFs in the env-major + global index space (``0`` on PhysX, view-dependent on Newton). + num_joints: Articulation-local joint count. + dst: Per-actuator controller parameter array (e.g. ``controller.kp``). + """ + i = wp.tid() + global_dof = int(indices[i]) - dof_offset + env = global_dof // num_joints + joint = global_dof % num_joints + e_pos = env_id_pos[env] + j_pos = joint_id_pos[joint] + if e_pos >= 0 and j_pos >= 0: + dst[i] = values[e_pos, j_pos] + + +# --------------------------------------------------------------------------- +# Articulation-level kernels: in-graph post-actuator hook. +# --------------------------------------------------------------------------- + + +@wp.kernel(enable_backward=False) +def sync_torque_telemetry( + joint_pos: wp.array2d(dtype=wp.float32), + joint_vel: wp.array2d(dtype=wp.float32), + joint_pos_target: wp.array2d(dtype=wp.float32), + joint_vel_target: wp.array2d(dtype=wp.float32), + joint_stiffness: wp.array2d(dtype=wp.float32), + joint_damping: wp.array2d(dtype=wp.float32), + effort_limit: wp.array2d(dtype=wp.float32), + joint_modes: wp.array(dtype=wp.int32), + sim_bind_joint_effort: wp.array2d(dtype=wp.float32), + actuator_computed_effort: wp.array2d(dtype=wp.float32), + computed: wp.array2d(dtype=wp.float32), + applied: wp.array2d(dtype=wp.float32), +): + """In-graph post-actuator hook: fill ``computed`` / ``applied`` torque telemetry. + + For implicit DOFs we compute the shadow PD locally (no Newton actuator + runs on these); for explicit DOFs we read the pre-clamp effort the + actuators just scatter-added into ``actuator_computed_effort`` and the + post-clamp effort already in ``sim_bind_joint_effort`` (= ``joint_f``). + + Note: ``effort_limit`` clamps only the PD shadow used for implicit-DOF + telemetry; the FF written into ``joint_f`` is not bounded by it. + """ + i, j = wp.tid() + if joint_modes[j] == 1: + err_p = joint_pos_target[i, j] - joint_pos[i, j] + err_v = joint_vel_target[i, j] - joint_vel[i, j] + pd = joint_stiffness[i, j] * err_p + joint_damping[i, j] * err_v + limit = effort_limit[i, j] + pd_clipped = wp.clamp(pd, -limit, limit) + total = pd_clipped + sim_bind_joint_effort[i, j] + computed[i, j] = total + applied[i, j] = total + else: + computed[i, j] = actuator_computed_effort[i, j] + applied[i, j] = sim_bind_joint_effort[i, j] + + +def build_implicit_dof_mask( + actuators: dict[str, ActuatorBase], + num_joints: int, + device: str, +) -> tuple[wp.array, torch.Tensor]: + """Per-DOF mask consumed by :func:`sync_torque_telemetry`. + + Entry is ``1`` for DOFs covered by an + :class:`~isaaclab.actuators.ImplicitActuator` group, ``0`` otherwise. + + Returns: + Tuple of ``(wp_mask, torch_owner)``. ``wp_mask`` is the Warp + view used by the kernel; ``torch_owner`` is the underlying + :class:`torch.Tensor` whose GPU memory ``wp_mask`` aliases. The + caller **must keep a reference to** ``torch_owner`` for the + Warp view's lifetime — otherwise the torch refcount drops to + zero, the memory becomes eligible for reallocation by the + caching allocator, and any captured CUDA graph that baked in + ``wp_mask``'s device pointer will read garbage at replay time. + """ + modes = torch.zeros(num_joints, dtype=torch.int32, device=device) + for actuator in actuators.values(): + if not isinstance(actuator, ImplicitActuator): + continue + j_ids = actuator.joint_indices + if j_ids == slice(None) or j_ids is None: + modes[:] = 1 + else: + modes[j_ids.long()] = 1 + return wp.from_torch(modes, dtype=wp.int32), modes diff --git a/source/isaaclab_newton/isaaclab_newton/actuators/physx_wrapper.py b/source/isaaclab_newton/isaaclab_newton/actuators/physx_wrapper.py new file mode 100644 index 000000000000..b3f48a2f9dee --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/actuators/physx_wrapper.py @@ -0,0 +1,60 @@ +# 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-only stepping helper for :class:`~newton.actuators.Actuator`. + +Newton's :meth:`Actuator.step` requires a ``sim_state`` / ``sim_control`` +pair that exposes flat 1-D Warp arrays (``joint_q``, ``joint_qd``, +``joint_target_pos``, ``joint_f``, …). On the **Newton backend** these +are the ``State`` and ``Control`` objects that the solver already owns — +no wrapper is needed because: + +* The solver manages double-buffered ``State`` objects for CUDA-graph + capture, and actuators are stepped inside the solver's own simulation + loop where states are already available. +* Wrapping them would add indirection with no benefit; the Newton + articulation code that calls :meth:`Actuator.step` lives in + ``newton_manager.py`` and has direct access to the model's state. + +On the **PhysX backend**, no Newton solver exists — the actuators are +stepped manually from the Lab articulation's ``write_data_to_sim`` +path. Isaac Lab stores joint data as 2-D tensors (``num_envs × +num_joints``), so :class:`PhysxActuatorWrapper` provides zero-copy flat +views that satisfy the protocol without allocating new memory. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import warp as wp + + +@dataclass +class PhysxActuatorWrapper: + """Flat-array wrapper serving as ``sim_state`` / ``sim_control`` for + :meth:`Actuator.step` on the PhysX backend. + + Most attributes are bound once at articulation init to zero-copy flat + views of Isaac Lab's 2-D buffers. ``joint_f_2d`` is the only persistent + allocation, sized via :meth:`create`; ``joint_f`` is its flat alias + consumed by the Newton actuator step. + """ + + joint_q: wp.array | None = None + joint_qd: wp.array | None = None + joint_target_pos: wp.array | None = None + joint_target_vel: wp.array | None = None + joint_act: wp.array | None = None + joint_f: wp.array | None = None + joint_f_2d: wp.array | None = None + + @classmethod + def create(cls, num_envs: int, num_joints: int, device: str) -> PhysxActuatorWrapper: + """Allocate the persistent ``joint_f`` buffer for the given articulation shape.""" + w = cls() + w.joint_f_2d = wp.zeros((num_envs, num_joints), dtype=wp.float32, device=device) + w.joint_f = w.joint_f_2d.reshape(-1) + return w diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index ff04b96c63ca..690065980945 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -8,6 +8,7 @@ from __future__ import annotations +import importlib.util import logging import warnings from collections.abc import Sequence @@ -25,6 +26,9 @@ from isaaclab.actuators import ActuatorBase, ActuatorBaseCfg, ImplicitActuator from isaaclab.assets.articulation.base_articulation import BaseArticulation + +_HAS_NEWTON_ACTUATORS = importlib.util.find_spec("isaaclab_newton.actuators") is not None + from isaaclab.physics import PhysicsEvent from isaaclab.sim.utils.queries import find_first_matching_prim, get_all_matching_child_prims from isaaclab.utils.string import resolve_matching_names, resolve_matching_names_values @@ -41,6 +45,7 @@ if TYPE_CHECKING: from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg + # import logger logger = logging.getLogger(__name__) @@ -113,8 +118,13 @@ def __init__(self, cfg: ArticulationCfg): Args: cfg: A configuration instance. """ + from isaaclab.sim import SimulationContext # noqa: PLC0415 + super().__init__(cfg) + sim_ctx = SimulationContext.instance() + self._sim_cfg = sim_ctx.cfg if sim_ctx is not None else None + """ Properties """ @@ -236,9 +246,18 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None # use ellipses object to skip initial indices. if (env_ids is None) or (env_ids == slice(None)): env_ids = slice(None) - # reset actuators + # reset Lab actuators registered on this articulation for actuator in self.actuators.values(): actuator.reset(env_ids) + # reset the global Newton actuator adapter (its ``_states_a/_b`` buffers + # carry per-env state — delay queues, neural hidden states — that must + # be cleared for the resetting envs). The adapter spans the whole model, + # so calling reset here resets state for every articulation that shares + # this env id; that's correct because env ids are world-scoped. + # ``getattr`` guards subclasses (e.g. ``Multirotor``) that override + # ``_process_actuators_cfg`` and never initialize ``_has_newton_actuators``. + if getattr(self, "_has_newton_actuators", False) and SimulationManager._adapter is not None: + SimulationManager._adapter.reset(env_ids) # reset external wrenches. self._instantaneous_wrench_composer.reset(env_ids, env_mask) self._permanent_wrench_composer.reset(env_ids, env_mask) @@ -275,14 +294,26 @@ def write_data_to_sim(self): ) self._instantaneous_wrench_composer.reset() - # apply actuator models - self._apply_actuator_model() - # write actions into simulation via Newton bindings - self.data._sim_bind_joint_effort.assign(self._joint_effort_target_sim) - # position and velocity targets only for implicit actuators - if self._has_implicit_actuators: - self.data._sim_bind_joint_position_target.assign(self._joint_pos_target_sim) - self.data._sim_bind_joint_velocity_target.assign(self._joint_vel_target_sim) + if getattr(self, "_has_newton_actuators", False): + # Raw targets go directly to Newton's control object. Newton PD + # consumes ``joint_act`` for explicit (Newton-managed) joints; the + # solver's built-in joint drive does the PD for implicit joints + # (whose stiffness/damping are non-zero in sim) and adds whatever + # is in ``joint_f`` as feedforward. We pre-fill ``joint_f`` with + # the user's effort target across all DOFs here; the adapter step + # will zero it at explicit DOFs and overwrite them with each + # actuator's computed effort, while implicit DOFs keep the FF. + self.data._sim_bind_joint_position_target.assign(self._data._joint_pos_target) + self.data._sim_bind_joint_velocity_target.assign(self._data._joint_vel_target) + self.data._sim_bind_joint_act.assign(self._data._joint_effort_target) + self.data._sim_bind_joint_effort.assign(self._data._joint_effort_target) + else: + # Standard Lab actuator path + self._apply_actuator_model() + self.data._sim_bind_joint_effort.assign(self._joint_effort_target_sim) + if self._has_implicit_actuators: + self.data._sim_bind_joint_position_target.assign(self._joint_pos_target_sim) + self.data._sim_bind_joint_velocity_target.assign(self._joint_vel_target_sim) def update(self, dt: float): """Updates the simulation data. @@ -1472,6 +1503,85 @@ def write_joint_damping_to_sim_mask( # tell the physics engine that some of the joint properties have been updated SimulationManager.add_model_change(SolverNotifyFlags.JOINT_DOF_PROPERTIES) + def write_actuator_stiffness_to_sim( + self, + *, + stiffness: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + """Write actuator kp at the (env_ids, joint_ids) sub-grid and propagate to controllers. + + Iterates the global adapter's Newton actuators and uses + :meth:`ArticulationView.get_actuator_parameter` / + :meth:`~ArticulationView.set_actuator_parameter` to patch each + controller's ``kp`` array. Actuators belonging to a different + articulation are no-ops because the view's per-DOF mapping + returns ``-1`` for DOFs outside this articulation's range. + + Args: + stiffness: Sub-grid of new kp values, shape ``(len(env_ids), len(joint_ids))``. + env_ids: 1D torch tensor of env indices. + joint_ids: 1D torch tensor of articulation-local joint indices. + + No-op when the Newton fast path is not active. + """ + self._write_actuator_param("kp", stiffness, env_ids, joint_ids) + + def write_actuator_damping_to_sim( + self, + *, + damping: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + """Write actuator kd at the (env_ids, joint_ids) sub-grid and propagate to controllers.""" + self._write_actuator_param("kd", damping, env_ids, joint_ids) + + def _write_actuator_param( + self, + attr: str, + values: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + """Shared body for :meth:`write_actuator_stiffness_to_sim` / :meth:`write_actuator_damping_to_sim`.""" + from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 + + adapter = self.newton_actuator_adapter + if adapter is None: + return + + env_ids_wp = wp.from_torch( + env_ids.to(self.device, dtype=torch.int32).contiguous(), + dtype=wp.int32, + ) + env_mask = wp.zeros(self.num_instances, dtype=wp.bool, device=self.device) + wp.launch( + actuator_kernels.set_mask_kernel, + dim=env_ids_wp.shape[0], + inputs=[env_mask, env_ids_wp], + device=self.device, + ) + + env_ids_long = env_ids.to(self.device, dtype=torch.long).unsqueeze(1) + joint_ids_long = joint_ids.to(self.device, dtype=torch.long).unsqueeze(0) + + for act in adapter.actuators: + ctrl = act.controller + if not hasattr(ctrl, attr): + continue + cur_wp = self._root_view.get_actuator_parameter(act, ctrl, attr) + cur_torch = wp.to_torch(cur_wp) + cur_torch[env_ids_long, joint_ids_long] = values.to(cur_torch.device, dtype=cur_torch.dtype) + self._root_view.set_actuator_parameter( + actuator=act, + component=ctrl, + name=attr, + values=cur_wp, + mask=env_mask, + ) + def write_joint_position_limit_to_sim_index( self, *, @@ -2099,6 +2209,24 @@ def write_joint_friction_coefficient_to_sim_mask( # tell the physics engine that some of the joint properties have been updated SimulationManager.add_model_change(SolverNotifyFlags.JOINT_DOF_PROPERTIES) + """ + Operations - Newton Actuator Parameter Writers. + """ + + @staticmethod + @wp.kernel(enable_backward=False) + def _build_env_mask_kernel(mask: wp.array(dtype=wp.bool), indices: wp.array(dtype=wp.int32)): + i = wp.tid() + mask[indices[i]] = True + + def _env_ids_to_mask(self, env_ids: wp.array) -> wp.array: + """Convert warp env_ids to a boolean Warp mask.""" + if env_ids is self._ALL_INDICES: + return self._ALL_ENV_MASK + mask = wp.zeros(self.num_instances, dtype=wp.bool, device=self.device) + wp.launch(self._build_env_mask_kernel, dim=env_ids.shape[0], inputs=[mask, env_ids], device=self.device) + return mask + """ Operations - Setters. """ @@ -3428,123 +3556,160 @@ def _process_actuators_cfg(self): # flag for implicit actuators # if this is false, we by-pass certain checks when doing actuator-related operations self._has_implicit_actuators = False + self._has_newton_actuators = False + # Per-DOF implicit/explicit mask consumed by the in-graph kernel + # ``sync_torque_telemetry``. ``None`` when no Newton fast path is active. + self._implicit_dof_mask: wp.array | None = None + # Reference to the global Newton actuator adapter (or ``None`` + # when this articulation has no explicit Newton actuators) and a + # per-articulation kp/kd snapshot consumed by + # ``randomize_actuator_gains`` to seed its DR baselines. + self.newton_actuator_adapter = None + self.newton_default_stiffness: torch.Tensor | None = None + self.newton_default_damping: torch.Tensor | None = None + self.newton_managed_local_joints: torch.Tensor | slice | None = None + + _use_newton_actuators = getattr(self._sim_cfg, "use_newton_actuators", False) + + if _use_newton_actuators and not _HAS_NEWTON_ACTUATORS: + logger.warning( + "use_newton_actuators is enabled but 'newton.actuators' is not available. " + "Newton-native actuators will be disabled. Upgrade Newton to >= 1.2.0rc1." + ) - # iterate over all actuator configurations - for actuator_name, actuator_cfg in self.cfg.actuators.items(): - # type annotation for type checkers - actuator_cfg: ActuatorBaseCfg - # create actuator group - joint_ids, joint_names = self.find_joints(actuator_cfg.joint_names_expr) - # check if any joints are found - if len(joint_names) == 0: - raise ValueError( - f"No joints found for actuator group: {actuator_name} with joint name expression:" - f" {actuator_cfg.joint_names_expr}." + if _use_newton_actuators and _HAS_NEWTON_ACTUATORS: + from newton import Model as NewtonModel # noqa: PLC0415 + + from isaaclab_newton.actuators import ( # noqa: PLC0415 + build_implicit_dof_mask, + build_newton_actuator_defaults, + ) + from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 + + # Enable the fast path even for all-implicit articulations: + # the solver runs PD internally; Lab only forwards targets. + self._has_newton_actuators = True + # Opt this articulation into the Newton fast path and (idempotently) + # build the single sim-level actuator adapter from ``model.actuators``. + SimulationManager.activate_newton_actuator_path() + + # Zero the simulator's joint-drive PD on DOFs covered by an explicit + # Lab actuator config in *this* articulation. The global Newton + # adapter's actuator step writes their effort to ``joint_f`` + # directly; the joint drive shouldn't add its own PD on top. + explicit_joint_ids: list[int] = [] + for actuator_cfg in self.cfg.actuators.values(): + cls_type = actuator_cfg.class_type + if ( + "ImplicitActuator" in cls_type + if isinstance(cls_type, str) + else issubclass(cls_type, ImplicitActuator) + ): + continue + joint_ids, _ = self.find_joints(actuator_cfg.joint_names_expr) + explicit_joint_ids.extend(int(j) for j in joint_ids) + if explicit_joint_ids: + explicit_ids_t = torch.tensor( + sorted(set(explicit_joint_ids)), + dtype=torch.int32, + device=self.device, ) - # resolve joint indices - # we pass a slice if all joints are selected to avoid indexing overhead - if len(joint_names) == self.num_joints: - joint_ids = slice(None) - else: - joint_ids = torch.tensor(joint_ids, device=self.device, dtype=torch.int32) - # create actuator collection - # note: for efficiency avoid indexing when over all indices - actuator: ActuatorBase = actuator_cfg.class_type( - cfg=actuator_cfg, - joint_names=joint_names, - joint_ids=joint_ids, - num_envs=self.num_instances, - device=self.device, - stiffness=self._data.joint_stiffness.torch[:, joint_ids], - damping=self._data.joint_damping.torch[:, joint_ids], - armature=self._data.joint_armature.torch[:, joint_ids], - friction=self._data.joint_friction_coeff.torch[:, joint_ids], - effort_limit=self._data.joint_effort_limits.torch[:, joint_ids].clone(), - velocity_limit=self._data.joint_vel_limits.torch[:, joint_ids], + self.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=explicit_ids_t) + self.write_joint_damping_to_sim_index(damping=0.0, joint_ids=explicit_ids_t) + + for actuator_name, actuator_cfg in self.cfg.actuators.items(): + cls_type = actuator_cfg.class_type + is_implicit = ( + "ImplicitActuator" in cls_type + if isinstance(cls_type, str) + else issubclass(cls_type, ImplicitActuator) + ) + if is_implicit: + self._create_lab_actuator(actuator_name, actuator_cfg) + else: + self._create_lab_actuator(actuator_name, actuator_cfg, properties_only=True) + + # ``_implicit_dof_mask_owner`` is the underlying torch tensor that owns + # the GPU memory aliased by ``_implicit_dof_mask``. We keep it as an + # instance attribute so the memory isn't freed while a CUDA graph + # holds a captured pointer into it. + self._implicit_dof_mask, self._implicit_dof_mask_owner = build_implicit_dof_mask( + self.actuators, + self.num_joints, + self.device, ) - # store actuator group - self.actuators[actuator_name] = actuator - # set the passed gains and limits into the simulation - if isinstance(actuator, ImplicitActuator): - self._has_implicit_actuators = True - # the gains and limits are set into the simulation since actuator model is implicit - self.write_joint_stiffness_to_sim_index(stiffness=actuator.stiffness, joint_ids=actuator.joint_indices) - self.write_joint_damping_to_sim_index(damping=actuator.damping, joint_ids=actuator.joint_indices) + + # Run the implicit-DOF FF-routing + telemetry kernel inside the + # captured graph, right after the actuator step. Closure captures + # the buffers we need via ``self._data``. + + # Per-articulation view of the global adapter's pre-clamp + # computed-effort buffer. Set up once here (the adapter is + # already built by ``activate_newton_actuator_path``) so the + # callback below has nothing to resolve. Falls back to a zero + # buffer for all-implicit scenes where no global adapter + # exists — the kernel only reads it on explicit DOFs. + adapter = SimulationManager._adapter + if adapter is not None: + dof_layout = self._root_view.frequency_layouts[NewtonModel.AttributeFrequency.JOINT_DOF] + if dof_layout.slice is not None: + arti_start = dof_layout.slice.start + elif dof_layout.indices is not None: + arti_start = int(dof_layout.indices.numpy()[0]) + else: + arti_start = 0 + self._data._sim_bind_joint_computed_effort = adapter.computed_effort_2d[ + :, arti_start : arti_start + self.num_joints + ] + self.newton_actuator_adapter = adapter + ( + self.newton_default_stiffness, + self.newton_default_damping, + self.newton_managed_local_joints, + ) = build_newton_actuator_defaults( + actuators=adapter.actuators, + num_envs=self.num_instances, + num_joints=self.num_joints, + dof_offset=arti_start, + device=self.device, + ) else: - # the gains and limits are processed by the actuator model - # we set gains to zero, and torque limit to a high value in simulation to avoid any interference - self.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=actuator.joint_indices) - self.write_joint_damping_to_sim_index(damping=0.0, joint_ids=actuator.joint_indices) - - # Set common properties into the simulation - self.write_joint_effort_limit_to_sim_index( - limits=actuator.effort_limit_sim, joint_ids=actuator.joint_indices - ) - self.write_joint_velocity_limit_to_sim_index( - limits=actuator.velocity_limit_sim, joint_ids=actuator.joint_indices - ) - self.write_joint_armature_to_sim_index(armature=actuator.armature, joint_ids=actuator.joint_indices) - self.write_joint_friction_coefficient_to_sim_index( - joint_friction_coeff=actuator.friction, joint_ids=actuator.joint_indices - ) + self._data._sim_bind_joint_computed_effort = wp.zeros( + (self.num_instances, self.num_joints), + dtype=wp.float32, + device=self.device, + ) - # Store the configured values from the actuator model - # note: this is the value configured in the actuator model (for implicit and explicit actuators) - joint_ids = actuator.joint_indices - if joint_ids == slice(None): - joint_ids = self._ALL_JOINT_INDICES - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices, - dim=(self.num_instances, joint_ids.shape[0]), - inputs=[ - actuator.stiffness, - self._ALL_INDICES, - joint_ids, - ], - outputs=[ - self.data._sim_bind_joint_stiffness_sim, - ], - device=self.device, - ) - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices, - dim=(self.num_instances, joint_ids.shape[0]), - inputs=[ - actuator.damping, - self._ALL_INDICES, - joint_ids, - ], - outputs=[ - self.data._sim_bind_joint_damping_sim, - ], - device=self.device, - ) - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices, - dim=(self.num_instances, joint_ids.shape[0]), - inputs=[ - actuator.armature, - self._ALL_INDICES, - joint_ids, - ], - outputs=[ - self.data._sim_bind_joint_armature, - ], - device=self.device, - ) - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices, - dim=(self.num_instances, joint_ids.shape[0]), - inputs=[ - actuator.friction, - self._ALL_INDICES, - joint_ids, - ], - outputs=[ - self.data._sim_bind_joint_friction_coeff, - ], - device=self.device, - ) + def _post_actuator() -> None: + wp.launch( + actuator_kernels.sync_torque_telemetry, + dim=(self.num_instances, self.num_joints), + inputs=[ + self._data.joint_pos.warp, + self._data.joint_vel.warp, + self._data._joint_pos_target, + self._data._joint_vel_target, + self._data.joint_stiffness.warp, + self._data.joint_damping.warp, + self._data.joint_effort_limits.warp, + self._implicit_dof_mask, + self._data._sim_bind_joint_effort, + self._data._sim_bind_joint_computed_effort, + ], + outputs=[ + self._data._computed_torque, + self._data._applied_torque, + ], + device=self.device, + ) + + SimulationManager.register_post_actuator_callback(_post_actuator) + + return + + # --- Standard Isaac Lab actuator path --- + for actuator_name, actuator_cfg in self.cfg.actuators.items(): + self._create_lab_actuator(actuator_name, actuator_cfg) # perform some sanity checks to ensure actuators are prepared correctly total_act_joints = sum(actuator.num_joints for actuator in self.actuators.values()) @@ -3555,8 +3720,14 @@ def _process_actuators_cfg(self): ) if self.cfg.actuator_value_resolution_debug_print: + if _HAS_NEWTON_ACTUATORS: + from isaaclab_newton.actuators import NewtonActuatorAdapter # noqa: PLC0415 + else: + NewtonActuatorAdapter = None # type: ignore[assignment] t = PrettyTable(["Group", "Property", "Name", "ID", "USD Value", "ActutatorCfg Value", "Applied"]) for actuator_group, actuator in self.actuators.items(): + if NewtonActuatorAdapter is not None and isinstance(actuator, NewtonActuatorAdapter): + continue group_count = 0 for property, resolution_details in actuator.joint_property_resolution_table.items(): for prop_idx, resolution_detail in enumerate(resolution_details): @@ -3567,6 +3738,112 @@ def _process_actuators_cfg(self): group_count += 1 logger.warning(f"\nActuatorCfg-USD Value Discrepancy Resolution (matching values are skipped): \n{t}") + def _create_lab_actuator( + self, + actuator_name: str, + actuator_cfg: ActuatorBaseCfg, + *, + properties_only: bool = False, + ) -> None: + """Instantiate a single Lab actuator from its config and write properties to sim. + + Args: + actuator_name: Name for the actuator group. + actuator_cfg: Configuration for the actuator. + properties_only: When ``True``, only write physical joint properties + (armature, limits, friction) without registering the actuator or + writing stiffness/damping. Used for explicit joints managed by + Newton actuators. + """ + joint_ids, joint_names = self.find_joints(actuator_cfg.joint_names_expr) + if len(joint_names) == 0: + raise ValueError( + f"No joints found for actuator group: {actuator_name} with joint name expression:" + f" {actuator_cfg.joint_names_expr}." + ) + if len(joint_names) == self.num_joints: + joint_ids = slice(None) + else: + joint_ids = torch.tensor(joint_ids, device=self.device, dtype=torch.int32) + + actuator: ActuatorBase = actuator_cfg.class_type( + cfg=actuator_cfg, + joint_names=joint_names, + joint_ids=joint_ids, + num_envs=self.num_instances, + device=self.device, + stiffness=wp.to_torch(self._data.joint_stiffness)[:, joint_ids], + damping=wp.to_torch(self._data.joint_damping)[:, joint_ids], + armature=wp.to_torch(self._data.joint_armature)[:, joint_ids], + friction=wp.to_torch(self._data.joint_friction_coeff)[:, joint_ids], + effort_limit=wp.to_torch(self._data.joint_effort_limits)[:, joint_ids].clone(), + velocity_limit=wp.to_torch(self._data.joint_vel_limits)[:, joint_ids], + ) + + # Write physical joint properties (armature, limits, friction) — always needed. + self.write_joint_effort_limit_to_sim_index( + limits=actuator.effort_limit_sim, + joint_ids=actuator.joint_indices, + ) + self.write_joint_velocity_limit_to_sim_index( + limits=actuator.velocity_limit_sim, + joint_ids=actuator.joint_indices, + ) + self.write_joint_armature_to_sim_index(armature=actuator.armature, joint_ids=actuator.joint_indices) + self.write_joint_friction_coefficient_to_sim_index( + joint_friction_coeff=actuator.friction, + joint_ids=actuator.joint_indices, + ) + + if properties_only: + return + + self.actuators[actuator_name] = actuator + + if isinstance(actuator, ImplicitActuator): + self._has_implicit_actuators = True + self.write_joint_stiffness_to_sim_index(stiffness=actuator.stiffness, joint_ids=actuator.joint_indices) + self.write_joint_damping_to_sim_index(damping=actuator.damping, joint_ids=actuator.joint_indices) + else: + self.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=actuator.joint_indices) + self.write_joint_damping_to_sim_index(damping=0.0, joint_ids=actuator.joint_indices) + + # Store the actuator-configured values in Lab-internal buffers. + # These are separate from the sim-bound model arrays so that + # write_joint_stiffness_to_sim_index(0.0) for explicit actuators + # is not overwritten (the solver must see ke=0 for explicit joints). + j_ids = actuator.joint_indices + if j_ids == slice(None): + j_ids = self._ALL_JOINT_INDICES + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(self.num_instances, j_ids.shape[0]), + inputs=[actuator.stiffness, self._ALL_INDICES, j_ids], + outputs=[self.data._actuator_stiffness], + device=self.device, + ) + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(self.num_instances, j_ids.shape[0]), + inputs=[actuator.damping, self._ALL_INDICES, j_ids], + outputs=[self.data._actuator_damping], + device=self.device, + ) + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(self.num_instances, j_ids.shape[0]), + inputs=[actuator.armature, self._ALL_INDICES, j_ids], + outputs=[self.data._sim_bind_joint_armature], + device=self.device, + ) + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(self.num_instances, j_ids.shape[0]), + inputs=[actuator.friction, self._ALL_INDICES, j_ids], + outputs=[self.data._sim_bind_joint_friction_coeff], + device=self.device, + ) + def _process_tendons(self): """Process fixed and spatial tendons.""" # create a list to store the fixed tendon names diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py index 0a2a84392619..2ddb3a13d3e4 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -1507,6 +1507,7 @@ def _create_simulation_bindings(self) -> None: self._sim_bind_joint_effort = self._root_view.get_attribute("joint_f", SimulationManager.get_control())[ :, 0 ] + self._sim_bind_joint_act = self._root_view.get_attribute("joint_act", SimulationManager.get_control())[:, 0] self._sim_bind_joint_position_target = self._root_view.get_attribute( "joint_target_pos", SimulationManager.get_control() )[:, 0] @@ -1538,6 +1539,7 @@ def _create_simulation_bindings(self) -> None: self._sim_bind_joint_pos = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) self._sim_bind_joint_vel = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) self._sim_bind_joint_effort = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) + self._sim_bind_joint_act = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) self._sim_bind_joint_position_target = wp.zeros( (self._num_instances, 0), dtype=wp.float32, device=self.device ) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py index 39d0fafbc1bb..ba2d407d2a9a 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py @@ -101,11 +101,11 @@ def step(cls) -> None: # Step simulation (graphed or not; _graph is None when capture is disabled or failed) if cfg is not None and cfg.use_cuda_graph and cls._graph is not None and "cuda" in device: # type: ignore[union-attr] wp.capture_launch(cls._graph) - if cls._usdrt_stage is not None: - cls._mark_transforms_dirty() else: with wp.ScopedDevice(device): - cls._simulate() + cls._simulate_physics_only() + if cls._usdrt_stage is not None: + cls._mark_transforms_dirty() # Launch solver-specific debug logging after stepping. cls._log_solver_debug() @@ -138,7 +138,7 @@ def _capture_or_defer_cuda_graph(cls) -> None: if cls._usdrt_stage is None: # No RTX active — use standard Warp capture (cudaStreamCaptureModeGlobal). with wp.ScopedCapture() as capture: - cls._simulate() + cls._simulate_physics_only() NewtonManager._graph = capture.graph logger.info("Newton CUDA graph captured (standard Warp mode)") diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 7f112af4bacf..fb8188ff5909 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -31,7 +31,7 @@ from newton.sensors import SensorContact as NewtonContactSensor from newton.sensors import SensorFrameTransform from newton.sensors import SensorIMU as NewtonSensorIMU -from newton.solvers import SolverBase, SolverNotifyFlags +from newton.solvers import SolverBase, SolverKamino, SolverNotifyFlags from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager, SceneDataBackend, SceneDataFormat from isaaclab.sim.utils.newton_model_utils import replace_newton_shape_colors @@ -45,6 +45,8 @@ if TYPE_CHECKING: from isaaclab.sim.simulation_context import SimulationContext + from isaaclab_newton.actuators import NewtonActuatorAdapter + from .newton_collision_cfg import NewtonCollisionPipelineCfg logger = logging.getLogger(__name__) @@ -166,6 +168,7 @@ class NewtonManager(PhysicsManager): _solver_dt: float = 1.0 / 200.0 _num_substeps: int = 1 + _decimation: int = 1 _num_envs: int | None = None # Newton model and state @@ -197,6 +200,13 @@ class NewtonManager(PhysicsManager): _world_reset_mask: wp.array | None = None # (num_envs,) wp.int32 — for SolverKamino.reset(world_mask=...) _fk_reset_mask: wp.array | None = None # (articulation_count,) wp.bool — for eval_fk(mask=...) + # Newton actuator adapter (owns actuators and double-buffered states) + _adapter: NewtonActuatorAdapter | None = None + # In-graph hooks invoked after the actuator step and before the solver + # substeps, in registration order. Multiple articulations register their + # implicit-DOF telemetry / FF-routing kernels here. + _post_actuator_callbacks: list[Callable[[], None]] = [] + # CUDA graphing _graph = None _graph_capture_pending: bool = False @@ -407,7 +417,30 @@ def _mark_transforms_dirty(cls) -> None: @classmethod def step(cls) -> None: - """Step the physics simulation.""" + """Step the physics simulation. + + The stepping logic follows one of two paths depending on whether + **all** actuators are CUDA-graph-safe: + + **All-graphable path** (:meth:`_simulate_full`): + + Actuators and solver substeps are captured together in a single + CUDA graph containing the full + ``decimation x (actuators + solver substeps)`` loop. + + **Eager-actuator path** (fallback, some actuators not graph-safe): + + Actuators are stepped eagerly on the CPU timeline (outside the + graph), then a graph containing only the solver substeps is + launched via :meth:`_simulate_physics_only`. + + In both paths the sequence within one physics step is:: + + zero actuated DOFs in control.joint_f + -> actuator.step (computes effort, writes to control.joint_f) + -> solver.step x num_substeps (integrates, reads control.joint_f) + -> sensors.update + """ sim = PhysicsManager._sim if sim is None or not sim.is_playing(): return @@ -419,9 +452,7 @@ def step(cls) -> None: cls._solver.notify_model_changed(change) NewtonManager._model_changes = set() - # Lazy CUDA graph capture: deferred from initialize_solver() when RTX was active. - # By the time step() is first called, RTX has fully initialized (all cudaImportExternalMemory - # calls are done) and is idle between render frames — giving us a clean capture window. + # Lazy CUDA graph capture cfg = PhysicsManager._cfg device = PhysicsManager._device if cls._graph_capture_pending and cfg is not None and cfg.use_cuda_graph and "cuda" in device: # type: ignore[union-attr] @@ -443,20 +474,37 @@ def step(cls) -> None: NewtonManager._world_reset_mask.zero_() NewtonManager._fk_reset_mask.zero_() - # Step simulation (graphed or not; _graph is None when capture is disabled or failed) - if cfg is not None and cfg.use_cuda_graph and cls._graph is not None and "cuda" in device: # type: ignore[union-attr] - wp.capture_launch(cls._graph) - if cls._usdrt_stage is not None: - cls._mark_transforms_dirty() + physics_dt = cls._solver_dt * cls._num_substeps + use_graph = cfg is not None and cfg.use_cuda_graph and cls._graph is not None and "cuda" in device # type: ignore[union-attr] + + if cls._is_all_graphable(): + # --- All actuators are graph-safe: actuators + solver in one graph --- + if use_graph: + wp.capture_launch(cls._graph) + else: + with wp.ScopedDevice(device): + cls._simulate_full() + PhysicsManager._sim_time += physics_dt * cls._decimation else: - with wp.ScopedDevice(device): - cls._simulate() + # --- Some actuators not graph-safe: step them eagerly, graph solver only --- + if cls._adapter is not None: + cls._adapter.step(cls._state_0, cls._control, physics_dt) + for cb in cls._post_actuator_callbacks: + cb() + + if use_graph: + wp.capture_launch(cls._graph) + else: + with wp.ScopedDevice(device): + cls._simulate_physics_only() + PhysicsManager._sim_time += physics_dt + + if cls._usdrt_stage is not None: + cls._mark_transforms_dirty() # Launch solver-specific debug logging after stepping. cls._log_solver_debug() - PhysicsManager._sim_time += cls._solver_dt * cls._num_substeps - @classmethod def close(cls) -> None: """Clean up Newton physics resources.""" @@ -522,6 +570,14 @@ def clear(cls): NewtonManager._newton_frame_transform_sensors = [] NewtonManager._newton_imu_sensors = [] NewtonManager._report_contacts = False + NewtonManager._adapter = None + NewtonManager._post_actuator_callbacks = [] + # Set by an articulation that took the ``use_newton_actuators=True`` + # branch in ``_process_actuators_cfg``. Together with the adapter + # check, this gates whether the decimation loop can be captured into + # a CUDA graph (see :meth:`_is_all_graphable`). + NewtonManager._use_newton_actuators_active = False + NewtonManager._decimation = 1 # Per-world reset masks NewtonManager._world_reset_mask = None NewtonManager._fk_reset_mask = None @@ -826,6 +882,14 @@ def start_simulation(cls) -> None: NewtonManager._control = cls._model.control() eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, None) + # The single global actuator adapter is built lazily on the first + # call to ``activate_newton_actuator_path`` from any Newton-fast-path + # articulation after this point. Assign through the explicit base + # class so external readers (which import ``NewtonManager`` directly) + # observe the canonical state regardless of which subclass is active. + NewtonManager._adapter = None + NewtonManager._use_newton_actuators_active = False + # Allocate per-world reset masks (used by all solvers for masked FK, and by Kamino for masked reset) NewtonManager._world_reset_mask = wp.zeros(cls._model.world_count, dtype=wp.int32, device=device) NewtonManager._fk_reset_mask = wp.zeros(cls._model.articulation_count, dtype=wp.bool, device=device) @@ -1070,12 +1134,18 @@ def initialize_solver(cls) -> None: if cls._usdrt_stage is not None: cls._setup_cubric_bindings() - device = PhysicsManager._device - use_cuda_graph = cfg.use_cuda_graph and "cuda" in device # type: ignore[union-attr] - if use_cuda_graph: - cls._capture_or_defer_cuda_graph() - else: - NewtonManager._graph = None + # Skip the initial graph capture when the Newton actuator fast path is + # active. Capturing here would use ``cls._decimation`` (still its default + # of 1, because the env's ``set_decimation`` hasn't run yet); a second + # capture from ``set_decimation`` then triggers an illegal-memory-access + # CUDA fault inside the captured ``_simulate_full`` graph (back-to-back + # captures of the contact + actuator pipeline don't survive re-capture + # — root cause is in Newton's collision/actuator buffer handling, not + # Lab code). For non-Newton-actuator paths this branch is unaffected: + # ``set_decimation`` is a no-op for them (``_is_all_graphable`` is False), + # so we still need the start-time capture below. + if not cls._use_newton_actuators_active: + cls._capture_or_defer_graph() @classmethod def _setup_cubric_bindings(cls) -> None: @@ -1096,23 +1166,50 @@ def _setup_cubric_bindings(cls) -> None: logger.warning("cubric bindings init failed; falling back to update_world_xforms()") @classmethod - def _capture_or_defer_cuda_graph(cls) -> None: - """Capture the physics CUDA graph, or defer if RTX is initializing.""" - with Timer(name="newton_cuda_graph", msg="CUDA graph took:"): - if cls._usdrt_stage is None: - # No RTX active — use standard Warp capture (cudaStreamCaptureModeGlobal). - with wp.ScopedCapture() as capture: - cls._simulate() - NewtonManager._graph = capture.graph - logger.info("Newton CUDA graph captured (standard Warp mode)") - else: - # RTX is active during initialization — cudaImportExternalMemory and other - # non-capturable RTX ops run on background CUDA streams right now. - # Defer capture to the first step() call, after RTX is fully initialized - # and idle between render frames (clean capture window). - NewtonManager._graph = None - NewtonManager._graph_capture_pending = True - logger.info("Newton CUDA graph capture deferred until first step() (RTX active)") + def _capture_or_defer_graph(cls) -> None: + """Capture (or schedule deferred capture of) the CUDA graph. + + Called by :meth:`start_simulation` and :meth:`set_decimation` + whenever the graph needs to be (re-)captured. + + * **No USDRT / headless**: captures immediately via + ``wp.ScopedCapture``. + * **RTX active**: defers capture to the first :meth:`step` call + via :meth:`_capture_relaxed_graph`, because RTX background + streams are not yet idle during initialisation. + * **CUDA graphs disabled**: clears the graph reference. + """ + cfg = PhysicsManager._cfg + device = PhysicsManager._device + if cfg is None or device is None: + return + + use_cuda_graph = cfg.use_cuda_graph and "cuda" in device + if use_cuda_graph: + with Timer(name="newton_cuda_graph", msg="CUDA graph took:"): + if cls._usdrt_stage is None: + simulate = cls._simulate_full if cls._is_all_graphable() else cls._simulate_physics_only + with wp.ScopedCapture() as capture: + simulate() + NewtonManager._graph = capture.graph + logger.info("Newton CUDA graph captured (standard Warp mode)") + + # Kamino: StateKamino.from_newton() lazily allocates body_f_total, + # joint_q_prev, and joint_lambdas via wp.clone/wp.zeros during the + # first step() inside graph capture. Replay once to pin those + # memory-pool addresses before any eager solver.reset() call. + if isinstance(cls._solver, SolverKamino): + wp.capture_launch(cls._graph) + else: + # RTX is active during initialization — cudaImportExternalMemory and other + # non-capturable RTX ops run on background CUDA streams right now. + # Defer capture to the first step() call, after RTX is fully initialized + # and idle between render frames (clean capture window). + NewtonManager._graph = None + NewtonManager._graph_capture_pending = True + logger.info("Newton CUDA graph capture deferred until first step() (RTX active)") + else: + NewtonManager._graph = None @classmethod def _capture_relaxed_graph(cls, device: str): @@ -1143,7 +1240,7 @@ def _capture_relaxed_graph(cls, device: str): this registers the capture in Warp's ``device.captures`` *without* calling ``cudaStreamBeginCapture`` (already done) and *without* changing device-wide memory pool attributes (avoids error 900 in RTX's ``cudaMallocAsync``). - - Run ``_simulate_physics_only()`` inside ``ScopedStream(fresh_stream)``: + - Run the simulate function inside ``ScopedStream(fresh_stream)``: kernels dispatch to ``fresh_stream`` and are captured; ``wp.capture_while`` finds the active capture and inserts a conditional graph node instead of synchronising. - Call ``wp.capture_end(stream=fresh_stream)`` to finalise the Warp-level capture. @@ -1161,8 +1258,9 @@ def _capture_relaxed_graph(cls, device: str): # Warmup: pre-allocate all solver scratch buffers so the capture window has # no new cudaMalloc calls (which are forbidden inside graph capture). + simulate = cls._simulate_full if cls._is_all_graphable() else cls._simulate_physics_only with wp.ScopedDevice(device): - cls._simulate_physics_only() + simulate() wp.synchronize_stream(wp.get_stream(device)) # Create a non-blocking stream (cudaStreamNonBlocking = 0x01). @@ -1195,7 +1293,7 @@ def _capture_relaxed_graph(cls, device: str): err_during_capture = None with wp.ScopedStream(fresh_stream, sync_enter=False): try: - cls._simulate_physics_only() + simulate() except Exception as exc: err_during_capture = exc @@ -1235,62 +1333,85 @@ def _capture_relaxed_graph(cls, device: str): graph.graph_exec = None return graph - @classmethod - def _simulate_physics_only(cls) -> None: - """Run one physics step without Fabric/USD sync — safe for CUDA graph capture. - - Used by :meth:`_capture_relaxed_graph` to capture only the pure physics kernels. - ``sync_transforms_to_usd`` is excluded because it calls ``wp.synchronize_device`` - (forbidden inside graph capture) and ``wp.fabricarray`` (device-wide allocation). - The caller (``step()``) is responsible for calling ``sync_transforms_to_usd()`` - eagerly after ``wp.capture_launch``. - """ - if cls._needs_collision_pipeline: - cls._collision_pipeline.collide(cls._state_0, cls._contacts) + # ------------------------------------------------------------------ + # Building blocks — used by _simulate_full / _simulate_physics_only + # ------------------------------------------------------------------ + @classmethod + def _run_solver_substeps(cls, contacts) -> None: + """Run ``num_substeps`` solver iterations, handling double-buffered state swap.""" if cls._use_single_state: for _ in range(cls._num_substeps): - cls._step_solver(cls._state_0, cls._state_0, cls._control, cls._solver_dt) + cls._solver.step(cls._state_0, cls._state_0, cls._control, contacts, cls._solver_dt) cls._state_0.clear_forces() else: cfg = PhysicsManager._cfg - need_copy_on_last_substep = (cfg is not None and cfg.use_cuda_graph) and cls._num_substeps % 2 == 1 # type: ignore[union-attr] + need_copy_on_last = (cfg is not None and cfg.use_cuda_graph) and cls._num_substeps % 2 == 1 # type: ignore[union-attr] for i in range(cls._num_substeps): - cls._step_solver(cls._state_0, cls._state_1, cls._control, cls._solver_dt) - if need_copy_on_last_substep and i == cls._num_substeps - 1: + cls._solver.step(cls._state_0, cls._state_1, cls._control, contacts, cls._solver_dt) + if need_copy_on_last and i == cls._num_substeps - 1: cls._state_0.assign(cls._state_1) else: - NewtonManager._state_0, NewtonManager._state_1 = cls._state_1, cls._state_0 + cls._state_0, cls._state_1 = cls._state_1, cls._state_0 cls._state_0.clear_forces() - # Update frame transform sensors + @classmethod + def _update_sensors(cls, contacts) -> None: + """Push latest state to all registered Newton sensors.""" if cls._newton_frame_transform_sensors: for sensor in cls._newton_frame_transform_sensors: sensor.update(cls._state_0) - - # Update IMU sensors if cls._newton_imu_sensors: for sensor in cls._newton_imu_sensors: sensor.update(cls._state_0) - - # Populate contacts for contact sensors if cls._report_contacts: - eval_contacts = cls._contacts + eval_contacts = contacts if contacts is not None else cls._contacts cls._solver.update_contacts(eval_contacts, cls._state_0) for sensor in cls._newton_contact_sensors.values(): sensor.update(cls._state_0, eval_contacts) + # ------------------------------------------------------------------ + # Composite stepping routines + # ------------------------------------------------------------------ + @classmethod - def _simulate(cls) -> None: - """Run one simulation step with substeps and USD sync. + def _simulate_full(cls) -> None: + """Run ``decimation x (actuators + solver substeps)``, then sensors. - Delegates physics work to :meth:`_simulate_physics_only` and then - marks transforms dirty for the next render-cadence sync. + Works for any decimation count (including 1). All actuators must be + graph-safe so the entire loop can be captured as a single CUDA graph. """ - cls._simulate_physics_only() + physics_dt = cls._solver_dt * cls._num_substeps + contacts = cls._contacts if cls._needs_collision_pipeline else None - if cls._usdrt_stage is not None: - cls._mark_transforms_dirty() + for _ in range(cls._decimation): + if cls._needs_collision_pipeline: + cls._collision_pipeline.collide(cls._state_0, cls._contacts) + + if cls._adapter is not None: + cls._adapter.step(cls._state_0, cls._control, physics_dt) + for cb in cls._post_actuator_callbacks: + cb() + + cls._run_solver_substeps(contacts) + + cls._update_sensors(contacts) + + @classmethod + def _simulate_physics_only(cls) -> None: + """Collision + solver substeps + sensors (no actuators, no USD sync). + + Used when actuators are stepped eagerly outside the graph, or when + there are no actuators at all. + """ + if cls._needs_collision_pipeline: + cls._collision_pipeline.collide(cls._state_0, cls._contacts) + contacts = cls._contacts + else: + contacts = None + + cls._run_solver_substeps(contacts) + cls._update_sensors(contacts) @classmethod def get_solver_convergence_steps(cls) -> dict[str, float | int]: @@ -1605,6 +1726,97 @@ def get_solver_dt(cls) -> float: """Get the solver substep timestep.""" return cls._solver_dt + @classmethod + def _is_all_graphable(cls) -> bool: + """``True`` when the decimation loop can be captured into a CUDA graph. + + Requires: + 1. An articulation took the ``use_newton_actuators=True`` branch + (signalled via :meth:`activate_newton_actuator_path`). + 2. Either no actuator adapter was needed (all-implicit) or every + actuator in the adapter is CUDA-graph-safe. + """ + if not cls._use_newton_actuators_active: + return False + return cls._adapter is None or cls._adapter.is_all_graphable + + @classmethod + def activate_newton_actuator_path(cls) -> None: + """Opt an articulation into the Newton actuator fast path. + + Idempotent — called by every Newton-fast-path articulation's + ``_process_actuators_cfg``: + + 1. Sets :attr:`_use_newton_actuators_active`, which + :meth:`_is_all_graphable` checks (adapter presence alone + cannot distinguish the fast path from the standard Lab path). + 2. On first call, builds the single sim-level + :class:`NewtonActuatorAdapter` over the full flat DOF layout; + later calls reuse it. + """ + # Shared state lives on the base class so all readers (including + # framework code that imports ``NewtonManager`` directly) see the + # same flag regardless of which solver subclass is active. + NewtonManager._use_newton_actuators_active = True + + if cls._adapter is not None: + return + if cls._model is None or not cls._model.actuators: + return + from isaaclab_newton.actuators import NewtonActuatorAdapter # noqa: PLC0415 + + dofs_per_env = cls._model.joint_dof_count // cls._num_envs + NewtonManager._adapter = NewtonActuatorAdapter( + actuators=list(cls._model.actuators), + num_envs=cls._num_envs, + num_joints=dofs_per_env, + dof_offset=0, + device=PhysicsManager._device, + ) + cls._adapter.finalize(cls._control) + + @classmethod + def register_post_actuator_callback(cls, callback: Callable[[], None]) -> None: + """Append a hook to the list invoked after the actuator step on every iteration. + + Each callback runs inside the captured CUDA graph (when + :meth:`_is_all_graphable` is ``True``) right after + :meth:`NewtonActuatorAdapter.step` and before the solver substeps, + so kernel writes to ``state``/``control`` are visible to the + integrator on the same iteration. Multiple articulations register + their own implicit-DOF telemetry / FF-routing kernels here; all + registered callbacks fire in registration order each step. + """ + cls._post_actuator_callbacks.append(callback) + + @classmethod + def set_decimation(cls, decimation: int) -> None: + """Set the decimation count and re-capture the CUDA graph. + + When all actuators are graphable the entire decimation loop + (actuators + solver substeps, repeated *decimation* times) + is captured as a single CUDA graph. + + If a CUDA graph was previously captured, it is automatically + re-captured with the new decimation count using the same + strategy as :meth:`start_simulation`: standard + ``wp.ScopedCapture`` when no USDRT stage is active, or + deferred relaxed capture when RTX is running. + """ + cls._decimation = max(1, decimation) + if cls._is_all_graphable(): + cls._capture_or_defer_graph() + + @classmethod + def handles_decimation(cls) -> bool: + """``True`` when :meth:`step` executes the full decimation loop internally. + + This is the case when all Newton actuators are CUDA-graph-safe. + The full decimation loop (including the trivial ``decimation=1`` case) + is folded into a single :meth:`step` call. + """ + return cls._is_all_graphable() + @classmethod def add_contact_sensor( cls, diff --git a/source/isaaclab_newton/isaaclab_newton/test/mock_interfaces/views/mock_articulation_view.py b/source/isaaclab_newton/isaaclab_newton/test/mock_interfaces/views/mock_articulation_view.py index 26761937667e..a75647624e5b 100644 --- a/source/isaaclab_newton/isaaclab_newton/test/mock_interfaces/views/mock_articulation_view.py +++ b/source/isaaclab_newton/isaaclab_newton/test/mock_interfaces/views/mock_articulation_view.py @@ -97,7 +97,9 @@ def _ensure_root_velocities(self) -> wp.array: def _ensure_attribute(self, name: str) -> wp.array: if self._attributes[name] is None: self._attributes[name] = self._create_default_attribute(name) - return self._attributes[name] + value = self._attributes[name] + assert value is not None + return value def _create_default_attribute(self, name: str) -> wp.array: N, B = self._num_envs, self._num_bodies @@ -250,6 +252,7 @@ def __init__( "joint_effort_limit": None, "body_f": None, "joint_f": None, + "joint_act": None, "joint_target_pos": None, "joint_target_vel": None, "joint_limit_ke": None, @@ -358,7 +361,9 @@ def _ensure_attribute(self, name: str) -> wp.array: """Lazily create an attribute array.""" if self._attributes[name] is None: self._attributes[name] = self._create_default_attribute(name) - return self._attributes[name] + value = self._attributes[name] + assert value is not None + return value def _create_default_attribute(self, name: str) -> wp.array: """Create a default attribute array based on name.""" @@ -383,6 +388,7 @@ def _create_default_attribute(self, name: str) -> wp.array: "joint_velocity_limit", "joint_effort_limit", "joint_f", + "joint_act", "joint_target_pos", "joint_target_vel", "joint_limit_ke", @@ -658,6 +664,7 @@ def set_random_mock_data(self) -> None: "joint_velocity_limit", "joint_effort_limit", "joint_f", + "joint_act", "joint_target_pos", "joint_target_vel", "joint_limit_ke", diff --git a/source/isaaclab_newton/setup.py b/source/isaaclab_newton/setup.py index 37dd583df959..40a6b996397f 100644 --- a/source/isaaclab_newton/setup.py +++ b/source/isaaclab_newton/setup.py @@ -60,6 +60,7 @@ def run(self): extras_require=EXTRAS_REQUIRE, packages=[ "isaaclab_newton", + "isaaclab_newton.actuators", "isaaclab_newton.assets", "isaaclab_newton.assets.articulation", "isaaclab_newton.assets.rigid_object", diff --git a/source/isaaclab_newton/test/assets/test_newton_actuators_newton.py b/source/isaaclab_newton/test/assets/test_newton_actuators_newton.py new file mode 100644 index 000000000000..49d4da05234e --- /dev/null +++ b/source/isaaclab_newton/test/assets/test_newton_actuators_newton.py @@ -0,0 +1,1438 @@ +# 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 + +"""PD actuator equivalence tests on ANYmal-C (floating-base quadruped). + +Compares IsaacLab-native actuators against Newton-native actuators (created +from the same Lab configs via USD authoring) on the Newton physics backend. +Both paths must produce identical joint trajectories within tolerance. + +Using ANYmal-C — a 12-DOF quadruped on a floating base — exercises the +coordinate-vs-DOF index separation that is critical when free joints shift +the mapping between ``joint_q`` (coordinate layout) and ``joint_qd`` +(DOF layout). + +Each test class overrides ANYmal's default actuators with a specific Lab +config (IdealPD, DCMotor, or mixed) and verifies Lab vs Newton equivalence. +""" + +from isaaclab.app import AppLauncher + +simulation_app = AppLauncher(headless=True).app + +import json +import os +import tempfile +import unittest + +import torch +import warp as wp +from isaaclab_newton.assets import Articulation +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import NewtonManager as SimulationManager + +import isaaclab.sim as sim_utils +from isaaclab.actuators import DCMotorCfg, DelayedPDActuatorCfg, IdealPDActuatorCfg, ImplicitActuatorCfg +from isaaclab.envs import ManagerBasedRLEnv +from isaaclab.sim import SimulationCfg, build_simulation_context + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.manager_based.locomotion.velocity.config.g1.flat_env_cfg import G1FlatEnvCfg + +from isaaclab_assets import ANYMAL_C_CFG + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +NUM_ENVS = 2 +NUM_STEPS = 10 +DT = 1.0 / 120.0 +TARGET_OFFSET = 0.1 # [rad] added to initial joint positions + +NEWTON_CFG = NewtonCfg( + solver_cfg=MJWarpSolverCfg( + njmax=500, + nconmax=500, + ls_iterations=20, + cone="pyramidal", + impratio=1, + ls_parallel=False, + integrator="implicitfast", + ), + num_substeps=1, + debug_mode=False, + use_cuda_graph=False, +) + +# --------------------------------------------------------------------------- +# Actuator configurations under test +# --------------------------------------------------------------------------- + +IDEAL_PD_ACTUATORS = { + "legs": IdealPDActuatorCfg( + joint_names_expr=[".*HAA", ".*HFE", ".*KFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), +} + +DC_MOTOR_ACTUATORS = { + "legs": DCMotorCfg( + joint_names_expr=[".*HAA", ".*HFE", ".*KFE"], + saturation_effort=120.0, + effort_limit=80.0, + velocity_limit=7.5, + stiffness={".*": 40.0}, + damping={".*": 5.0}, + ), +} + +MIXED_ACTUATORS = { + "hips": IdealPDActuatorCfg( + joint_names_expr=[".*HAA"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + "knees": DCMotorCfg( + joint_names_expr=[".*HFE", ".*KFE"], + saturation_effort=120.0, + effort_limit=80.0, + velocity_limit=7.5, + stiffness={".*": 40.0}, + damping={".*": 5.0}, + ), +} + +# --------------------------------------------------------------------------- +# Simulation runner +# --------------------------------------------------------------------------- + + +def _run_simulation( + actuators: dict, + use_newton_actuators: bool, + *, + dt: float = DT, + newton_cfg: NewtonCfg = NEWTON_CFG, + num_steps: int = NUM_STEPS, + decimation: int = 1, + feedforward: float | None = None, +) -> dict: + """Run ANYmal-C and return recorded trajectories + telemetry. + + Always records ``joint_pos``, ``joint_vel``, ``computed_torque``, and + ``applied_torque`` so callers don't need a separate "with telemetry" + runner. Optionally applies a constant per-DOF feedforward effort target. + + Args: + actuators: Actuator config dict overriding ANYmal's defaults. + use_newton_actuators: Use Newton-native actuators when ``True``. + dt: Physics timestep [s]. + newton_cfg: Newton physics configuration. + num_steps: Number of policy-level steps. + decimation: Actuator steps per policy step (Newton's CUDA-graph + d-loop is used when all-graphable; otherwise an explicit Python + inner loop). + feedforward: When not ``None``, set a constant per-DOF feedforward + effort target. Used by the implicit-FF equivalence test. + + Returns: + Dict with ``joint_pos``, ``joint_vel``, ``computed_torque``, + ``applied_torque`` (lists of per-step tensors) plus ``target_pos`` + and ``target_vel`` snapshots. + """ + sim_cfg = SimulationCfg(dt=dt, physics=newton_cfg, use_newton_actuators=use_newton_actuators) + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0, 0)) + art_cfg = ANYMAL_C_CFG.replace(actuators=actuators, prim_path="/World/Env_.*/Robot") + articulation = Articulation(art_cfg) + sim.reset() + assert articulation.is_initialized + + if use_newton_actuators and decimation > 1: + SimulationManager.set_decimation(decimation) + + handles_dec = ( + use_newton_actuators + and decimation > 1 + and SimulationManager._is_all_graphable() + and SimulationManager._decimation > 1 + ) + + init_pos = wp.to_torch(articulation.data.joint_pos).clone() + target_pos = init_pos + TARGET_OFFSET + target_vel = torch.zeros_like(init_pos) + articulation.set_joint_position_target_index(target=target_pos) + articulation.set_joint_velocity_target_index(target=target_vel) + if feedforward is not None: + articulation.set_joint_effort_target_index( + target=torch.full_like(init_pos, feedforward), + ) + + recorded_pos, recorded_vel = [], [] + recorded_computed, recorded_applied = [], [] + for _ in range(num_steps): + if handles_dec: + articulation.write_data_to_sim() + sim.step() + articulation.update(dt * decimation) + else: + for _ in range(decimation): + articulation.write_data_to_sim() + sim.step() + articulation.update(dt) + recorded_pos.append(wp.to_torch(articulation.data.joint_pos).clone()) + recorded_vel.append(wp.to_torch(articulation.data.joint_vel).clone()) + recorded_computed.append(wp.to_torch(articulation.data.computed_torque).clone()) + recorded_applied.append(wp.to_torch(articulation.data.applied_torque).clone()) + + return { + "joint_pos": recorded_pos, + "joint_vel": recorded_vel, + "computed_torque": recorded_computed, + "applied_torque": recorded_applied, + "target_pos": target_pos.clone(), + "target_vel": target_vel.clone(), + } + + +# --------------------------------------------------------------------------- +# Base test class +# --------------------------------------------------------------------------- + + +class _EquivalenceTestBase(unittest.TestCase): + """Base for Lab-vs-Newton equivalence tests. + + Subclasses set ``actuators`` to the config under test. ``setUpClass`` + runs the simulation with both ``use_newton_actuators=False`` (Lab path) + and ``True`` (Newton path) and stores the results. + """ + + __test__ = False + actuators: dict = {} + feedforward: float | None = None + dt: float = DT + newton_cfg: NewtonCfg = NEWTON_CFG + num_steps: int = NUM_STEPS + decimation: int = 1 + pos_atol: float = 2e-3 + pos_rtol: float = 1e-3 + vel_atol: float = 1e-2 + vel_rtol: float = 1e-2 + torque_atol: float = 1e-3 + torque_rtol: float = 1e-3 + + @classmethod + def setUpClass(cls): + kwargs = dict( + feedforward=cls.feedforward, + dt=cls.dt, + newton_cfg=cls.newton_cfg, + num_steps=cls.num_steps, + decimation=cls.decimation, + ) + cls.lab_result = _run_simulation(cls.actuators, use_newton_actuators=False, **kwargs) + cls.newton_result = _run_simulation(cls.actuators, use_newton_actuators=True, **kwargs) + + def test_joint_positions_match(self): + for step_i, (lab, newton) in enumerate(zip(self.lab_result["joint_pos"], self.newton_result["joint_pos"])): + torch.testing.assert_close( + lab, + newton, + atol=self.pos_atol, + rtol=self.pos_rtol, + msg=f"Joint positions diverged at step {step_i}", + ) + + def test_joint_velocities_match(self): + for step_i, (lab, newton) in enumerate(zip(self.lab_result["joint_vel"], self.newton_result["joint_vel"])): + torch.testing.assert_close( + lab, + newton, + atol=self.vel_atol, + rtol=self.vel_rtol, + msg=f"Joint velocities diverged at step {step_i}", + ) + + def test_applied_torque_match(self): + for step_i, (lab, newton) in enumerate( + zip(self.lab_result["applied_torque"], self.newton_result["applied_torque"]) + ): + torch.testing.assert_close( + lab, + newton, + atol=self.torque_atol, + rtol=self.torque_rtol, + msg=f"applied_torque diverged at step {step_i}", + ) + + def test_computed_torque_match(self): + for step_i, (lab, newton) in enumerate( + zip(self.lab_result["computed_torque"], self.newton_result["computed_torque"]) + ): + torch.testing.assert_close( + lab, + newton, + atol=self.torque_atol, + rtol=self.torque_rtol, + msg=f"computed_torque diverged at step {step_i}", + ) + + +# --------------------------------------------------------------------------- +# Equivalence tests with different actuator types +# --------------------------------------------------------------------------- + + +class TestIdealPDEquivalence(_EquivalenceTestBase): + """IdealPDActuator on all 12 joints: Lab vs Newton.""" + + __test__ = True + actuators = IDEAL_PD_ACTUATORS + + +class TestDCMotorEquivalence(_EquivalenceTestBase): + """DCMotor actuator on all 12 joints: Lab vs Newton.""" + + __test__ = True + actuators = DC_MOTOR_ACTUATORS + + +class TestMixedActuatorEquivalence(_EquivalenceTestBase): + """Mixed actuators (IdealPD on HAA, DCMotor on HFE/KFE): Lab vs Newton.""" + + __test__ = True + actuators = MIXED_ACTUATORS + + +MIXED_WITH_IMPLICIT_ACTUATORS = { + "hips": ImplicitActuatorCfg( + joint_names_expr=[".*HAA"], + stiffness=40.0, + damping=5.0, + ), + "thighs": IdealPDActuatorCfg( + joint_names_expr=[".*HFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + "knees": DCMotorCfg( + joint_names_expr=[".*KFE"], + saturation_effort=120.0, + effort_limit=80.0, + velocity_limit=7.5, + stiffness=40.0, + damping=5.0, + ), +} + + +class TestMixedWithImplicitEquivalence(_EquivalenceTestBase): + """Implicit HAA + IdealPD HFE + DCMotor KFE: Lab vs Newton. + + Verifies that implicit actuators (handled by the physics engine's + built-in joint drives) coexist correctly with explicit Newton actuators. + """ + + __test__ = True + actuators = MIXED_WITH_IMPLICIT_ACTUATORS + + +# --------------------------------------------------------------------------- +# Implicit-only fast-path: enable Newton actuator branch with no explicit groups +# --------------------------------------------------------------------------- + +IMPLICIT_ONLY_ACTUATORS = { + "legs": ImplicitActuatorCfg( + joint_names_expr=[".*HAA", ".*HFE", ".*KFE"], + stiffness=40.0, + damping=5.0, + ), +} + + +class TestImplicitOnlyEquivalence(_EquivalenceTestBase): + """All-implicit articulation with ``use_newton_actuators=True``: Lab vs fast-path.""" + + __test__ = True + actuators = IMPLICIT_ONLY_ACTUATORS + + +# --------------------------------------------------------------------------- +# Implicit + non-zero feedforward effort target +# --------------------------------------------------------------------------- + + +class TestImplicitWithFeedforwardEquivalence(_EquivalenceTestBase): + """Implicit-only actuators with a non-zero feedforward effort target. + + Verifies that the user's FF effort lands additively on top of the + simulator's joint-drive PD identically on both Lab and Newton paths. + """ + + __test__ = True + actuators = IMPLICIT_ONLY_ACTUATORS + feedforward = 2.0 + torque_atol = 0.5 + + +# --------------------------------------------------------------------------- +# Multi-articulation Newton scene (regression test for class-attr clobber) +# --------------------------------------------------------------------------- + + +CARTPOLE_EXPLICIT_ACTUATORS = { + "all_joints": IdealPDActuatorCfg( + joint_names_expr=["slider_to_cart", "cart_to_pole"], + stiffness=10.0, + damping=1.0, + effort_limit=100.0, + ), +} + + +def _run_anymal_and_cartpole(use_newton_actuators: bool, *, num_steps: int = NUM_STEPS) -> dict: + """Spawn ANYmal-C + Cartpole per env (different DOF counts, different base types).""" + from isaaclab_assets import CARTPOLE_CFG # noqa: PLC0415 + + sim_cfg = SimulationCfg(dt=DT, physics=NEWTON_CFG, use_newton_actuators=use_newton_actuators) + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 6.0, 0, 0)) + + anymal_cfg = ANYMAL_C_CFG.replace(actuators=IDEAL_PD_ACTUATORS, prim_path="/World/Env_.*/Anymal") + cartpole_cfg = CARTPOLE_CFG.replace( + actuators=CARTPOLE_EXPLICIT_ACTUATORS, + prim_path="/World/Env_.*/Cartpole", + ) + # Stand the cartpole well clear of the anymal. + cartpole_cfg.init_state = cartpole_cfg.init_state.replace(pos=(0.0, 3.0, 2.0)) + + anymal = Articulation(anymal_cfg) + cartpole = Articulation(cartpole_cfg) + sim.reset() + assert anymal.is_initialized and cartpole.is_initialized + + init_anymal = wp.to_torch(anymal.data.joint_pos).clone() + init_cartpole = wp.to_torch(cartpole.data.joint_pos).clone() + anymal.set_joint_position_target_index(target=init_anymal + TARGET_OFFSET) + anymal.set_joint_velocity_target_index(target=torch.zeros_like(init_anymal)) + cartpole.set_joint_position_target_index(target=init_cartpole + TARGET_OFFSET) + cartpole.set_joint_velocity_target_index(target=torch.zeros_like(init_cartpole)) + + pos_anymal, pos_cartpole = [], [] + for _ in range(num_steps): + anymal.write_data_to_sim() + cartpole.write_data_to_sim() + sim.step() + anymal.update(DT) + cartpole.update(DT) + pos_anymal.append(wp.to_torch(anymal.data.joint_pos).clone()) + pos_cartpole.append(wp.to_torch(cartpole.data.joint_pos).clone()) + + return {"joint_pos_anymal": pos_anymal, "joint_pos_cartpole": pos_cartpole} + + +class TestHeterogeneousMultiArticulationNewton(unittest.TestCase): + """Two structurally-different articulations (ANYmal floating + Cartpole fixed) on Newton. + + Regression for the singleton-clobber bug in ``NewtonManager._adapter`` + / ``_post_actuator_callback`` — fixed by the global-adapter refactor + + callback-list multiplexing. Heterogeneous DOF counts (12 vs 2) and + base types (floating vs fixed) stress the global adapter's handling + of varied actuator index patterns. Equivalence against the Lab + actuator path is the meaningful end-to-end check: divergence on + either robot would indicate broken stepping. + """ + + @classmethod + def setUpClass(cls): + cls.lab_result = _run_anymal_and_cartpole(use_newton_actuators=False) + cls.newton_result = _run_anymal_and_cartpole(use_newton_actuators=True) + + def test_anymal_matches_lab(self): + for step_i, (lab, newton) in enumerate( + zip(self.lab_result["joint_pos_anymal"], self.newton_result["joint_pos_anymal"]) + ): + torch.testing.assert_close( + newton, + lab, + atol=2e-3, + rtol=1e-3, + msg=f"ANYmal joint_pos diverged from Lab path at step {step_i}", + ) + + def test_cartpole_matches_lab(self): + for step_i, (lab, newton) in enumerate( + zip(self.lab_result["joint_pos_cartpole"], self.newton_result["joint_pos_cartpole"]) + ): + torch.testing.assert_close( + newton, + lab, + atol=2e-3, + rtol=1e-3, + msg=f"Cartpole joint_pos diverged from Lab path at step {step_i}", + ) + + +# --------------------------------------------------------------------------- +# Domain randomization via events.py — Newton backend +# --------------------------------------------------------------------------- + + +class _MockScene: + """Minimal stand-in for ``InteractiveScene`` accepted by ``ManagerTermBase``.""" + + def __init__(self, assets: dict, num_envs: int): + self._assets = assets + self.num_envs = num_envs + + def __getitem__(self, name: str): + return self._assets[name] + + +class _MockEnv: + """Minimal stand-in for ``ManagerBasedEnv`` for invoking DR terms. + + ``randomize_actuator_gains`` only reads ``env.scene[name]`` and + ``env.scene.num_envs`` (plus ``env.num_envs`` / ``env.device`` from the + ``ManagerTermBase`` properties). No simulator access is needed because + the DR term reaches the actuator adapter via ``self.asset.newton_actuator_adapter``. + """ + + def __init__(self, assets: dict, num_envs: int, device: str): + self.scene = _MockScene(assets, num_envs) + self.num_envs = num_envs + self.device = device + + +def _build_dr_term(env, asset_name, joint_ids=None): + from isaaclab.envs.mdp.events import randomize_actuator_gains # noqa: PLC0415 + from isaaclab.managers import EventTermCfg, SceneEntityCfg # noqa: PLC0415 + + asset_cfg = SceneEntityCfg(asset_name) + if joint_ids is not None: + asset_cfg.joint_ids = joint_ids + cfg = EventTermCfg( + func=randomize_actuator_gains, + params={ + "asset_cfg": asset_cfg, + "stiffness_distribution_params": (100.0, 100.0), + "damping_distribution_params": (5.0, 5.0), + "operation": "abs", + "distribution": "uniform", + }, + ) + return randomize_actuator_gains(cfg, env), asset_cfg + + +class TestRandomizeActuatorGainsViaEventsNewton(unittest.TestCase): + """End-to-end DR test for the Newton backend. + + Drives ``randomize_actuator_gains`` (events.py) and verifies the new + kp/kd values land on the controllers of the articulation's Newton + actuators — exercising the full path: events → + ``write_actuator_stiffness_to_sim`` → per-actuator + ``ArticulationView.set_actuator_parameter`` (with the per-DOF mapping + silently skipping actuators that belong to other articulations). + + With ``operation="abs"`` and ``distribution="uniform"`` over a + degenerate range ``(K, K)``, every randomized cell is set to exactly + ``K`` — so the assertions are deterministic. + """ + + @staticmethod + def _gather_param(articulation, attr) -> torch.Tensor: + """Read ``controller.`` for every Newton actuator via the view. + + Iterates the global adapter's actuator list. ``get_actuator_parameter`` + returns zeros for DOFs that don't belong to this articulation's + view (the per-DOF mapping skips them), so summing across all + actuators yields a clean ``(num_envs, num_joints)`` snapshot for + this articulation. + """ + n_env = articulation.num_instances + n_j = articulation.num_joints + out = torch.zeros((n_env, n_j), device=articulation.device) + adapter = SimulationManager._adapter + if adapter is None: + return out + for act in adapter.actuators: + ctrl = act.controller + if not hasattr(ctrl, attr): + continue + cur_wp = articulation._root_view.get_actuator_parameter(act, ctrl, attr) + out += wp.to_torch(cur_wp) + return out + + def test_single_articulation(self): + sim_cfg = SimulationCfg(dt=DT, physics=NEWTON_CFG, use_newton_actuators=True) + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0, 0)) + art_cfg = ANYMAL_C_CFG.replace( + actuators=IDEAL_PD_ACTUATORS, + prim_path="/World/Env_.*/Robot", + ) + anymal = Articulation(art_cfg) + sim.reset() + + adapter = SimulationManager._adapter + self.assertIsNotNone(adapter, "Newton adapter should exist with use_newton_actuators=True") + kp_before = self._gather_param(anymal, "kp").clone() + kd_before = self._gather_param(anymal, "kd").clone() + + env = _MockEnv({"robot": anymal}, NUM_ENVS, anymal.device) + term, asset_cfg = _build_dr_term(env, "robot") + env_ids = torch.tensor([0], device=anymal.device, dtype=torch.long) + + term( + env, + env_ids=env_ids, + asset_cfg=asset_cfg, + stiffness_distribution_params=(100.0, 100.0), + damping_distribution_params=(5.0, 5.0), + operation="abs", + distribution="uniform", + ) + + kp_after = self._gather_param(anymal, "kp") + kd_after = self._gather_param(anymal, "kd") + n = anymal.num_joints + torch.testing.assert_close(kp_after[0], torch.full((n,), 100.0, device=anymal.device)) + torch.testing.assert_close(kd_after[0], torch.full((n,), 5.0, device=anymal.device)) + # Other envs untouched. + for env_idx in range(1, NUM_ENVS): + torch.testing.assert_close(kp_after[env_idx], kp_before[env_idx]) + torch.testing.assert_close(kd_after[env_idx], kd_before[env_idx]) + + def test_two_articulations(self): + from isaaclab_assets import CARTPOLE_CFG # noqa: PLC0415 + + sim_cfg = SimulationCfg(dt=DT, physics=NEWTON_CFG, use_newton_actuators=True) + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 6.0, 0, 0)) + + anymal_cfg = ANYMAL_C_CFG.replace(actuators=IDEAL_PD_ACTUATORS, prim_path="/World/Env_.*/Anymal") + cartpole_cfg = CARTPOLE_CFG.replace( + actuators=CARTPOLE_EXPLICIT_ACTUATORS, + prim_path="/World/Env_.*/Cartpole", + ) + cartpole_cfg.init_state = cartpole_cfg.init_state.replace(pos=(0.0, 3.0, 2.0)) + anymal = Articulation(anymal_cfg) + cartpole = Articulation(cartpole_cfg) + sim.reset() + + self.assertIsNotNone(SimulationManager._adapter) + + anymal_kp_before = self._gather_param(anymal, "kp").clone() + anymal_kd_before = self._gather_param(anymal, "kd").clone() + cp_kp_before = self._gather_param(cartpole, "kp").clone() + cp_kd_before = self._gather_param(cartpole, "kd").clone() + + env = _MockEnv({"anymal": anymal, "cartpole": cartpole}, NUM_ENVS, anymal.device) + term, asset_cfg = _build_dr_term(env, "cartpole") + env_ids = torch.tensor([0], device=anymal.device, dtype=torch.long) + + term( + env, + env_ids=env_ids, + asset_cfg=asset_cfg, + stiffness_distribution_params=(100.0, 100.0), + damping_distribution_params=(5.0, 5.0), + operation="abs", + distribution="uniform", + ) + + cp_kp_after = self._gather_param(cartpole, "kp") + cp_kd_after = self._gather_param(cartpole, "kd") + n_cp = cartpole.num_joints + torch.testing.assert_close(cp_kp_after[0], torch.full((n_cp,), 100.0, device=anymal.device)) + torch.testing.assert_close(cp_kd_after[0], torch.full((n_cp,), 5.0, device=anymal.device)) + + # ANYmal is untouched (DR was scoped to cartpole). + torch.testing.assert_close(self._gather_param(anymal, "kp"), anymal_kp_before) + torch.testing.assert_close(self._gather_param(anymal, "kd"), anymal_kd_before) + + # Cartpole's other envs are also untouched (env_ids=[0] only). + for env_idx in range(1, NUM_ENVS): + torch.testing.assert_close(cp_kp_after[env_idx], cp_kp_before[env_idx]) + torch.testing.assert_close(cp_kd_after[env_idx], cp_kd_before[env_idx]) + + +# --------------------------------------------------------------------------- +# DelayedPD equivalence: PD with actuator command delay +# --------------------------------------------------------------------------- + +DELAYED_PD_ACTUATORS = { + "legs": DelayedPDActuatorCfg( + joint_names_expr=[".*HAA", ".*HFE", ".*KFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + min_delay=2, + max_delay=4, + ), +} + + +class TestDelayedPDEquivalence(_EquivalenceTestBase): + """DelayedPDActuator on all 12 joints: Lab vs Newton. + + Verifies that actuator command delays are correctly authored as + ``NewtonActuatorDelayAPI`` and produce matching trajectories. + """ + + __test__ = True + actuators = DELAYED_PD_ACTUATORS + + +class TestDelayedPDAuthoring(unittest.TestCase): + """Verify DelayedPDActuatorCfg is authored with NewtonActuatorDelayAPI.""" + + @classmethod + def setUpClass(cls): + cls.result = _run_authoring_introspection(DELAYED_PD_ACTUATORS) + + def test_has_delay(self): + for a in self.result["actuator_info"]: + self.assertTrue(a["has_delay"], "Delay not found on delayed PD actuator") + + def test_controller_is_pd(self): + for a in self.result["actuator_info"]: + self.assertEqual(a["controller_type"], "ControllerPD") + + +# --------------------------------------------------------------------------- +# Decimation tests: re-run equivalence with decimation > 1 + CUDA graph capture +# --------------------------------------------------------------------------- + +NEWTON_CFG_DEC = NewtonCfg( + solver_cfg=MJWarpSolverCfg( + njmax=500, + nconmax=500, + ls_iterations=20, + cone="pyramidal", + impratio=1, + ls_parallel=False, + integrator="implicitfast", + ), + num_substeps=2, + debug_mode=False, + use_cuda_graph=True, +) + + +class _DecimationMixin: + """Common knobs for decimation/CUDA-graph variants of equivalence classes.""" + + __test__ = True + dt = 1.0 / 100.0 + newton_cfg = NEWTON_CFG_DEC + num_steps = 5 + decimation = 2 + + +class TestDecimationDCMotor(_DecimationMixin, TestDCMotorEquivalence): + """DCMotor — same equivalence checks, with decimation=2 + CUDA graph.""" + + +class TestDecimationIdealPD(_DecimationMixin, TestIdealPDEquivalence): + """IdealPD — decimation=2 + CUDA graph.""" + + +class TestDecimationDelayedPD(_DecimationMixin, TestDelayedPDEquivalence): + """DelayedPD — decimation=2 + CUDA graph (delay queue stepped inside the captured graph).""" + + +class TestDecimationMixed(_DecimationMixin, TestMixedActuatorEquivalence): + """Mixed (IdealPD + DCMotor) — decimation=2 + CUDA graph.""" + + +# --------------------------------------------------------------------------- +# Per-env reset: actuator state isolation +# --------------------------------------------------------------------------- + +RESET_WARMUP_STEPS = 3 + + +class TestActuatorStateReset(unittest.TestCase): + """Reset must clear the actuator state buffers for the requested envs only. + + Inspects ``adapter.actuators[i].state.delay_state.num_pushes`` directly: + + * After warmup, ``num_pushes > 0`` for every DOF (buffer was populated). + * After ``articulation.reset(env_ids=[0])``, the entries for env 0's DOFs + must be ``0`` and the entries for env 1's DOFs must remain ``> 0``. + + Done independently on Lab and Newton paths. Lab inspects the + ``positions_delay_buffer._circular_buffer`` of its DelayedPDActuator; + Newton inspects the model-wide adapter's per-actuator state. + """ + + RESET_ENV: int = 0 + UNCHANGED_ENV: int = 1 + + def _build_and_warm(self, *, use_newton_actuators: bool): + sim_cfg = SimulationCfg( + dt=DT, + physics=NEWTON_CFG, + use_newton_actuators=use_newton_actuators, + ) + ctx = build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) + sim = ctx.__enter__() + sim._app_control_on_stop_handle = None + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0, 0)) + art_cfg = ANYMAL_C_CFG.replace( + actuators=DELAYED_PD_ACTUATORS, + prim_path="/World/Env_.*/Robot", + ) + articulation = Articulation(art_cfg) + sim.reset() + + init_pos = wp.to_torch(articulation.data.joint_pos).clone() + target_pos = init_pos + TARGET_OFFSET + target_vel = torch.zeros_like(init_pos) + articulation.set_joint_position_target_index(target=target_pos) + articulation.set_joint_velocity_target_index(target=target_vel) + for _ in range(RESET_WARMUP_STEPS): + articulation.write_data_to_sim() + sim.step() + articulation.update(DT) + return ctx, sim, articulation + + def test_newton_state_reset_isolated_to_reset_env(self): + """Newton: ``num_pushes`` zeroes for env 0's DOFs only after reset of [0].""" + ctx, sim, articulation = self._build_and_warm(use_newton_actuators=True) + try: + adapter = SimulationManager._adapter + self.assertIsNotNone(adapter) + # Find a DelayedPD actuator (it's the only one with delay_state). + stateful_pairs = [ + (act, st) + for act, st in zip(adapter.actuators, adapter._states_a) + if st is not None and getattr(st, "delay_state", None) is not None + ] + self.assertGreater(len(stateful_pairs), 0, "expected at least one DelayedPD actuator with delay_state") + + # Per-DOF entry layout inside each actuator's state: ``act.indices`` + # is the flat global DOF id; envs are stacked so env 0's DOFs come first. + for act, state in stateful_pairs: + pushes_before = state.delay_state.num_pushes.numpy() + self.assertTrue( + (pushes_before > 0).all(), + "expected non-zero num_pushes for all DOFs after warmup", + ) + + articulation.reset(env_ids=torch.tensor([self.RESET_ENV], device=articulation.device, dtype=torch.long)) + + # Map each entry of ``act.indices`` to its env via the adapter's full + # per-env DOF count (model.joint_dof_count // num_envs — includes free + # joint DOFs on floating-base articulations, unlike articulation.num_joints + # which counts only actuated DOFs). + for act, state in stateful_pairs: + pushes_after = state.delay_state.num_pushes.numpy() + indices_np = act.indices.numpy() + for i, global_dof in enumerate(indices_np): + env = int(global_dof) // adapter.num_joints + if env == self.RESET_ENV: + self.assertEqual( + int(pushes_after[i]), + 0, + f"DOF {i} (env {env}) should be reset to 0, got {pushes_after[i]}", + ) + else: + self.assertGreater( + int(pushes_after[i]), + 0, + f"DOF {i} (env {env}) was NOT in reset env_ids but num_pushes is 0", + ) + finally: + ctx.__exit__(None, None, None) + + def test_lab_state_reset_isolated_to_reset_env(self): + """Lab: DelayedPDActuator circular buffer zeroed for env 0 only.""" + ctx, sim, articulation = self._build_and_warm(use_newton_actuators=False) + try: + from isaaclab.actuators import DelayedPDActuator # noqa: PLC0415 + + delayed = [a for a in articulation.actuators.values() if isinstance(a, DelayedPDActuator)] + self.assertGreater(len(delayed), 0, "expected at least one Lab DelayedPDActuator") + actuator = delayed[0] + buf = actuator.positions_delay_buffer._circular_buffer._buffer + # ``_buffer`` shape: (max_length, batch_size, num_joints). + self.assertIsNotNone(buf, "delay buffer should be populated after warmup") + self.assertTrue( + (buf[:, self.UNCHANGED_ENV] != 0).any().item(), + "expected non-zero buffer entries for env 1 after warmup", + ) + + articulation.reset(env_ids=torch.tensor([self.RESET_ENV], device=articulation.device, dtype=torch.long)) + + self.assertTrue( + torch.all(buf[:, self.RESET_ENV] == 0).item(), + f"Lab: env {self.RESET_ENV} buffer not zeroed after reset.", + ) + self.assertTrue( + (buf[:, self.UNCHANGED_ENV] != 0).any().item(), + f"Lab: env {self.UNCHANGED_ENV} buffer was zeroed — reset leaked into an unselected env.", + ) + finally: + ctx.__exit__(None, None, None) + + +# --------------------------------------------------------------------------- +# RemotizedPD actuator: PD + delay + position-based clamping lookup table +# --------------------------------------------------------------------------- + +SPOT_KNEE_LOOKUP = [ + [-2.792900, -24.776718, 37.165077], + [-2.767442, -26.290108, 39.435162], + [-2.741984, -27.793369, 41.690054], + [-2.716526, -29.285997, 43.928996], + [-2.691068, -30.767536, 46.151304], + [-2.665610, -32.237423, 48.356134], + [-2.640152, -33.695168, 50.542751], + [-2.614694, -35.140221, 52.710331], + [-2.589236, -36.572052, 54.858078], + [-2.563778, -37.990086, 56.985128], + [-2.538320, -39.393730, 59.090595], + [-2.512862, -40.782406, 61.173609], + [-2.487404, -42.155487, 63.233231], + [-2.461946, -43.512371, 65.268557], + [-2.436488, -44.852371, 67.278557], + [-2.411030, -46.174873, 69.262310], + [-2.385572, -47.479156, 71.218735], + [-2.360114, -48.764549, 73.146824], + [-2.334656, -50.030334, 75.045502], + [-2.309198, -51.275761, 76.913641], + [-2.283740, -52.500103, 78.750154], + [-2.258282, -53.702587, 80.553881], + [-2.232824, -54.882442, 82.323664], + [-2.207366, -56.038860, 84.058290], + [-2.181908, -57.171028, 85.756542], + [-2.156450, -58.278133, 87.417200], + [-2.130992, -59.359314, 89.038971], + [-2.105534, -60.413738, 90.620607], + [-2.080076, -61.440529, 92.160793], + [-2.054618, -62.438812, 93.658218], + [-2.029160, -63.407692, 95.111538], + [-2.003702, -64.346268, 96.519402], + [-1.978244, -65.253670, 97.880505], + [-1.952786, -66.128944, 99.193417], + [-1.927328, -66.971176, 100.456764], + [-1.901870, -67.779457, 101.669186], + [-1.876412, -68.552864, 102.829296], + [-1.850954, -69.290451, 103.935677], + [-1.825496, -69.991325, 104.986988], + [-1.800038, -70.654541, 105.981812], + [-1.774580, -71.279190, 106.918785], + [-1.749122, -71.864319, 107.796478], + [-1.723664, -72.409088, 108.613632], + [-1.698206, -72.912567, 109.368851], + [-1.672748, -73.373871, 110.060806], + [-1.647290, -73.792130, 110.688194], + [-1.621832, -74.166512, 111.249767], + [-1.596374, -74.496147, 111.744221], + [-1.570916, -74.780251, 112.170376], + [-1.545458, -75.017998, 112.526997], + [-1.520000, -75.208656, 112.812984], + [-1.494542, -75.351448, 113.027172], + [-1.469084, -75.445686, 113.168530], + [-1.443626, -75.490677, 113.236015], + [-1.418168, -75.485771, 113.228657], + [-1.392710, -75.430344, 113.145515], + [-1.367252, -75.323830, 112.985744], + [-1.341794, -75.165688, 112.748531], + [-1.316336, -74.955406, 112.433109], + [-1.290878, -74.692551, 112.038826], + [-1.265420, -74.376694, 111.565041], + [-1.239962, -74.007477, 111.011215], + [-1.214504, -73.584579, 110.376869], + [-1.189046, -73.107742, 109.661613], + [-1.163588, -72.576752, 108.865128], + [-1.138130, -71.991455, 107.987183], + [-1.112672, -71.351707, 107.027561], + [-1.087214, -70.657486, 105.986229], + [-1.061756, -69.908813, 104.863220], + [-1.036298, -69.105721, 103.658581], + [-1.010840, -68.248337, 102.372505], + [-0.985382, -67.336861, 101.005291], + [-0.959924, -66.371513, 99.557270], + [-0.934466, -65.352615, 98.028923], + [-0.909008, -64.280533, 96.420799], + [-0.883550, -63.155693, 94.733540], + [-0.858092, -61.978588, 92.967882], + [-0.832634, -60.749775, 91.124662], + [-0.807176, -59.469845, 89.204767], + [-0.781718, -58.139503, 87.209255], + [-0.756260, -56.759487, 85.139231], + [-0.730802, -55.330616, 82.995924], + [-0.705344, -53.853729, 80.780594], + [-0.679886, -52.329796, 78.494694], + [-0.654428, -50.759762, 76.139643], + [-0.628970, -49.144699, 73.717049], + [-0.603512, -47.485737, 71.228605], + [-0.578054, -45.784004, 68.676006], + [-0.552596, -44.040764, 66.061146], + [-0.527138, -42.257267, 63.385900], + [-0.501680, -40.434883, 60.652325], + [-0.476222, -38.574947, 57.862421], + [-0.450764, -36.678982, 55.018473], + [-0.425306, -34.748432, 52.122648], + [-0.399848, -32.784836, 49.177254], + [-0.374390, -30.789810, 46.184715], + [-0.348932, -28.764952, 43.147428], + [-0.323474, -26.711969, 40.067954], + [-0.298016, -24.632576, 36.948864], + [-0.272558, -22.528547, 33.792821], + [-0.247100, -20.401667, 30.602500], +] +"""Spot knee joint parameter lookup table (102 entries). + +Columns: joint angle [rad], transmission ratio, output torque [N*m]. +Sourced from :mod:`isaaclab_assets.robots.spot`. +""" + + +def _run_authoring_introspection(actuator_cfgs: dict) -> dict: + """Instantiate Newton simulation, return Newton actuator introspection. + + Verifies that Lab configs are correctly authored to Newton USD schemas + and that Newton creates the expected controller/clamping/delay objects. + + Returns: + Dict with ``num_actuators``, ``actuator_info`` (list of per-actuator + dicts), and ``joint_pos`` (recorded trajectories). + """ + sim_cfg = SimulationCfg(dt=DT, physics=NEWTON_CFG, use_newton_actuators=True) + + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0, 0)) + + art_cfg = ANYMAL_C_CFG.replace( + actuators=actuator_cfgs, + prim_path="/World/Env_.*/Robot", + ) + articulation = Articulation(art_cfg) + sim.reset() + assert articulation.is_initialized + + model = SimulationManager.get_model() + + actuator_info = [] + for act in model.actuators: + ctrl_type = type(act.controller).__name__ + clamp_types = sorted(type(c).__name__ for c in (act.clamping or [])) + actuator_info.append( + { + "controller_type": ctrl_type, + "clamping_types": clamp_types, + "has_delay": act.delay is not None, + "num_indices": len(act.indices), + } + ) + + init_pos = wp.to_torch(articulation.data.joint_pos).clone() + target_pos = init_pos + TARGET_OFFSET + target_vel = torch.zeros_like(init_pos) + articulation.set_joint_position_target_index(target=target_pos) + articulation.set_joint_velocity_target_index(target=target_vel) + + recorded_pos = [] + for _ in range(NUM_STEPS): + articulation.write_data_to_sim() + sim.step() + articulation.update(DT) + recorded_pos.append(wp.to_torch(articulation.data.joint_pos).clone()) + + return { + "num_actuators": len(model.actuators), + "actuator_info": actuator_info, + "joint_pos": recorded_pos, + } + + +class TestRemotizedPDAuthoring(unittest.TestCase): + """Verify RemotizedPDActuatorCfg is authored as Newton PD + delay + + position-based clamping. + + Uses the Spot knee lookup table (102 entries) on ANYmal's KFE joints, + with IdealPD on HAA and HFE joints. + """ + + @classmethod + def setUpClass(cls): + from isaaclab.actuators.actuator_pd_cfg import RemotizedPDActuatorCfg # noqa: PLC0415 + + cls.result = _run_authoring_introspection( + { + "hips": IdealPDActuatorCfg( + joint_names_expr=[".*HAA", ".*HFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + "knees": RemotizedPDActuatorCfg( + joint_names_expr=[".*KFE"], + stiffness=60.0, + damping=1.5, + effort_limit=80.0, + max_delay=3, + joint_parameter_lookup=SPOT_KNEE_LOOKUP, + ), + } + ) + + def test_num_actuators(self): + self.assertGreaterEqual(self.result["num_actuators"], 2) + + def test_kfe_controller_is_pd(self): + kfe_acts = [a for a in self.result["actuator_info"] if "ClampingPositionBased" in a["clamping_types"]] + self.assertTrue(len(kfe_acts) > 0, "No actuator with position-based clamping found") + for a in kfe_acts: + self.assertEqual(a["controller_type"], "ControllerPD") + + def test_kfe_has_position_based_clamping(self): + kfe_acts = [a for a in self.result["actuator_info"] if "ClampingPositionBased" in a["clamping_types"]] + self.assertTrue(len(kfe_acts) > 0, "Position-based clamping not found") + + def test_kfe_has_delay(self): + kfe_acts = [a for a in self.result["actuator_info"] if "ClampingPositionBased" in a["clamping_types"]] + for a in kfe_acts: + self.assertTrue(a["has_delay"], "Delay not found on remotized KFE actuator") + + +class TestRemotizedPDEquivalence(_EquivalenceTestBase): + """RemotizedPD (PD + delay + position-based clamping): Lab vs Newton.""" + + __test__ = True + + @classmethod + def setUpClass(cls): + from isaaclab.actuators.actuator_pd_cfg import RemotizedPDActuatorCfg # noqa: PLC0415 + + cls.actuators = { + "hips": IdealPDActuatorCfg( + joint_names_expr=[".*HAA", ".*HFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + "knees": RemotizedPDActuatorCfg( + joint_names_expr=[".*KFE"], + stiffness=60.0, + damping=1.5, + effort_limit=80.0, + max_delay=3, + joint_parameter_lookup=SPOT_KNEE_LOOKUP, + ), + } + super().setUpClass() + + +class TestDecimationRemotizedPD(_DecimationMixin, TestRemotizedPDEquivalence): + """RemotizedPD — decimation=2 + CUDA graph.""" + + +class TestManagerBasedSceneNewtonActuatorAuthoring(unittest.TestCase): + """Regression test for Newton actuator authoring in manager-based clone paths. + + The default G1 config uses ``ImplicitActuatorCfg`` for every group, which + intentionally skips ``NewtonActuator`` USD authoring. To exercise the + authoring path we override the scene's robot actuators with explicit + ``DCMotorCfg`` groups covering the same joint patterns. + """ + + def test_newton_actuators_present_for_g1_manager_env(self): + env_cfg = G1FlatEnvCfg() + env_cfg.scene.num_envs = 1 + env_cfg.decimation = 1 + env_cfg.scene.contact_forces = None + env_cfg.rewards.feet_air_time = None + env_cfg.rewards.feet_slide = None + env_cfg.terminations.base_contact = None + env_cfg.sim.physics = NewtonCfg( + solver_cfg=MJWarpSolverCfg( + njmax=95, + nconmax=10, + cone="pyramidal", + impratio=1, + integrator="implicitfast", + ), + num_substeps=1, + debug_mode=False, + ) + env_cfg.sim.use_newton_actuators = True + env_cfg.scene.robot.actuators = { + "legs": DCMotorCfg( + joint_names_expr=[ + ".*_hip_yaw_joint", + ".*_hip_roll_joint", + ".*_hip_pitch_joint", + ".*_knee_joint", + "torso_joint", + ], + saturation_effort=300.0, + effort_limit=300.0, + velocity_limit=20.0, + stiffness=150.0, + damping=5.0, + ), + "feet": DCMotorCfg( + joint_names_expr=[".*_ankle_pitch_joint", ".*_ankle_roll_joint"], + saturation_effort=20.0, + effort_limit=20.0, + velocity_limit=20.0, + stiffness=20.0, + damping=2.0, + ), + "arms": DCMotorCfg( + joint_names_expr=[ + ".*_shoulder_pitch_joint", + ".*_shoulder_roll_joint", + ".*_shoulder_yaw_joint", + ".*_elbow_pitch_joint", + ".*_elbow_roll_joint", + ], + saturation_effort=300.0, + effort_limit=300.0, + velocity_limit=20.0, + stiffness=40.0, + damping=10.0, + ), + } + env = ManagerBasedRLEnv(cfg=env_cfg) + try: + stage = env.unwrapped.sim.stage + actuator_prim_count = sum(1 for prim in stage.Traverse() if prim.GetTypeName() == "NewtonActuator") + self.assertGreater( + actuator_prim_count, + 0, + "Expected authored NewtonActuator prims in manager-based scene workflow.", + ) + self.assertGreater( + len(SimulationManager.get_model().actuators), + 0, + "Expected Newton model actuators to be non-empty with use_newton_actuators=True.", + ) + finally: + env.close() + + +# --------------------------------------------------------------------------- +# Neural network actuator authoring: MLP and LSTM +# --------------------------------------------------------------------------- + + +def _make_dummy_mlp_checkpoint(device: str = "cpu") -> str: + """Create a minimal TorchScript MLP checkpoint with metadata. + + The network accepts 6 inputs (3 history steps x 2 features per step + in pos_vel order) and outputs 1 effort. + """ + torch.manual_seed(42) + net = ( + torch.nn.Sequential( + torch.nn.Linear(6, 8), + torch.nn.ELU(), + torch.nn.Linear(8, 1), + ) + .to(device) + .eval() + ) + scripted = torch.jit.script(net) + + with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as tmp: + tmp_path = tmp.name + extra = { + "metadata.json": json.dumps( + { + "model_type": "mlp", + "input_order": "pos_vel", + "input_idx": [0, 1, 2], + "pos_scale": 1.0, + "vel_scale": 0.5, + "torque_scale": 2.0, + } + ) + } + torch.jit.save(scripted, tmp_path, _extra_files=extra) + return tmp_path + + +class _DummyLSTM(torch.nn.Module): + """Minimal LSTM network for actuator testing.""" + + def __init__(self): + super().__init__() + self.lstm = torch.nn.LSTM(input_size=2, hidden_size=4, num_layers=1, batch_first=True) + self.fc = torch.nn.Linear(4, 1) + + def forward( + self, + x: torch.Tensor, + hc: tuple[torch.Tensor, torch.Tensor], + ) -> tuple[torch.Tensor, tuple[torch.Tensor, torch.Tensor]]: + out, hc_new = self.lstm(x, hc) + return self.fc(out[:, -1, :]), hc_new + + +def _make_dummy_lstm_checkpoint(device: str = "cpu") -> str: + """Create a minimal TorchScript LSTM checkpoint with metadata.""" + torch.manual_seed(42) + net = _DummyLSTM().to(device).eval() + scripted = torch.jit.script(net) + + with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as tmp: + tmp_path = tmp.name + extra = {"metadata.json": json.dumps({"model_type": "lstm"})} + torch.jit.save(scripted, tmp_path, _extra_files=extra) + return tmp_path + + +class TestNeuralMLPAuthoring(unittest.TestCase): + """Verify ActuatorNetMLPCfg is authored as Newton NeuralMLP controller + with DC motor clamping. + """ + + @classmethod + def setUpClass(cls): + from isaaclab.actuators.actuator_net_cfg import ActuatorNetMLPCfg # noqa: PLC0415 + + cls.mlp_path = _make_dummy_mlp_checkpoint() + cls.result = _run_authoring_introspection( + { + "mlp_legs": ActuatorNetMLPCfg( + joint_names_expr=[".*HAA"], + network_file=cls.mlp_path, + saturation_effort=120.0, + effort_limit=80.0, + velocity_limit=7.5, + pos_scale=-1.0, + vel_scale=1.0, + torque_scale=1.0, + input_order="pos_vel", + input_idx=[0, 1, 2], + ), + "pd_legs": IdealPDActuatorCfg( + joint_names_expr=[".*HFE", ".*KFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + } + ) + + @classmethod + def tearDownClass(cls): + os.unlink(cls.mlp_path) + + def test_num_actuators(self): + self.assertGreaterEqual(self.result["num_actuators"], 2) + + def test_has_neural_mlp_controller(self): + mlp_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "ControllerNeuralMLP"] + self.assertTrue(len(mlp_acts) > 0, "No NeuralMLP controller found") + + def test_mlp_has_dc_motor_clamping(self): + mlp_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "ControllerNeuralMLP"] + for a in mlp_acts: + self.assertIn("ClampingDCMotor", a["clamping_types"]) + + +class TestNeuralLSTMAuthoring(unittest.TestCase): + """Verify ActuatorNetLSTMCfg is authored as Newton NeuralLSTM controller + with DC motor clamping. + """ + + @classmethod + def setUpClass(cls): + from isaaclab.actuators.actuator_net_cfg import ActuatorNetLSTMCfg # noqa: PLC0415 + + cls.lstm_path = _make_dummy_lstm_checkpoint() + cls.result = _run_authoring_introspection( + { + "lstm_legs": ActuatorNetLSTMCfg( + joint_names_expr=[".*HAA"], + network_file=cls.lstm_path, + saturation_effort=120.0, + effort_limit=80.0, + velocity_limit=7.5, + ), + "pd_legs": IdealPDActuatorCfg( + joint_names_expr=[".*HFE", ".*KFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + } + ) + + @classmethod + def tearDownClass(cls): + os.unlink(cls.lstm_path) + + def test_num_actuators(self): + self.assertGreaterEqual(self.result["num_actuators"], 2) + + def test_has_neural_lstm_controller(self): + lstm_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "ControllerNeuralLSTM"] + self.assertTrue(len(lstm_acts) > 0, "No NeuralLSTM controller found") + + def test_lstm_has_dc_motor_clamping(self): + lstm_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "ControllerNeuralLSTM"] + for a in lstm_acts: + self.assertIn("ClampingDCMotor", a["clamping_types"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/source/isaaclab_physx/changelog.d/antoiner-refactor-pr5455-followups.rst b/source/isaaclab_physx/changelog.d/antoiner-refactor-pr5455-followups.rst new file mode 100644 index 000000000000..5d20d3888780 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/antoiner-refactor-pr5455-followups.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Reworded the FF-routing comments in + :class:`~isaaclab_physx.assets.Articulation` to refer to "actuated DOFs" + rather than splitting on implicit vs explicit, since the + ``synch_torque_and_apply_implicit_feedforwards`` kernel operates on the full + actuated DOF set. diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index ea2c85e4c3bd..78fdb387aa04 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -8,6 +8,7 @@ from __future__ import annotations +import importlib.util import logging import warnings from collections.abc import Sequence @@ -22,6 +23,10 @@ from isaaclab.actuators import ActuatorBase, ActuatorBaseCfg, ImplicitActuator from isaaclab.assets.articulation.base_articulation import BaseArticulation + +_HAS_NEWTON_ACTUATORS = importlib.util.find_spec("isaaclab_newton.actuators") is not None + + from isaaclab.sim.utils.queries import find_first_matching_prim, get_all_matching_child_prims from isaaclab.utils.string import resolve_matching_names, resolve_matching_names_values from isaaclab.utils.types import ArticulationActions @@ -35,6 +40,8 @@ from .articulation_data import ArticulationData if TYPE_CHECKING: + from isaaclab_newton.actuators import NewtonActuatorAdapter + import omni.physics.tensors.api as physx from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg @@ -111,8 +118,13 @@ def __init__(self, cfg: ArticulationCfg): Args: cfg: A configuration instance. """ + from isaaclab.sim import SimulationContext # noqa: PLC0415 + super().__init__(cfg) + sim_ctx = SimulationContext.instance() + self._sim_cfg = sim_ctx.cfg if sim_ctx is not None else None + """ Properties """ @@ -217,9 +229,15 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None # use ellipses object to skip initial indices. if (env_ids is None) or (env_ids == slice(None)): env_ids = slice(None) - # reset actuators + # reset actuators (including Newton-native adapter which owns its states) for actuator in self.actuators.values(): actuator.reset(env_ids) + # Reset Newton-actuator per-env states (delay queues, neural hidden state, etc.). + # The adapter is per-articulation on PhysX and is not part of ``self.actuators``. + # ``getattr`` guards subclasses (e.g. ``Multirotor``) that override + # ``_process_actuators_cfg`` and never initialize these attributes. + if getattr(self, "_has_newton_actuators", False) and getattr(self, "newton_actuator_adapter", None) is not None: + self.newton_actuator_adapter.reset(env_ids) # reset external wrenches. self._instantaneous_wrench_composer.reset(env_ids, env_mask) self._permanent_wrench_composer.reset(env_ids, env_mask) @@ -239,30 +257,40 @@ def write_data_to_sim(self): if self._instantaneous_wrench_composer.active: composer = self._instantaneous_wrench_composer composer.add_raw_buffers_from(self._permanent_wrench_composer) - get_force_data = self._get_inst_wrench_force_f32 - get_torque_data = self._get_inst_wrench_torque_f32 else: composer = self._permanent_wrench_composer - get_force_data = self._get_perm_wrench_force_f32 - get_torque_data = self._get_perm_wrench_torque_f32 composer.compose_to_body_frame() self.root_view.apply_forces_and_torques_at_position( - force_data=get_force_data(), - torque_data=get_torque_data(), + force_data=composer.out_force_b.warp.flatten().view(wp.float32), + torque_data=composer.out_torque_b.warp.flatten().view(wp.float32), position_data=None, indices=self._ALL_INDICES, is_global=False, ) self._instantaneous_wrench_composer.reset() - # apply actuator models - self._apply_actuator_model() - # write actions into simulation - self.root_view.set_dof_actuation_forces(self._joint_effort_target_sim, self._ALL_INDICES) - # position and velocity targets only for implicit actuators - if self._has_implicit_actuators: - self.root_view.set_dof_position_targets(self._joint_pos_target_sim, self._ALL_INDICES) - self.root_view.set_dof_velocity_targets(self._joint_vel_target_sim, self._ALL_INDICES) + if getattr(self, "_has_newton_actuators", False): + # Newton fast path: pos/vel targets pass straight through; the + # in-graph kernel inside ``_apply_actuator_model_newton`` merges + # Newton's actuator output (explicit DOFs) with user FF + # (implicit DOFs) into ``w.joint_f_2d``, which is what we push + # to PhysX as the actuation force. + self._apply_actuator_model_newton() + self.root_view.set_dof_actuation_forces( + self._physx_actuator_wrapper.joint_f_2d, + self._ALL_INDICES, + ) + if self._has_implicit_actuators: + self.root_view.set_dof_position_targets(self._data._joint_pos_target, self._ALL_INDICES) + self.root_view.set_dof_velocity_targets(self._data._joint_vel_target, self._ALL_INDICES) + else: + # Standard Lab actuator path: per-group ``actuator.compute()`` may + # transform targets, so we push the staging buffers PhysX-side. + self._apply_actuator_model() + self.root_view.set_dof_actuation_forces(self._joint_effort_target_sim, self._ALL_INDICES) + if self._has_implicit_actuators: + self.root_view.set_dof_position_targets(self._joint_pos_target_sim, self._ALL_INDICES) + self.root_view.set_dof_velocity_targets(self._joint_vel_target_sim, self._ALL_INDICES) def update(self, dt: float): """Updates the simulation data. @@ -469,7 +497,7 @@ def write_root_link_pose_to_sim_index( self.data._body_com_jacobian_w.timestamp = -1.0 self.data._gravity_compensation_forces.timestamp = -1.0 # set into simulation - self.root_view.set_root_transforms(self._get_root_link_pose_w_f32(), indices=env_ids) + self.root_view.set_root_transforms(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) def write_root_link_pose_to_sim_mask( self, @@ -562,7 +590,7 @@ def write_root_com_pose_to_sim_index( self.data._body_com_jacobian_w.timestamp = -1.0 self.data._gravity_compensation_forces.timestamp = -1.0 # set into simulation - self.root_view.set_root_transforms(self._get_root_link_pose_w_f32(), indices=env_ids) + self.root_view.set_root_transforms(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) def write_root_com_pose_to_sim_mask( self, @@ -699,7 +727,7 @@ def write_root_com_velocity_to_sim_index( self.data._root_state_w.timestamp = -1.0 self.data._root_com_state_w.timestamp = -1.0 # set into simulation - self.root_view.set_root_velocities(self._get_root_com_vel_w_f32(), indices=env_ids) + self.root_view.set_root_velocities(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) def write_root_com_velocity_to_sim_mask( self, @@ -789,7 +817,7 @@ def write_root_link_velocity_to_sim_index( self.data._root_state_w.timestamp = -1.0 self.data._root_com_state_w.timestamp = -1.0 # set into simulation - self.root_view.set_root_velocities(self._get_root_link_vel_w_f32(), indices=env_ids) + self.root_view.set_root_velocities(self.data._root_link_vel_w.data.view(wp.float32), indices=env_ids) def write_root_link_velocity_to_sim_mask( self, @@ -913,12 +941,9 @@ def write_joint_state_to_sim_mask( joint_mask: Joint mask. If None, then all joints are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ - # resolve masks to indices (PhysX only supports index-based TensorAPI) - env_ids = self._resolve_env_mask(env_mask) - joint_ids = self._resolve_joint_mask(joint_mask) - self.write_joint_state_to_sim_index( - position=position, velocity=velocity, joint_ids=joint_ids, env_ids=env_ids, full_data=True - ) + # set into simulation + self.write_joint_position_to_sim_mask(position=position, env_mask=env_mask, joint_mask=joint_mask) + self.write_joint_velocity_to_sim_mask(velocity=velocity, env_mask=env_mask, joint_mask=joint_mask) def write_joint_position_to_sim_index( self, @@ -1152,8 +1177,7 @@ def write_joint_stiffness_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self._cpu_joint_stiffness, self.data._joint_stiffness) - self.root_view.set_dof_stiffnesses(self._cpu_joint_stiffness, indices=cpu_env_ids) + self.root_view.set_dof_stiffnesses(wp.clone(self.data._joint_stiffness, device="cpu"), indices=cpu_env_ids) def write_joint_stiffness_to_sim_mask( self, @@ -1247,8 +1271,104 @@ def write_joint_damping_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self._cpu_joint_damping, self.data._joint_damping) - self.root_view.set_dof_dampings(self._cpu_joint_damping, indices=cpu_env_ids) + self.root_view.set_dof_dampings(wp.clone(self.data._joint_damping, device="cpu"), indices=cpu_env_ids) + + def write_actuator_stiffness_to_sim( + self, + *, + stiffness: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + """Write actuator kp at the (env_ids, joint_ids) sub-grid and propagate to controllers. + + Iterates the per-articulation adapter's Newton actuators and uses + :data:`patch_actuator_param_kernel` to overwrite each + controller's ``kp`` array at the ``(env_ids × joint_ids)`` + cells. DOFs not owned by an actuator are skipped by the kernel's + per-slot index mapping. + + Args: + stiffness: Sub-grid of new kp values, shape ``(len(env_ids), len(joint_ids))``. + env_ids: 1D torch tensor of env indices. + joint_ids: 1D torch tensor of articulation-local joint indices. + + No-op when no Newton actuators are registered for this articulation. + """ + self._write_actuator_param("kp", stiffness, env_ids, joint_ids) + + def write_actuator_damping_to_sim( + self, + *, + damping: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + """Write actuator kd at the (env_ids, joint_ids) sub-grid and propagate to controllers.""" + self._write_actuator_param("kd", damping, env_ids, joint_ids) + + def _write_actuator_param( + self, + attr: str, + values: torch.Tensor, + env_ids: torch.Tensor, + joint_ids: torch.Tensor, + ) -> None: + """Shared body for :meth:`write_actuator_stiffness_to_sim` / :meth:`write_actuator_damping_to_sim`.""" + adapter = self.newton_actuator_adapter + if adapter is None: + return + + from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 + + env_id_pos = torch.full( + (self.num_instances,), + -1, + dtype=torch.int32, + device=self.device, + ) + env_id_pos[env_ids.to(self.device, dtype=torch.long)] = torch.arange( + env_ids.shape[0], + dtype=torch.int32, + device=self.device, + ) + joint_id_pos = torch.full( + (self.num_joints,), + -1, + dtype=torch.int32, + device=self.device, + ) + joint_id_pos[joint_ids.to(self.device, dtype=torch.long)] = torch.arange( + joint_ids.shape[0], + dtype=torch.int32, + device=self.device, + ) + + values_wp = wp.from_torch( + values.to(self.device, dtype=torch.float32).contiguous(), + dtype=wp.float32, + ) + env_id_pos_wp = wp.from_torch(env_id_pos, dtype=wp.int32) + joint_id_pos_wp = wp.from_torch(joint_id_pos, dtype=wp.int32) + + for act in adapter.actuators: + ctrl = act.controller + if not hasattr(ctrl, attr): + continue + wp.launch( + actuator_kernels.patch_actuator_param_kernel, + dim=act.indices.shape[0], + inputs=[ + act.indices, + env_id_pos_wp, + joint_id_pos_wp, + values_wp, + 0, + self.num_joints, + ], + outputs=[getattr(ctrl, attr)], + device=self.device, + ) def write_joint_damping_to_sim_mask( self, @@ -1348,8 +1468,7 @@ def write_joint_position_limit_to_sim_index( logger.info(violation_message) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self._cpu_joint_pos_limits, self.data._joint_pos_limits) - self.root_view.set_dof_limits(self._cpu_joint_pos_limits, indices=cpu_env_ids) + self.root_view.set_dof_limits(wp.clone(self.data._joint_pos_limits, device="cpu"), indices=cpu_env_ids) def write_joint_position_limit_to_sim_mask( self, @@ -1453,8 +1572,7 @@ def write_joint_velocity_limit_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self._cpu_joint_vel_limits, self.data._joint_vel_limits) - self.root_view.set_dof_max_velocities(self._cpu_joint_vel_limits, indices=cpu_env_ids) + self.root_view.set_dof_max_velocities(wp.clone(self.data._joint_vel_limits, device="cpu"), indices=cpu_env_ids) def write_joint_velocity_limit_to_sim_mask( self, @@ -1555,8 +1673,7 @@ def write_joint_effort_limit_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self._cpu_joint_effort_limits, self.data._joint_effort_limits) - self.root_view.set_dof_max_forces(self._cpu_joint_effort_limits, indices=cpu_env_ids) + self.root_view.set_dof_max_forces(wp.clone(self.data._joint_effort_limits, device="cpu"), indices=cpu_env_ids) def write_joint_effort_limit_to_sim_mask( self, @@ -1655,8 +1772,7 @@ def write_joint_armature_to_sim_index( if isinstance(env_ids, torch.Tensor): env_ids = wp.from_torch(env_ids, dtype=wp.int32) cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self._cpu_joint_armature, self.data._joint_armature) - self.root_view.set_dof_armatures(self._cpu_joint_armature, indices=cpu_env_ids) + self.root_view.set_dof_armatures(wp.clone(self.data._joint_armature, device="cpu"), indices=cpu_env_ids) def write_joint_armature_to_sim_mask( self, @@ -1700,23 +1816,16 @@ def write_joint_friction_coefficient_to_sim_index( ): r"""Write joint friction coefficients over selected environment indices into the simulation. - For Isaac Sim versions below 5.0, only the legacy unitless joint friction coefficient is set. - This limits the resisting force or torque up to a maximum proportional to the transmitted spatial force: - :math:`\|F_{resist}\| \leq \mu_s \, \|F_{spatial}\|`. - - For Isaac Sim versions 5.0 and above, the PhysX joint friction parameter model is used. It combines - Coulomb (static and dynamic) friction with a viscous term: + For Isaac Sim versions below 5.0, only the static friction coefficient is set. + This limits the resisting force or torque up to a maximum proportional to the transmitted + spatial force: :math:`\|F_{resist}\| \leq \mu_s \, \|F_{spatial}\|`. - - Static friction effort defines the maximum effort that prevents motion at rest [N or N·m, depending on - joint type]. - - Dynamic friction effort applies once motion begins and remains constant during motion [N or N·m, - depending on joint type]. - - Viscous friction coefficient is a velocity-proportional resistive term [N·s/m or N·m·s/rad, depending - on joint type]. + For Isaac Sim versions 5.0 and above, the static, dynamic, and viscous friction coefficients + are set. The model combines Coulomb (static & dynamic) friction with a viscous term: - .. warning:: - For Isaac Sim versions 5.0 and above, the static friction effort must be greater than or equal to the - dynamic friction effort. + - Static friction :math:`\mu_s` defines the maximum effort that prevents motion at rest. + - Dynamic friction :math:`\mu_d` applies once motion begins and remains constant during motion. + - Viscous friction :math:`c_v` is a velocity-proportional resistive term. .. note:: This method expects partial data or full data. @@ -1726,12 +1835,11 @@ def write_joint_friction_coefficient_to_sim_index( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_friction_coeff: Legacy unitless coefficient for Isaac Sim versions below 5.0, or static friction - effort [N or N·m, depending on joint type] for Isaac Sim versions 5.0 and above. Shape is - (len(env_ids), len(joint_ids)) or (num_instances, num_joints). - joint_dynamic_friction_coeff: Dynamic friction effort [N or N·m, depending on joint type]. + joint_friction_coeff: Static friction coefficient :math:`\mu_s`. + Shape is (len(env_ids), len(joint_ids)) or (num_instances, num_joints). + joint_dynamic_friction_coeff: Dynamic (Coulomb) friction coefficient :math:`\mu_d`. Same shape as above. If None, the dynamic coefficient is not updated. - joint_viscous_friction_coeff: Viscous friction coefficient [N·s/m or N·m·s/rad, depending on joint type]. + joint_viscous_friction_coeff: Viscous friction coefficient :math:`c_v`. Same shape as above. If None, the viscous coefficient is not updated. joint_ids: Joint indices. If None, then all joints are used. env_ids: Environment indices. If None, then all indices are used. @@ -1803,8 +1911,7 @@ def write_joint_friction_coefficient_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self._cpu_joint_friction_props, friction_props) - self.root_view.set_dof_friction_properties(self._cpu_joint_friction_props, indices=cpu_env_ids) + self.root_view.set_dof_friction_properties(wp.clone(friction_props, device="cpu"), indices=cpu_env_ids) def write_joint_friction_coefficient_to_sim_mask( self, @@ -1817,23 +1924,16 @@ def write_joint_friction_coefficient_to_sim_mask( ): r"""Write joint friction coefficients over selected environment mask into the simulation. - For Isaac Sim versions below 5.0, only the legacy unitless joint friction coefficient is set. - This limits the resisting force or torque up to a maximum proportional to the transmitted spatial force: - :math:`\|F_{resist}\| \leq \mu_s \, \|F_{spatial}\|`. - - For Isaac Sim versions 5.0 and above, the PhysX joint friction parameter model is used. It combines - Coulomb (static and dynamic) friction with a viscous term: + For Isaac Sim versions below 5.0, only the static friction coefficient is set. + This limits the resisting force or torque up to a maximum proportional to the transmitted + spatial force: :math:`\|F_{resist}\| \leq \mu_s \, \|F_{spatial}\|`. - - Static friction effort defines the maximum effort that prevents motion at rest [N or N·m, depending on - joint type]. - - Dynamic friction effort applies once motion begins and remains constant during motion [N or N·m, - depending on joint type]. - - Viscous friction coefficient is a velocity-proportional resistive term [N·s/m or N·m·s/rad, depending - on joint type]. + For Isaac Sim versions 5.0 and above, the static, dynamic, and viscous friction coefficients + are set. The model combines Coulomb (static & dynamic) friction with a viscous term: - .. warning:: - For Isaac Sim versions 5.0 and above, the static friction effort must be greater than or equal to the - dynamic friction effort. + - Static friction :math:`\mu_s` defines the maximum effort that prevents motion at rest. + - Dynamic friction :math:`\mu_d` applies once motion begins and remains constant during motion. + - Viscous friction :math:`c_v` is a velocity-proportional resistive term. .. note:: This method expects full data. @@ -1843,12 +1943,11 @@ def write_joint_friction_coefficient_to_sim_mask( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_friction_coeff: Legacy unitless coefficient for Isaac Sim versions below 5.0, or static friction - effort [N or N·m, depending on joint type] for Isaac Sim versions 5.0 and above. Shape is - (num_instances, num_joints). - joint_dynamic_friction_coeff: Dynamic friction effort [N or N·m, depending on joint type]. + joint_friction_coeff: Static friction coefficient :math:`\mu_s`. + Shape is (num_instances, num_joints). + joint_dynamic_friction_coeff: Dynamic (Coulomb) friction coefficient :math:`\mu_d`. Same shape as above. If None, the dynamic coefficient is not updated. - joint_viscous_friction_coeff: Viscous friction coefficient [N·s/m or N·m·s/rad, depending on joint type]. + joint_viscous_friction_coeff: Viscous friction coefficient :math:`c_v`. Same shape as above. If None, the viscous coefficient is not updated. joint_mask: Joint mask. If None, then all joints are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). @@ -1874,9 +1973,7 @@ def write_joint_dynamic_friction_coefficient_to_sim_index( env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, full_data: bool = False, ) -> None: - """Write joint dynamic friction effort over selected environment indices into the simulation. - - The dynamic friction effort is [N or N·m, depending on joint type]. + """Write joint dynamic friction coefficient over selected environment indices into the simulation. .. note:: This method expects partial data or full data. @@ -1886,8 +1983,8 @@ def write_joint_dynamic_friction_coefficient_to_sim_index( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_dynamic_friction_coeff: Dynamic friction effort [N or N·m, depending on joint type]. Shape is - (len(env_ids), len(joint_ids)) or (num_instances, num_joints) if full_data. + joint_dynamic_friction_coeff: Joint dynamic friction coefficient. Shape is (len(env_ids), len(joint_ids)) + or (num_instances, num_joints) if full_data. joint_ids: Joint indices. If None, then all joints are used. env_ids: Environment indices. If None, then all indices are used. full_data: Whether to expect full data. Defaults to False. @@ -1931,8 +2028,7 @@ def write_joint_dynamic_friction_coefficient_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self._cpu_joint_friction_props, friction_props) - self.root_view.set_dof_friction_properties(self._cpu_joint_friction_props, indices=cpu_env_ids) + self.root_view.set_dof_friction_properties(wp.clone(friction_props, device="cpu"), indices=cpu_env_ids) def write_joint_dynamic_friction_coefficient_to_sim_mask( self, @@ -1941,9 +2037,7 @@ def write_joint_dynamic_friction_coefficient_to_sim_mask( joint_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Write joint dynamic friction effort over selected environment mask into the simulation. - - The dynamic friction effort is [N or N·m, depending on joint type]. + """Write joint dynamic friction coefficient over selected environment mask into the simulation. .. note:: This method expects full data. @@ -1953,8 +2047,7 @@ def write_joint_dynamic_friction_coefficient_to_sim_mask( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_dynamic_friction_coeff: Dynamic friction effort [N or N·m, depending on joint type]. Shape is - (num_instances, num_joints). + joint_dynamic_friction_coeff: Joint dynamic friction coefficient. Shape is (num_instances, num_joints). joint_mask: Joint mask. If None, then all joints are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ @@ -1979,8 +2072,6 @@ def write_joint_viscous_friction_coefficient_to_sim_index( ) -> None: """Write joint viscous friction coefficient over selected environment indices into the simulation. - The coefficient is [N·s/m or N·m·s/rad, depending on joint type]. - .. note:: This method expects partial data or full data. @@ -1989,8 +2080,8 @@ def write_joint_viscous_friction_coefficient_to_sim_index( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_viscous_friction_coeff: Viscous friction coefficient [N·s/m or N·m·s/rad, depending on joint type]. - Shape is (len(env_ids), len(joint_ids)) or (num_instances, num_joints) if full_data. + joint_viscous_friction_coeff: Joint viscous friction coefficient. Shape is (len(env_ids), len(joint_ids)) + or (num_instances, num_joints) if full_data. joint_ids: Joint indices. If None, then all joints are used. env_ids: Environment indices. If None, then all indices are used. full_data: Whether to expect full data. Defaults to False. @@ -2037,8 +2128,7 @@ def write_joint_viscous_friction_coefficient_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self._cpu_joint_friction_props, friction_props) - self.root_view.set_dof_friction_properties(self._cpu_joint_friction_props, indices=cpu_env_ids) + self.root_view.set_dof_friction_properties(wp.clone(friction_props, device="cpu"), indices=cpu_env_ids) def write_joint_viscous_friction_coefficient_to_sim_mask( self, @@ -2049,8 +2139,6 @@ def write_joint_viscous_friction_coefficient_to_sim_mask( ) -> None: """Write joint viscous friction coefficient over selected environment mask into the simulation. - The coefficient is [N·s/m or N·m·s/rad, depending on joint type]. - .. note:: This method expects full data. @@ -2059,8 +2147,7 @@ def write_joint_viscous_friction_coefficient_to_sim_mask( is only supporting indexing, hence masks need to be converted to indices. Args: - joint_viscous_friction_coeff: Viscous friction coefficient [N·s/m or N·m·s/rad, depending on joint type]. - Shape is (num_instances, num_joints). + joint_viscous_friction_coeff: Joint viscous friction coefficient. Shape is (num_instances, num_joints). joint_mask: Joint mask. If None, then all joints are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ @@ -2128,8 +2215,7 @@ def set_masses_index( # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self._cpu_body_mass, self.data._body_mass) - self.root_view.set_masses(self._cpu_body_mass, indices=cpu_env_ids) + self.root_view.set_masses(wp.clone(self.data._body_mass, device="cpu"), indices=cpu_env_ids) def set_masses_mask( self, @@ -2208,11 +2294,12 @@ def set_coms_index( # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. # Convert from wp.transformf to flat (N, M, 7) array for PhysX cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy( - self._cpu_body_coms, - self.data._body_com_pose_b.data.view(wp.float32).reshape((self.num_instances, self.num_bodies, 7)), + body_com_flat = ( + wp.clone(self.data._body_com_pose_b.data, device="cpu") + .view(wp.float32) + .reshape((self.num_instances, self.num_bodies, 7)) ) - self.root_view.set_coms(self._cpu_body_coms, indices=cpu_env_ids) + self.root_view.set_coms(body_com_flat, indices=cpu_env_ids) def set_coms_mask( self, @@ -2290,8 +2377,7 @@ def set_inertias_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self._cpu_body_inertia, self.data._body_inertia) - self.root_view.set_inertias(self._cpu_body_inertia, indices=cpu_env_ids) + self.root_view.set_inertias(wp.clone(self.data._body_inertia, device="cpu"), indices=cpu_env_ids) def set_inertias_mask( self, @@ -3782,35 +3868,6 @@ def _create_buffers(self): device=self.device, ) - # Cached .view(wp.float32) wrappers for structured warp arrays. - # These avoid per-call wp.array metadata allocation in writers. - # Reset to None each time _create_buffers runs (during initialization). - self._root_link_pose_w_f32: wp.array | None = None - self._root_com_vel_w_f32: wp.array | None = None - self._root_link_vel_w_f32: wp.array | None = None - # Cached wrench views for write_data_to_sim - self._inst_wrench_force_f32: wp.array | None = None - self._inst_wrench_torque_f32: wp.array | None = None - self._perm_wrench_force_f32: wp.array | None = None - self._perm_wrench_torque_f32: wp.array | None = None - - # Pre-allocated pinned CPU buffers for PhysX TensorAPI writes. - # PhysX requires CPU arrays for "model" property updates (stiffness, damping, etc.). - # Pinned memory enables DMA fast path and avoids per-call malloc. - N, J, B = self.num_instances, self.num_joints, self.num_bodies - self._cpu_env_ids_all = wp.zeros(N, dtype=wp.int32, device="cpu", pinned=True) - wp.copy(self._cpu_env_ids_all, self._ALL_INDICES) - self._cpu_joint_stiffness = wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) - self._cpu_joint_damping = wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) - self._cpu_joint_pos_limits = wp.zeros((N, J, 2), dtype=wp.float32, device="cpu", pinned=True) - self._cpu_joint_vel_limits = wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) - self._cpu_joint_effort_limits = wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) - self._cpu_joint_armature = wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) - self._cpu_joint_friction_props = wp.zeros((N, J, 3), dtype=wp.float32, device="cpu", pinned=True) - self._cpu_body_mass = wp.zeros((N, B), dtype=wp.float32, device="cpu", pinned=True) - self._cpu_body_coms = wp.zeros((N, B, 7), dtype=wp.float32, device="cpu", pinned=True) - self._cpu_body_inertia = wp.zeros((N, B, 9), dtype=wp.float32, device="cpu", pinned=True) - def _process_cfg(self): """Post processing of configuration parameters.""" # default state @@ -3869,165 +3926,143 @@ def _process_actuators_cfg(self): """Process and apply articulation joint properties.""" # create actuators self.actuators = dict() + self._physx_actuator_wrapper = None + # Per-articulation Newton actuator adapter and the frozen kp/kd + # snapshot consumed by ``randomize_actuator_gains``. ``None`` for + # articulations not on the Newton fast path or with only implicit + # Lab actuators. + self.newton_actuator_adapter: NewtonActuatorAdapter | None = None + self.newton_default_stiffness: torch.Tensor | None = None + self.newton_default_damping: torch.Tensor | None = None + self.newton_managed_local_joints: torch.Tensor | slice | None = None # flag for implicit actuators # if this is false, we by-pass certain checks when doing actuator-related operations self._has_implicit_actuators = False + self._has_newton_actuators = False + # Per-DOF implicit/explicit mask consumed by the + # ``sync_torque_telemetry`` kernel. ``None`` when no Newton fast path + # is active. + self._implicit_dof_mask: wp.array | None = None - # iterate over all actuator configurations - for actuator_name, actuator_cfg in self.cfg.actuators.items(): - # type annotation for type checkers - actuator_cfg: ActuatorBaseCfg - # create actuator group - joint_ids, joint_names = self.find_joints(actuator_cfg.joint_names_expr) - # check if any joints are found - if len(joint_names) == 0: - raise ValueError( - f"No joints found for actuator group: {actuator_name} with joint name expression:" - f" {actuator_cfg.joint_names_expr}." - ) - # resolve joint indices - # we pass a slice if all joints are selected to avoid indexing overhead - if len(joint_names) == self.num_joints: - joint_ids = slice(None) - else: - joint_ids = torch.tensor(joint_ids, device=self.device, dtype=torch.int32) - # create actuator collection - # note: for efficiency avoid indexing when over all indices - actuator: ActuatorBase = actuator_cfg.class_type( - cfg=actuator_cfg, - joint_names=joint_names, - joint_ids=joint_ids, - num_envs=self.num_instances, - device=self.device, - stiffness=self._data.joint_stiffness.torch[:, joint_ids], - damping=self._data.joint_damping.torch[:, joint_ids], - armature=self._data.joint_armature.torch[:, joint_ids], - friction=self._data.joint_friction_coeff.torch[:, joint_ids], - dynamic_friction=self._data.joint_dynamic_friction_coeff.torch[:, joint_ids], - viscous_friction=self._data.joint_viscous_friction_coeff.torch[:, joint_ids], - effort_limit=self._data.joint_effort_limits.torch[:, joint_ids].clone(), - velocity_limit=self._data.joint_vel_limits.torch[:, joint_ids], - ) - # store actuator group - self.actuators[actuator_name] = actuator - # Store the configured values from the actuator model - # note: this is the value configured in the actuator model (for implicit and explicit actuators) - joint_ids = actuator.joint_indices - if joint_ids == slice(None): - joint_ids = self._ALL_JOINT_INDICES - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices, - dim=(self.num_instances, joint_ids.shape[0]), - inputs=[ - actuator.stiffness, - self._ALL_INDICES, - joint_ids, - False, - ], - outputs=[ - self.data._joint_stiffness, - ], - device=self.device, - ) - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices, - dim=(self.num_instances, joint_ids.shape[0]), - inputs=[ - actuator.damping, - self._ALL_INDICES, - joint_ids, - False, - ], - outputs=[ - self.data._joint_damping, - ], - device=self.device, + _use_newton_actuators = getattr(self._sim_cfg, "use_newton_actuators", False) + + if _use_newton_actuators and not _HAS_NEWTON_ACTUATORS: + logger.warning( + "use_newton_actuators is enabled but 'isaaclab_newton.actuators' is not available." + " Newton-native actuators will be disabled and the simulation will fall back to the" + " Isaac Lab actuator path. Install the isaaclab_newton extension to enable the fast path." ) - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices, - dim=(self.num_instances, joint_ids.shape[0]), - inputs=[ - actuator.armature, - self._ALL_INDICES, - joint_ids, - False, - ], - outputs=[ - self.data._joint_armature, - ], - device=self.device, + + if _HAS_NEWTON_ACTUATORS and _use_newton_actuators: + from isaaclab_newton.actuators import ( # noqa: PLC0415 + NewtonActuatorAdapter, + PhysxActuatorWrapper, + build_implicit_dof_mask, + build_newton_actuator_defaults, ) - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices, - dim=(self.num_instances, joint_ids.shape[0]), - inputs=[ - actuator.friction, - self._ALL_INDICES, - joint_ids, - False, - ], - outputs=[ - self.data._joint_friction_coeff, - ], - device=self.device, + + from isaaclab.sim.utils.stage import get_current_stage # noqa: PLC0415 + + # Enable the fast path even for all-implicit articulations: + # PhysX runs PD internally; Lab only forwards targets. + self._has_newton_actuators = True + + # Author Newton actuator prims only if any explicit Lab group exists. + has_explicit = any( + not ( + "ImplicitActuator" in actuator_cfg.class_type + if isinstance(actuator_cfg.class_type, str) + else issubclass(actuator_cfg.class_type, ImplicitActuator) + ) + for actuator_cfg in self.cfg.actuators.values() ) - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices, - dim=(self.num_instances, joint_ids.shape[0]), - inputs=[ - actuator.dynamic_friction, - self._ALL_INDICES, - joint_ids, - False, - ], - outputs=[ - self.data._joint_dynamic_friction_coeff, - ], + + # Always allocate the wrapper so ``_apply_actuator_model_newton`` + # has a ``joint_f_2d`` buffer to merge effort into, even when + # there are no explicit Newton actuators (implicit-only case). + self._physx_actuator_wrapper = PhysxActuatorWrapper.create( + num_envs=self.num_instances, + num_joints=self.num_joints, device=self.device, ) - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices, - dim=(self.num_instances, joint_ids.shape[0]), - inputs=[ - actuator.viscous_friction, - self._ALL_INDICES, - joint_ids, - False, - ], - outputs=[ - self.data._joint_viscous_friction_coeff, - ], - device=self.device, + + if has_explicit: + first_prim = find_first_matching_prim(self.cfg.prim_path) + art_prim_path = str(first_prim.GetPath()) if first_prim is not None else None + + adapter = NewtonActuatorAdapter.from_usd( + stage=get_current_stage(), + joint_names=self.joint_names, + num_envs=self.num_instances, + num_joints=self.num_joints, + device=self.device, + articulation_prim_path=art_prim_path, + ) + + # Bind the wrapper's flat aliases of state/input buffers once. + # The underlying wp.arrays alias stable PhysX-owned GPU memory + # whose device pointer is fixed for the articulation's lifetime, + # so the views remain valid for every subsequent step. + w = self._physx_actuator_wrapper + w.joint_q = self._data.joint_pos.warp.reshape(-1) + w.joint_qd = self._data.joint_vel.warp.reshape(-1) + w.joint_target_pos = self._data.joint_pos_target.warp.reshape(-1) + w.joint_target_vel = self._data.joint_vel_target.warp.reshape(-1) + w.joint_act = self._data.joint_effort_target.warp.reshape(-1) + adapter.finalize(w) + self.newton_actuator_adapter = adapter + ( + self.newton_default_stiffness, + self.newton_default_damping, + self.newton_managed_local_joints, + ) = build_newton_actuator_defaults( + actuators=adapter.actuators, + num_envs=self.num_instances, + num_joints=self.num_joints, + dof_offset=0, + device=self.device, + ) + self.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=adapter.joint_indices) + self.write_joint_damping_to_sim_index(damping=0.0, joint_ids=adapter.joint_indices) + + for actuator_name, actuator_cfg in self.cfg.actuators.items(): + cls_type = actuator_cfg.class_type + is_implicit = ( + "ImplicitActuator" in cls_type + if isinstance(cls_type, str) + else issubclass(cls_type, ImplicitActuator) + ) + if is_implicit: + self._create_lab_actuator(actuator_name, actuator_cfg) + else: + self._create_lab_actuator(actuator_name, actuator_cfg, properties_only=True) + + # ``_implicit_dof_mask_owner`` is the underlying torch tensor that owns + # the GPU memory aliased by ``_implicit_dof_mask``. We keep it as an + # instance attribute so the memory isn't freed while a CUDA graph + # holds a captured pointer into it. + self._implicit_dof_mask, self._implicit_dof_mask_owner = build_implicit_dof_mask( + self.actuators, + self.num_joints, + self.device, ) - # set the passed gains and limits into the simulation - if isinstance(actuator, ImplicitActuator): - self._has_implicit_actuators = True - # the gains and limits are set into the simulation since actuator model is implicit - self.write_joint_stiffness_to_sim_index(stiffness=actuator.stiffness, joint_ids=actuator.joint_indices) - self.write_joint_damping_to_sim_index(damping=actuator.damping, joint_ids=actuator.joint_indices) + # Per-articulation view of the adapter's pre-clamp computed-effort + # buffer (or zero fallback when there are no explicit Newton + # actuators). Set once here so ``_apply_actuator_model_newton`` + # passes it straight to ``sync_torque_telemetry``. + if self.newton_actuator_adapter is not None: + self._data._sim_bind_joint_computed_effort = self.newton_actuator_adapter.computed_effort_2d else: - # the gains and limits are processed by the actuator model - # we set gains to zero, and torque limit to a high value in simulation to avoid any interference - self.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=actuator.joint_indices) - self.write_joint_damping_to_sim_index(damping=0.0, joint_ids=actuator.joint_indices) - - # Set common properties into the simulation - self.write_joint_effort_limit_to_sim_index( - limits=actuator.effort_limit_sim, joint_ids=actuator.joint_indices - ) - self.write_joint_velocity_limit_to_sim_index( - limits=actuator.velocity_limit_sim, joint_ids=actuator.joint_indices - ) - self.write_joint_armature_to_sim_index(armature=actuator.armature, joint_ids=actuator.joint_indices) - self.write_joint_friction_coefficient_to_sim_index( - joint_friction_coeff=actuator.friction, joint_ids=actuator.joint_indices - ) - self.write_joint_dynamic_friction_coefficient_to_sim_index( - joint_dynamic_friction_coeff=actuator.dynamic_friction, joint_ids=actuator.joint_indices - ) - self.write_joint_viscous_friction_coefficient_to_sim_index( - joint_viscous_friction_coeff=actuator.viscous_friction, joint_ids=actuator.joint_indices - ) + self._data._sim_bind_joint_computed_effort = wp.zeros( + (self.num_instances, self.num_joints), + dtype=wp.float32, + device=self.device, + ) + return + + # --- Standard Isaac Lab actuator path --- + for actuator_name, actuator_cfg in self.cfg.actuators.items(): + self._create_lab_actuator(actuator_name, actuator_cfg) # perform some sanity checks to ensure actuators are prepared correctly total_act_joints = sum(actuator.num_joints for actuator in self.actuators.values()) @@ -4038,8 +4073,14 @@ def _process_actuators_cfg(self): ) if self.cfg.actuator_value_resolution_debug_print: + if _HAS_NEWTON_ACTUATORS: + from isaaclab_newton.actuators import NewtonActuatorAdapter # noqa: PLC0415 + else: + NewtonActuatorAdapter = None # type: ignore[assignment] t = PrettyTable(["Group", "Property", "Name", "ID", "USD Value", "ActutatorCfg Value", "Applied"]) for actuator_group, actuator in self.actuators.items(): + if NewtonActuatorAdapter is not None and isinstance(actuator, NewtonActuatorAdapter): + continue group_count = 0 for property, resolution_details in actuator.joint_property_resolution_table.items(): for prop_idx, resolution_detail in enumerate(resolution_details): @@ -4050,6 +4091,106 @@ def _process_actuators_cfg(self): group_count += 1 logger.warning(f"\nActuatorCfg-USD Value Discrepancy Resolution (matching values are skipped): \n{t}") + def _create_lab_actuator( + self, + actuator_name: str, + actuator_cfg: ActuatorBaseCfg, + *, + properties_only: bool = False, + ) -> None: + """Instantiate a single Lab actuator from its config and write properties to sim. + + Args: + actuator_name: Name for the actuator group. + actuator_cfg: Configuration for the actuator. + properties_only: When ``True``, only write physical joint properties + (armature, limits, friction) without registering the actuator or + writing stiffness/damping. Used for explicit joints managed by + Newton actuators. + """ + joint_ids, joint_names = self.find_joints(actuator_cfg.joint_names_expr) + if len(joint_names) == 0: + raise ValueError( + f"No joints found for actuator group: {actuator_name} with joint name expression:" + f" {actuator_cfg.joint_names_expr}." + ) + if len(joint_names) == self.num_joints: + joint_ids = slice(None) + else: + joint_ids = torch.tensor(joint_ids, device=self.device, dtype=torch.int32) + + actuator: ActuatorBase = actuator_cfg.class_type( + cfg=actuator_cfg, + joint_names=joint_names, + joint_ids=joint_ids, + num_envs=self.num_instances, + device=self.device, + stiffness=wp.to_torch(self._data.joint_stiffness)[:, joint_ids], + damping=wp.to_torch(self._data.joint_damping)[:, joint_ids], + armature=wp.to_torch(self._data.joint_armature)[:, joint_ids], + friction=wp.to_torch(self._data.joint_friction_coeff)[:, joint_ids], + dynamic_friction=wp.to_torch(self._data.joint_dynamic_friction_coeff)[:, joint_ids], + viscous_friction=wp.to_torch(self._data.joint_viscous_friction_coeff)[:, joint_ids], + effort_limit=wp.to_torch(self._data.joint_effort_limits)[:, joint_ids].clone(), + velocity_limit=wp.to_torch(self._data.joint_vel_limits)[:, joint_ids], + ) + + # Write physical joint properties (armature, limits, friction) — always needed. + self.write_joint_effort_limit_to_sim_index( + limits=actuator.effort_limit_sim, + joint_ids=actuator.joint_indices, + ) + self.write_joint_velocity_limit_to_sim_index( + limits=actuator.velocity_limit_sim, + joint_ids=actuator.joint_indices, + ) + self.write_joint_armature_to_sim_index(armature=actuator.armature, joint_ids=actuator.joint_indices) + self.write_joint_friction_coefficient_to_sim_index( + joint_friction_coeff=actuator.friction, + joint_ids=actuator.joint_indices, + ) + self.write_joint_dynamic_friction_coefficient_to_sim_index( + joint_dynamic_friction_coeff=actuator.dynamic_friction, + joint_ids=actuator.joint_indices, + ) + self.write_joint_viscous_friction_coefficient_to_sim_index( + joint_viscous_friction_coeff=actuator.viscous_friction, + joint_ids=actuator.joint_indices, + ) + + if properties_only: + return + + self.actuators[actuator_name] = actuator + + # Store the configured values from the actuator model + j_ids = actuator.joint_indices + if j_ids == slice(None): + j_ids = self._ALL_JOINT_INDICES + for attr, buf in ( + (actuator.stiffness, self.data._joint_stiffness), + (actuator.damping, self.data._joint_damping), + (actuator.armature, self.data._joint_armature), + (actuator.friction, self.data._joint_friction_coeff), + (actuator.dynamic_friction, self.data._joint_dynamic_friction_coeff), + (actuator.viscous_friction, self.data._joint_viscous_friction_coeff), + ): + wp.launch( + shared_kernels.write_2d_data_to_buffer_with_indices, + dim=(self.num_instances, j_ids.shape[0]), + inputs=[attr, self._ALL_INDICES, j_ids, False], + outputs=[buf], + device=self.device, + ) + + if isinstance(actuator, ImplicitActuator): + self._has_implicit_actuators = True + self.write_joint_stiffness_to_sim_index(stiffness=actuator.stiffness, joint_ids=actuator.joint_indices) + self.write_joint_damping_to_sim_index(damping=actuator.damping, joint_ids=actuator.joint_indices) + else: + self.write_joint_stiffness_to_sim_index(stiffness=0.0, joint_ids=actuator.joint_indices) + self.write_joint_damping_to_sim_index(damping=0.0, joint_ids=actuator.joint_indices) + def _process_tendons(self): """Process fixed and spatial tendons.""" # create a list to store the fixed tendon names @@ -4143,6 +4284,47 @@ def _apply_actuator_model(self): device=self.device, ) + def _apply_actuator_model_newton(self): + """Pre-fill effort buffer with FF, step Newton actuators, sync telemetry. + + Pre-fills ``w.joint_f_2d`` with the user's effort target across all + DOFs. ``newton_adapter.step`` (no-op if no explicit Newton actuators + exist) then zeroes ``joint_f_2d`` at explicit DOFs and overwrites + them with each actuator's computed effort, while implicit DOFs keep + the FF. The :func:`sync_torque_telemetry` kernel then fills + ``_data._computed_torque`` / ``_data._applied_torque`` from the + resulting buffer. The final ``joint_f_2d`` is what gets pushed to + PhysX as the actuation force in :meth:`write_data_to_sim`. + """ + from isaaclab_newton.actuators import kernels as actuator_kernels # noqa: PLC0415 + + w = self._physx_actuator_wrapper + w.joint_f_2d.assign(self._data._joint_effort_target) + if self.newton_actuator_adapter is not None: + self.newton_actuator_adapter.step(w, w, SimulationManager.get_physics_dt()) + + wp.launch( + actuator_kernels.sync_torque_telemetry, + dim=(self.num_instances, self.num_joints), + inputs=[ + self._data.joint_pos.warp, + self._data.joint_vel.warp, + self._data._joint_pos_target, + self._data._joint_vel_target, + self._data.joint_stiffness.warp, + self._data.joint_damping.warp, + self._data.joint_effort_limits.warp, + self._implicit_dof_mask, + w.joint_f_2d, + self._data._sim_bind_joint_computed_effort, + ], + outputs=[ + self._data._computed_torque, + self._data._applied_torque, + ], + device=self.device, + ) + """ Internal helpers -- Debugging. """ @@ -4355,23 +4537,17 @@ def format_limits(_, v: tuple[float, float]) -> str: ) def _get_cpu_env_ids(self, env_ids: wp.array | torch.Tensor) -> wp.array: - """Get the CPU environment indices. - - For the full-index case (all environments), returns the pre-allocated - pinned CPU buffer. For partial indices (e.g. during partial resets), clones to CPU. + """ + Get the CPU environment indices. Args: env_ids: Environment indices. Returns: - A warp array of environment indices on CPU. + A warp array of environment indices. """ if isinstance(env_ids, torch.Tensor): env_ids = wp.from_torch(env_ids, dtype=wp.int32) - # Fast path: if these are all indices, use pre-allocated pinned buffer - if env_ids.ptr == self._ALL_INDICES.ptr: - return self._cpu_env_ids_all - # Slow path: partial indices (reset), clone to CPU return wp.clone(env_ids, device="cpu") def _resolve_env_mask(self, env_mask: wp.array | None) -> torch.Tensor | wp.array: @@ -4393,55 +4569,12 @@ def _resolve_env_mask(self, env_mask: wp.array | None) -> torch.Tensor | wp.arra env_ids = self._ALL_INDICES return env_ids - def _get_root_link_pose_w_f32(self) -> wp.array: - """Get a cached float32 view of root_link_pose_w for PhysX TensorAPI. Invalidated in ``_create_buffers``.""" - if self._root_link_pose_w_f32 is None: - self._root_link_pose_w_f32 = self.data._root_link_pose_w.data.view(wp.float32) - return self._root_link_pose_w_f32 - - def _get_root_com_vel_w_f32(self) -> wp.array: - """Get a cached float32 view of root_com_vel_w for PhysX TensorAPI. Invalidated in ``_create_buffers``.""" - if self._root_com_vel_w_f32 is None: - self._root_com_vel_w_f32 = self.data._root_com_vel_w.data.view(wp.float32) - return self._root_com_vel_w_f32 - - def _get_root_link_vel_w_f32(self) -> wp.array: - """Get a cached float32 view of root_link_vel_w for PhysX TensorAPI. Invalidated in ``_create_buffers``.""" - if self._root_link_vel_w_f32 is None: - self._root_link_vel_w_f32 = self.data._root_link_vel_w.data.view(wp.float32) - return self._root_link_vel_w_f32 - - def _get_inst_wrench_force_f32(self) -> wp.array: - """Get a cached flattened float32 view of instantaneous wrench force. Invalidated in ``_create_buffers``.""" - if self._inst_wrench_force_f32 is None: - self._inst_wrench_force_f32 = self._instantaneous_wrench_composer.out_force_b.warp.flatten().view( - wp.float32 - ) - return self._inst_wrench_force_f32 - - def _get_inst_wrench_torque_f32(self) -> wp.array: - """Get a cached flattened float32 view of instantaneous wrench torque. Invalidated in ``_create_buffers``.""" - if self._inst_wrench_torque_f32 is None: - self._inst_wrench_torque_f32 = self._instantaneous_wrench_composer.out_torque_b.warp.flatten().view( - wp.float32 - ) - return self._inst_wrench_torque_f32 - - def _get_perm_wrench_force_f32(self) -> wp.array: - """Get a cached flattened float32 view of permanent wrench force. Invalidated in ``_create_buffers``.""" - if self._perm_wrench_force_f32 is None: - self._perm_wrench_force_f32 = self._permanent_wrench_composer.out_force_b.warp.flatten().view(wp.float32) - return self._perm_wrench_force_f32 - - def _get_perm_wrench_torque_f32(self) -> wp.array: - """Get a cached flattened float32 view of permanent wrench torque. Invalidated in ``_create_buffers``.""" - if self._perm_wrench_torque_f32 is None: - self._perm_wrench_torque_f32 = self._permanent_wrench_composer.out_torque_b.warp.flatten().view(wp.float32) - return self._perm_wrench_torque_f32 - def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array: """Resolve environment indices to a warp array. + .. note:: + We need to convert torch tensors to warp arrays since the TensorAPI views only support warp arrays. + Args: env_ids: Environment indices. If None, then all indices are used. @@ -4451,6 +4584,7 @@ def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | No if (env_ids is None) or (env_ids == slice(None)): return self._ALL_INDICES if isinstance(env_ids, torch.Tensor): + # Convert int64 to int32 if needed, as warp expects int32 if env_ids.dtype == torch.int64: env_ids = env_ids.to(torch.int32) return wp.from_torch(env_ids, dtype=wp.int32) @@ -4478,6 +4612,9 @@ def _resolve_joint_mask(self, joint_mask: wp.array | None) -> torch.Tensor | wp. def _resolve_joint_ids(self, joint_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array | torch.Tensor: """Resolve joint indices to a warp array or tensor. + .. note:: + We do not need to convert torch tensors to warp arrays since they never get passed to the TensorAPI views. + Args: joint_ids: Joint indices. If None, then all indices are used. @@ -4488,10 +4625,6 @@ def _resolve_joint_ids(self, joint_ids: Sequence[int] | torch.Tensor | wp.array return wp.array(joint_ids, dtype=wp.int32, device=self.device) if (joint_ids is None) or (joint_ids == slice(None)): return self._ALL_JOINT_INDICES - if isinstance(joint_ids, torch.Tensor): - if joint_ids.dtype == torch.int64: - joint_ids = joint_ids.to(torch.int32) - return wp.from_torch(joint_ids, dtype=wp.int32) return joint_ids def _resolve_body_mask(self, body_mask: wp.array | None) -> torch.Tensor | wp.array: @@ -4524,10 +4657,6 @@ def _resolve_body_ids(self, body_ids: Sequence[int] | torch.Tensor | wp.array | return wp.array(body_ids, dtype=wp.int32, device=self.device) if (body_ids is None) or (body_ids == slice(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, dtype=wp.int32) return body_ids def _resolve_fixed_tendon_mask(self, fixed_tendon_mask: wp.array | None) -> torch.Tensor | wp.array: @@ -4740,11 +4869,14 @@ def write_joint_state_to_sim( joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ): - """Deprecated, same as :meth:`write_joint_state_to_sim_index`.""" + """Deprecated, same as :meth:`write_joint_position_to_sim_index` and + :meth:`write_joint_velocity_to_sim_index`.""" warnings.warn( "The function 'write_joint_state_to_sim' will be deprecated in a future release. Please" - " use 'write_joint_state_to_sim_index' instead.", + " use 'write_joint_position_to_sim_index' and 'write_joint_velocity_to_sim_index' instead.", DeprecationWarning, stacklevel=2, ) - self.write_joint_state_to_sim_index(position=position, velocity=velocity, joint_ids=joint_ids, env_ids=env_ids) + # set into simulation + self.write_joint_position_to_sim_index(position=position, joint_ids=joint_ids, env_ids=env_ids) + self.write_joint_velocity_to_sim_index(velocity=velocity, joint_ids=joint_ids, env_ids=env_ids) diff --git a/source/isaaclab_physx/test/assets/test_newton_actuators_physx.py b/source/isaaclab_physx/test/assets/test_newton_actuators_physx.py new file mode 100644 index 000000000000..8e8d2ea134c4 --- /dev/null +++ b/source/isaaclab_physx/test/assets/test_newton_actuators_physx.py @@ -0,0 +1,1100 @@ +# 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 + +"""PD actuator equivalence tests on ANYmal-C (floating-base quadruped) — PhysX backend. + +Compares IsaacLab-native actuators against Newton-native actuators (created +from the same Lab configs via USD authoring, stepped via +:class:`PhysxActuatorWrapper`) on the PhysX physics backend. Both paths +must produce identical joint trajectories within tolerance. + +Using ANYmal-C — a 12-DOF quadruped on a floating base — exercises the +full Lab-to-Newton config translation pipeline on a real-world robot. +""" + +from isaaclab.app import AppLauncher + +simulation_app = AppLauncher(headless=True).app + +import json +import os +import tempfile +import unittest + +import torch +import warp as wp +from isaaclab_physx.assets import Articulation +from isaaclab_physx.physics import PhysxCfg + +import isaaclab.sim as sim_utils +from isaaclab.actuators import DCMotorCfg, DelayedPDActuatorCfg, IdealPDActuatorCfg, ImplicitActuatorCfg +from isaaclab.sim import SimulationCfg, build_simulation_context + +from isaaclab_assets import ANYMAL_C_CFG + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +NUM_ENVS = 2 +NUM_STEPS = 10 +DT = 1.0 / 120.0 +TARGET_OFFSET = 0.1 # [rad] added to initial joint positions + +# --------------------------------------------------------------------------- +# Actuator configurations under test +# --------------------------------------------------------------------------- + +IDEAL_PD_ACTUATORS = { + "legs": IdealPDActuatorCfg( + joint_names_expr=[".*HAA", ".*HFE", ".*KFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), +} + +DC_MOTOR_ACTUATORS = { + "legs": DCMotorCfg( + joint_names_expr=[".*HAA", ".*HFE", ".*KFE"], + saturation_effort=120.0, + effort_limit=80.0, + velocity_limit=7.5, + stiffness={".*": 40.0}, + damping={".*": 5.0}, + ), +} + +MIXED_ACTUATORS = { + "hips": IdealPDActuatorCfg( + joint_names_expr=[".*HAA"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + "knees": DCMotorCfg( + joint_names_expr=[".*HFE", ".*KFE"], + saturation_effort=120.0, + effort_limit=80.0, + velocity_limit=7.5, + stiffness={".*": 40.0}, + damping={".*": 5.0}, + ), +} + +DELAYED_PD_ACTUATORS = { + "legs": DelayedPDActuatorCfg( + joint_names_expr=[".*HAA", ".*HFE", ".*KFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + min_delay=2, + max_delay=4, + ), +} + +MIXED_WITH_IMPLICIT_ACTUATORS = { + "hips": ImplicitActuatorCfg( + joint_names_expr=[".*HAA"], + stiffness=40.0, + damping=5.0, + ), + "thighs": IdealPDActuatorCfg( + joint_names_expr=[".*HFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + "knees": DCMotorCfg( + joint_names_expr=[".*KFE"], + saturation_effort=120.0, + effort_limit=80.0, + velocity_limit=7.5, + stiffness=40.0, + damping=5.0, + ), +} + +# --------------------------------------------------------------------------- +# Simulation runner +# --------------------------------------------------------------------------- + + +def _run_simulation( + actuators: dict, + use_newton_actuators: bool, + *, + num_steps: int = NUM_STEPS, + feedforward: float | None = None, +) -> dict: + """Run ANYmal-C on PhysX and return recorded trajectories + telemetry. + + Always records ``joint_pos``, ``joint_vel``, ``computed_torque``, and + ``applied_torque``. Optionally applies a constant per-DOF feedforward + effort target. + """ + sim_cfg = SimulationCfg(dt=DT, physics=PhysxCfg(), use_newton_actuators=use_newton_actuators) + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0, 0)) + art_cfg = ANYMAL_C_CFG.replace(actuators=actuators, prim_path="/World/Env_.*/Robot") + articulation = Articulation(art_cfg) + sim.reset() + assert articulation.is_initialized + + init_pos = wp.to_torch(articulation.data.joint_pos).clone() + target_pos = init_pos + TARGET_OFFSET + target_vel = torch.zeros_like(init_pos) + articulation.set_joint_position_target_index(target=target_pos) + articulation.set_joint_velocity_target_index(target=target_vel) + if feedforward is not None: + articulation.set_joint_effort_target_index( + target=torch.full_like(init_pos, feedforward), + ) + + recorded_pos, recorded_vel = [], [] + recorded_computed, recorded_applied = [], [] + for _ in range(num_steps): + articulation.write_data_to_sim() + sim.step() + articulation.update(DT) + recorded_pos.append(wp.to_torch(articulation.data.joint_pos).clone()) + recorded_vel.append(wp.to_torch(articulation.data.joint_vel).clone()) + recorded_computed.append(wp.to_torch(articulation.data.computed_torque).clone()) + recorded_applied.append(wp.to_torch(articulation.data.applied_torque).clone()) + + return { + "joint_pos": recorded_pos, + "joint_vel": recorded_vel, + "computed_torque": recorded_computed, + "applied_torque": recorded_applied, + "target_pos": target_pos.clone(), + "target_vel": target_vel.clone(), + } + + +# --------------------------------------------------------------------------- +# Base test class +# --------------------------------------------------------------------------- + + +class _EquivalenceTestBase(unittest.TestCase): + """Base for Lab-vs-Newton equivalence tests on the PhysX backend. + + Subclasses set ``actuators`` to the config under test. ``setUpClass`` + runs the simulation with both ``use_newton_actuators=False`` (Lab path) + and ``True`` (Newton via PhysxActuatorWrapper) and stores the results. + """ + + __test__ = False + actuators: dict = {} + feedforward: float | None = None + pos_atol: float = 2e-3 + pos_rtol: float = 1e-3 + vel_atol: float = 1e-2 + vel_rtol: float = 1e-2 + torque_atol: float = 1e-3 + torque_rtol: float = 1e-3 + + @classmethod + def setUpClass(cls): + cls.lab_result = _run_simulation( + cls.actuators, + use_newton_actuators=False, + feedforward=cls.feedforward, + ) + cls.newton_result = _run_simulation( + cls.actuators, + use_newton_actuators=True, + feedforward=cls.feedforward, + ) + + def test_joint_positions_match(self): + for step_i, (lab, newton) in enumerate(zip(self.lab_result["joint_pos"], self.newton_result["joint_pos"])): + torch.testing.assert_close( + lab, + newton, + atol=self.pos_atol, + rtol=self.pos_rtol, + msg=f"Joint positions diverged at step {step_i}", + ) + + def test_joint_velocities_match(self): + for step_i, (lab, newton) in enumerate(zip(self.lab_result["joint_vel"], self.newton_result["joint_vel"])): + torch.testing.assert_close( + lab, + newton, + atol=self.vel_atol, + rtol=self.vel_rtol, + msg=f"Joint velocities diverged at step {step_i}", + ) + + def test_applied_torque_match(self): + for step_i, (lab, newton) in enumerate( + zip(self.lab_result["applied_torque"], self.newton_result["applied_torque"]) + ): + torch.testing.assert_close( + lab, + newton, + atol=self.torque_atol, + rtol=self.torque_rtol, + msg=f"applied_torque diverged at step {step_i}", + ) + + def test_computed_torque_match(self): + for step_i, (lab, newton) in enumerate( + zip(self.lab_result["computed_torque"], self.newton_result["computed_torque"]) + ): + torch.testing.assert_close( + lab, + newton, + atol=self.torque_atol, + rtol=self.torque_rtol, + msg=f"computed_torque diverged at step {step_i}", + ) + + +# --------------------------------------------------------------------------- +# Equivalence tests with different actuator types +# --------------------------------------------------------------------------- + + +class TestIdealPDEquivalence(_EquivalenceTestBase): + """IdealPDActuator on all 12 joints: Lab vs Newton (PhysX backend).""" + + __test__ = True + actuators = IDEAL_PD_ACTUATORS + + +class TestDCMotorEquivalence(_EquivalenceTestBase): + """DCMotor actuator on all 12 joints: Lab vs Newton (PhysX backend).""" + + __test__ = True + actuators = DC_MOTOR_ACTUATORS + + +class TestMixedActuatorEquivalence(_EquivalenceTestBase): + """Mixed actuators (IdealPD on HAA, DCMotor on HFE/KFE): Lab vs Newton (PhysX).""" + + __test__ = True + actuators = MIXED_ACTUATORS + + +class TestDelayedPDEquivalence(_EquivalenceTestBase): + """DelayedPDActuator on all 12 joints: Lab vs Newton (PhysX). + + Verifies that actuator command delays are correctly authored and + produce matching trajectories on the PhysX backend. + """ + + __test__ = True + actuators = DELAYED_PD_ACTUATORS + + +class TestMixedWithImplicitEquivalence(_EquivalenceTestBase): + """Implicit HAA + IdealPD HFE + DCMotor KFE: Lab vs Newton (PhysX). + + Verifies that implicit actuators (handled by PhysX joint drives) + coexist correctly with explicit Newton actuators via PhysxActuatorWrapper. + """ + + __test__ = True + actuators = MIXED_WITH_IMPLICIT_ACTUATORS + + +# --------------------------------------------------------------------------- +# Implicit-only fast-path: enable Newton-actuator branch on PhysX with no explicit groups +# --------------------------------------------------------------------------- + +IMPLICIT_ONLY_ACTUATORS = { + "legs": ImplicitActuatorCfg( + joint_names_expr=[".*HAA", ".*HFE", ".*KFE"], + stiffness=40.0, + damping=5.0, + ), +} + + +class TestImplicitOnlyEquivalencePhysx(_EquivalenceTestBase): + """All-implicit articulation on PhysX with ``use_newton_actuators=True``: Lab vs fast-path.""" + + __test__ = True + actuators = IMPLICIT_ONLY_ACTUATORS + + +class TestImplicitWithFeedforwardEquivalencePhysx(_EquivalenceTestBase): + """Implicit-only actuators with a non-zero feedforward effort target on PhysX.""" + + __test__ = True + actuators = IMPLICIT_ONLY_ACTUATORS + feedforward = 5.0 + + +# --------------------------------------------------------------------------- +# Heterogeneous multi-articulation (ANYmal floating-base + Cartpole fixed-base) +# --------------------------------------------------------------------------- + + +CARTPOLE_EXPLICIT_ACTUATORS = { + "all_joints": IdealPDActuatorCfg( + joint_names_expr=["slider_to_cart", "cart_to_pole"], + stiffness=10.0, + damping=1.0, + effort_limit=100.0, + ), +} + + +def _run_anymal_and_cartpole(use_newton_actuators: bool, *, num_steps: int = NUM_STEPS) -> dict: + """Spawn ANYmal-C + Cartpole per env on PhysX (different DOF counts, base types).""" + from isaaclab_assets import CARTPOLE_CFG # noqa: PLC0415 + + sim_cfg = SimulationCfg(dt=DT, physics=PhysxCfg(), use_newton_actuators=use_newton_actuators) + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 6.0, 0, 0)) + + anymal_cfg = ANYMAL_C_CFG.replace(actuators=IDEAL_PD_ACTUATORS, prim_path="/World/Env_.*/Anymal") + cartpole_cfg = CARTPOLE_CFG.replace( + actuators=CARTPOLE_EXPLICIT_ACTUATORS, + prim_path="/World/Env_.*/Cartpole", + ) + cartpole_cfg.init_state = cartpole_cfg.init_state.replace(pos=(0.0, 3.0, 2.0)) + + anymal = Articulation(anymal_cfg) + cartpole = Articulation(cartpole_cfg) + sim.reset() + assert anymal.is_initialized and cartpole.is_initialized + + init_anymal = wp.to_torch(anymal.data.joint_pos).clone() + init_cartpole = wp.to_torch(cartpole.data.joint_pos).clone() + anymal.set_joint_position_target_index(target=init_anymal + TARGET_OFFSET) + anymal.set_joint_velocity_target_index(target=torch.zeros_like(init_anymal)) + cartpole.set_joint_position_target_index(target=init_cartpole + TARGET_OFFSET) + cartpole.set_joint_velocity_target_index(target=torch.zeros_like(init_cartpole)) + + pos_anymal, pos_cartpole = [], [] + for _ in range(num_steps): + anymal.write_data_to_sim() + cartpole.write_data_to_sim() + sim.step() + anymal.update(DT) + cartpole.update(DT) + pos_anymal.append(wp.to_torch(anymal.data.joint_pos).clone()) + pos_cartpole.append(wp.to_torch(cartpole.data.joint_pos).clone()) + + return {"joint_pos_anymal": pos_anymal, "joint_pos_cartpole": pos_cartpole} + + +class TestHeterogeneousMultiArticulationPhysx(unittest.TestCase): + """Two structurally-different articulations (ANYmal floating + Cartpole fixed) on PhysX. + + Each PhysX articulation owns its own :class:`PhysxActuatorWrapper` + and per-art :class:`NewtonActuatorAdapter`. Heterogeneous DOF counts + (12 vs 2) and base types (floating vs fixed) verify the + per-articulation authoring + adapter construction works for varied + structures. Equivalence against the Lab actuator path is the + meaningful end-to-end check. + """ + + @classmethod + def setUpClass(cls): + cls.lab_result = _run_anymal_and_cartpole(use_newton_actuators=False) + cls.newton_result = _run_anymal_and_cartpole(use_newton_actuators=True) + + def test_anymal_matches_lab(self): + for step_i, (lab, newton) in enumerate( + zip(self.lab_result["joint_pos_anymal"], self.newton_result["joint_pos_anymal"]) + ): + torch.testing.assert_close( + newton, + lab, + atol=2e-3, + rtol=1e-3, + msg=f"ANYmal joint_pos diverged from Lab path at step {step_i}", + ) + + def test_cartpole_matches_lab(self): + for step_i, (lab, newton) in enumerate( + zip(self.lab_result["joint_pos_cartpole"], self.newton_result["joint_pos_cartpole"]) + ): + torch.testing.assert_close( + newton, + lab, + atol=2e-3, + rtol=1e-3, + msg=f"Cartpole joint_pos diverged from Lab path at step {step_i}", + ) + + +# --------------------------------------------------------------------------- +# Domain randomization via events.py — PhysX backend +# --------------------------------------------------------------------------- + + +class _MockScene: + """Minimal stand-in for ``InteractiveScene`` accepted by ``ManagerTermBase``.""" + + def __init__(self, assets: dict, num_envs: int): + self._assets = assets + self.num_envs = num_envs + + def __getitem__(self, name: str): + return self._assets[name] + + +class _MockEnv: + """Minimal stand-in for ``ManagerBasedEnv`` for invoking DR terms. + + ``randomize_actuator_gains`` only reads ``env.scene[name]`` and + ``env.scene.num_envs`` (plus ``env.num_envs`` / ``env.device`` from the + ``ManagerTermBase`` properties). No simulator access is needed because + the DR term reaches the actuator adapter via ``self.the actuator adapter``. + """ + + def __init__(self, assets: dict, num_envs: int, device: str): + self.scene = _MockScene(assets, num_envs) + self.num_envs = num_envs + self.device = device + + +def _build_dr_term(env, asset_name, joint_ids=None): + from isaaclab.envs.mdp.events import randomize_actuator_gains # noqa: PLC0415 + from isaaclab.managers import EventTermCfg, SceneEntityCfg # noqa: PLC0415 + + asset_cfg = SceneEntityCfg(asset_name) + if joint_ids is not None: + asset_cfg.joint_ids = joint_ids + cfg = EventTermCfg( + func=randomize_actuator_gains, + params={ + "asset_cfg": asset_cfg, + "stiffness_distribution_params": (100.0, 100.0), + "damping_distribution_params": (5.0, 5.0), + "operation": "abs", + "distribution": "uniform", + }, + ) + return randomize_actuator_gains(cfg, env), asset_cfg + + +class TestRandomizeActuatorGainsViaEventsPhysx(unittest.TestCase): + """End-to-end DR test for the PhysX backend. + + Drives ``randomize_actuator_gains`` (events.py) and verifies the new + kp/kd values land in the per-articulation adapter's buffer at the + right cells — exercising the full path: events → + the actuator adapter → write_stiffness/damping → propagation + to controllers. + + With ``operation="abs"`` and ``distribution="uniform"`` over a + degenerate range ``(K, K)``, every randomized cell is set to exactly + ``K`` — so the assertions are deterministic. + """ + + @staticmethod + def _gather_param(adapter, num_envs, num_joints, attr, device): + """Reconstruct a ``(num_envs, num_joints)`` view of ``controller.`` across all actuators.""" + out = torch.zeros((num_envs, num_joints), device=device) + for act in adapter.actuators: + ctrl = act.controller + if not hasattr(ctrl, attr): + continue + flat_t = wp.to_torch(getattr(ctrl, attr)) + idx_np = act.indices.numpy() + envs = torch.from_numpy((idx_np // num_joints).astype("int64")).to(device) + locals_ = torch.from_numpy((idx_np % num_joints).astype("int64")).to(device) + out[envs, locals_] = flat_t + return out + + def test_single_articulation(self): + sim_cfg = SimulationCfg(dt=DT, physics=PhysxCfg(), use_newton_actuators=True) + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0, 0)) + art_cfg = ANYMAL_C_CFG.replace( + actuators=IDEAL_PD_ACTUATORS, + prim_path="/World/Env_.*/Robot", + ) + anymal = Articulation(art_cfg) + sim.reset() + + adapter = anymal.newton_actuator_adapter + self.assertIsNotNone(adapter, "PhysX per-articulation adapter should exist") + n = anymal.num_joints + kp_before = self._gather_param(adapter, NUM_ENVS, n, "kp", anymal.device).clone() + kd_before = self._gather_param(adapter, NUM_ENVS, n, "kd", anymal.device).clone() + + env = _MockEnv({"robot": anymal}, NUM_ENVS, anymal.device) + term, asset_cfg = _build_dr_term(env, "robot") + env_ids = torch.tensor([0], device=anymal.device, dtype=torch.long) + + term( + env, + env_ids=env_ids, + asset_cfg=asset_cfg, + stiffness_distribution_params=(100.0, 100.0), + damping_distribution_params=(5.0, 5.0), + operation="abs", + distribution="uniform", + ) + + kp_after = self._gather_param(adapter, NUM_ENVS, n, "kp", anymal.device) + kd_after = self._gather_param(adapter, NUM_ENVS, n, "kd", anymal.device) + torch.testing.assert_close(kp_after[0], torch.full((n,), 100.0, device=anymal.device)) + torch.testing.assert_close(kd_after[0], torch.full((n,), 5.0, device=anymal.device)) + for env_idx in range(1, NUM_ENVS): + torch.testing.assert_close(kp_after[env_idx], kp_before[env_idx]) + torch.testing.assert_close(kd_after[env_idx], kd_before[env_idx]) + + def test_two_articulations(self): + from isaaclab_assets import CARTPOLE_CFG # noqa: PLC0415 + + sim_cfg = SimulationCfg(dt=DT, physics=PhysxCfg(), use_newton_actuators=True) + with build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) as sim: + sim._app_control_on_stop_handle = None + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 6.0, 0, 0)) + + anymal_cfg = ANYMAL_C_CFG.replace(actuators=IDEAL_PD_ACTUATORS, prim_path="/World/Env_.*/Anymal") + cartpole_cfg = CARTPOLE_CFG.replace( + actuators=CARTPOLE_EXPLICIT_ACTUATORS, + prim_path="/World/Env_.*/Cartpole", + ) + cartpole_cfg.init_state = cartpole_cfg.init_state.replace(pos=(0.0, 3.0, 2.0)) + anymal = Articulation(anymal_cfg) + cartpole = Articulation(cartpole_cfg) + sim.reset() + + # On PhysX each articulation owns its own adapter — they are distinct objects. + anymal_adapter = anymal.newton_actuator_adapter + cartpole_adapter = cartpole.newton_actuator_adapter + self.assertIsNotNone(anymal_adapter) + self.assertIsNotNone(cartpole_adapter) + self.assertIsNot(anymal_adapter, cartpole_adapter) + + n_anymal = anymal.num_joints + n_cp = cartpole.num_joints + anymal_kp_before = self._gather_param(anymal_adapter, NUM_ENVS, n_anymal, "kp", anymal.device).clone() + anymal_kd_before = self._gather_param(anymal_adapter, NUM_ENVS, n_anymal, "kd", anymal.device).clone() + cp_kp_before = self._gather_param(cartpole_adapter, NUM_ENVS, n_cp, "kp", anymal.device).clone() + cp_kd_before = self._gather_param(cartpole_adapter, NUM_ENVS, n_cp, "kd", anymal.device).clone() + + env = _MockEnv({"anymal": anymal, "cartpole": cartpole}, NUM_ENVS, anymal.device) + term, asset_cfg = _build_dr_term(env, "cartpole") + env_ids = torch.tensor([0], device=anymal.device, dtype=torch.long) + + term( + env, + env_ids=env_ids, + asset_cfg=asset_cfg, + stiffness_distribution_params=(100.0, 100.0), + damping_distribution_params=(5.0, 5.0), + operation="abs", + distribution="uniform", + ) + + cp_kp_after = self._gather_param(cartpole_adapter, NUM_ENVS, n_cp, "kp", anymal.device) + cp_kd_after = self._gather_param(cartpole_adapter, NUM_ENVS, n_cp, "kd", anymal.device) + torch.testing.assert_close(cp_kp_after[0], torch.full((n_cp,), 100.0, device=anymal.device)) + torch.testing.assert_close(cp_kd_after[0], torch.full((n_cp,), 5.0, device=anymal.device)) + for env_idx in range(1, NUM_ENVS): + torch.testing.assert_close(cp_kp_after[env_idx], cp_kp_before[env_idx]) + torch.testing.assert_close(cp_kd_after[env_idx], cp_kd_before[env_idx]) + + # ANYmal's controllers are fully untouched — DR was scoped to cartpole. + anymal_kp_after = self._gather_param(anymal_adapter, NUM_ENVS, n_anymal, "kp", anymal.device) + anymal_kd_after = self._gather_param(anymal_adapter, NUM_ENVS, n_anymal, "kd", anymal.device) + torch.testing.assert_close(anymal_kp_after, anymal_kp_before) + torch.testing.assert_close(anymal_kd_after, anymal_kd_before) + + +# --------------------------------------------------------------------------- +# Per-env reset: actuator state isolation +# --------------------------------------------------------------------------- + +RESET_WARMUP_STEPS = 3 + + +class TestActuatorStateReset(unittest.TestCase): + """Reset must clear the actuator state buffers for the requested envs only. + + Inspects ``adapter.actuators[i].state.delay_state.num_pushes`` directly: + + * After warmup, ``num_pushes > 0`` for every DOF (buffer was populated). + * After ``articulation.reset(env_ids=[0])``, the entries for env 0's DOFs + must be ``0`` and the entries for env 1's DOFs must remain ``> 0``. + + Done independently on Lab and Newton paths. PhysX-side adapter is + per-articulation, available via ``articulation.newton_actuator_adapter``. + """ + + RESET_ENV: int = 0 + UNCHANGED_ENV: int = 1 + + def _build_and_warm(self, *, use_newton_actuators: bool): + sim_cfg = SimulationCfg( + dt=DT, + physics=PhysxCfg(), + use_newton_actuators=use_newton_actuators, + ) + ctx = build_simulation_context( + device="cuda:0", + gravity_enabled=True, + add_ground_plane=True, + sim_cfg=sim_cfg, + ) + sim = ctx.__enter__() + sim._app_control_on_stop_handle = None + for i in range(NUM_ENVS): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0, 0)) + art_cfg = ANYMAL_C_CFG.replace( + actuators=DELAYED_PD_ACTUATORS, + prim_path="/World/Env_.*/Robot", + ) + articulation = Articulation(art_cfg) + sim.reset() + + init_pos = wp.to_torch(articulation.data.joint_pos).clone() + target_pos = init_pos + TARGET_OFFSET + target_vel = torch.zeros_like(init_pos) + articulation.set_joint_position_target_index(target=target_pos) + articulation.set_joint_velocity_target_index(target=target_vel) + for _ in range(RESET_WARMUP_STEPS): + articulation.write_data_to_sim() + sim.step() + articulation.update(DT) + return ctx, sim, articulation + + def test_newton_state_reset_isolated_to_reset_env(self): + """Newton: ``num_pushes`` zeroes for env 0's DOFs only after reset of [0].""" + ctx, sim, articulation = self._build_and_warm(use_newton_actuators=True) + try: + adapter = articulation.newton_actuator_adapter + self.assertIsNotNone(adapter) + stateful_pairs = [ + (act, st) + for act, st in zip(adapter.actuators, adapter._states_a) + if st is not None and getattr(st, "delay_state", None) is not None + ] + self.assertGreater(len(stateful_pairs), 0, "expected at least one DelayedPD actuator with delay_state") + + for act, state in stateful_pairs: + pushes_before = state.delay_state.num_pushes.numpy() + self.assertTrue( + (pushes_before > 0).all(), + "expected non-zero num_pushes for all DOFs after warmup", + ) + + articulation.reset(env_ids=torch.tensor([self.RESET_ENV], device=articulation.device, dtype=torch.long)) + + # Map each entry of ``act.indices`` to its env via ``adapter.num_joints`` + # (PhysX adapter is per-articulation so this equals articulation.num_joints — + # using adapter.num_joints keeps the test symmetric with the Newton path). + for act, state in stateful_pairs: + pushes_after = state.delay_state.num_pushes.numpy() + indices_np = act.indices.numpy() + for i, global_dof in enumerate(indices_np): + env = int(global_dof) // adapter.num_joints + if env == self.RESET_ENV: + self.assertEqual( + int(pushes_after[i]), + 0, + f"DOF {i} (env {env}) should be reset to 0, got {pushes_after[i]}", + ) + else: + self.assertGreater( + int(pushes_after[i]), + 0, + f"DOF {i} (env {env}) was NOT in reset env_ids but num_pushes is 0", + ) + finally: + ctx.__exit__(None, None, None) + + def test_lab_state_reset_isolated_to_reset_env(self): + """Lab: DelayedPDActuator circular buffer zeroed for env 0 only.""" + ctx, sim, articulation = self._build_and_warm(use_newton_actuators=False) + try: + from isaaclab.actuators import DelayedPDActuator # noqa: PLC0415 + + delayed = [a for a in articulation.actuators.values() if isinstance(a, DelayedPDActuator)] + self.assertGreater(len(delayed), 0, "expected at least one Lab DelayedPDActuator") + actuator = delayed[0] + buf = actuator.positions_delay_buffer._circular_buffer._buffer + self.assertIsNotNone(buf, "delay buffer should be populated after warmup") + self.assertTrue( + (buf[:, self.UNCHANGED_ENV] != 0).any().item(), + "expected non-zero buffer entries for env 1 after warmup", + ) + + articulation.reset(env_ids=torch.tensor([self.RESET_ENV], device=articulation.device, dtype=torch.long)) + + self.assertTrue( + torch.all(buf[:, self.RESET_ENV] == 0).item(), + f"Lab: env {self.RESET_ENV} buffer not zeroed after reset.", + ) + self.assertTrue( + (buf[:, self.UNCHANGED_ENV] != 0).any().item(), + f"Lab: env {self.UNCHANGED_ENV} buffer was zeroed — reset leaked into an unselected env.", + ) + finally: + ctx.__exit__(None, None, None) + + +# --------------------------------------------------------------------------- +# RemotizedPD authoring: PD + delay + position-based clamping lookup table +# --------------------------------------------------------------------------- + +SPOT_KNEE_LOOKUP = [ + [-2.792900, -24.776718, 37.165077], + [-2.767442, -26.290108, 39.435162], + [-2.741984, -27.793369, 41.690054], + [-2.716526, -29.285997, 43.928996], + [-2.691068, -30.767536, 46.151304], + [-2.665610, -32.237423, 48.356134], + [-2.640152, -33.695168, 50.542751], + [-2.614694, -35.140221, 52.710331], + [-2.589236, -36.572052, 54.858078], + [-2.563778, -37.990086, 56.985128], + [-2.538320, -39.393730, 59.090595], + [-2.512862, -40.782406, 61.173609], + [-2.487404, -42.155487, 63.233231], + [-2.461946, -43.512371, 65.268557], + [-2.436488, -44.852371, 67.278557], + [-2.411030, -46.174873, 69.262310], + [-2.385572, -47.479156, 71.218735], + [-2.360114, -48.764549, 73.146824], + [-2.334656, -50.030334, 75.045502], + [-2.309198, -51.275761, 76.913641], + [-2.283740, -52.500103, 78.750154], + [-2.258282, -53.702587, 80.553881], + [-2.232824, -54.882442, 82.323664], + [-2.207366, -56.038860, 84.058290], + [-2.181908, -57.171028, 85.756542], + [-2.156450, -58.278133, 87.417200], + [-2.130992, -59.359314, 89.038971], + [-2.105534, -60.413738, 90.620607], + [-2.080076, -61.440529, 92.160793], + [-2.054618, -62.438812, 93.658218], + [-2.029160, -63.407692, 95.111538], + [-2.003702, -64.346268, 96.519402], + [-1.978244, -65.253670, 97.880505], + [-1.952786, -66.128944, 99.193417], + [-1.927328, -66.971176, 100.456764], + [-1.901870, -67.779457, 101.669186], + [-1.876412, -68.552864, 102.829296], + [-1.850954, -69.290451, 103.935677], + [-1.825496, -69.991325, 104.986988], + [-1.800038, -70.654541, 105.981812], + [-1.774580, -71.279190, 106.918785], + [-1.749122, -71.864319, 107.796478], + [-1.723664, -72.409088, 108.613632], + [-1.698206, -72.912567, 109.368851], + [-1.672748, -73.373871, 110.060806], + [-1.647290, -73.792130, 110.688194], + [-1.621832, -74.166512, 111.249767], + [-1.596374, -74.496147, 111.744221], + [-1.570916, -74.780251, 112.170376], + [-1.545458, -75.017998, 112.526997], + [-1.520000, -75.208656, 112.812984], + [-1.494542, -75.351448, 113.027172], + [-1.469084, -75.445686, 113.168530], + [-1.443626, -75.490677, 113.236015], + [-1.418168, -75.485771, 113.228657], + [-1.392710, -75.430344, 113.145515], + [-1.367252, -75.323830, 112.985744], + [-1.341794, -75.165688, 112.748531], + [-1.316336, -74.955406, 112.433109], + [-1.290878, -74.692551, 112.038826], + [-1.265420, -74.376694, 111.565041], + [-1.239962, -74.007477, 111.011215], + [-1.214504, -73.584579, 110.376869], + [-1.189046, -73.107742, 109.661613], + [-1.163588, -72.576752, 108.865128], + [-1.138130, -71.991455, 107.987183], + [-1.112672, -71.351707, 107.027561], + [-1.087214, -70.657486, 105.986229], + [-1.061756, -69.908813, 104.863220], + [-1.036298, -69.105721, 103.658581], + [-1.010840, -68.248337, 102.372505], + [-0.985382, -67.336861, 101.005291], + [-0.959924, -66.371513, 99.557270], + [-0.934466, -65.352615, 98.028923], + [-0.909008, -64.280533, 96.420799], + [-0.883550, -63.155693, 94.733540], + [-0.858092, -61.978588, 92.967882], + [-0.832634, -60.749775, 91.124662], + [-0.807176, -59.469845, 89.204767], + [-0.781718, -58.139503, 87.209255], + [-0.756260, -56.759487, 85.139231], + [-0.730802, -55.330616, 82.995924], + [-0.705344, -53.853729, 80.780594], + [-0.679886, -52.329796, 78.494694], + [-0.654428, -50.759762, 76.139643], + [-0.628970, -49.144699, 73.717049], + [-0.603512, -47.485737, 71.228605], + [-0.578054, -45.784004, 68.676006], + [-0.552596, -44.040764, 66.061146], + [-0.527138, -42.257267, 63.385900], + [-0.501680, -40.434883, 60.652325], + [-0.476222, -38.574947, 57.862421], + [-0.450764, -36.678982, 55.018473], + [-0.425306, -34.748432, 52.122648], + [-0.399848, -32.784836, 49.177254], + [-0.374390, -30.789810, 46.184715], + [-0.348932, -28.764952, 43.147428], + [-0.323474, -26.711969, 40.067954], + [-0.298016, -24.632576, 36.948864], + [-0.272558, -22.528547, 33.792821], + [-0.247100, -20.401667, 30.602500], +] + + +class TestRemotizedPDEquivalence(_EquivalenceTestBase): + """RemotizedPD (PD + delay + position-based clamping): Lab vs Newton (PhysX). + + Uses the Spot knee lookup table on ANYmal's KFE joints with IdealPD + on HAA and HFE. + """ + + __test__ = True + + @classmethod + def setUpClass(cls): + from isaaclab.actuators.actuator_pd_cfg import RemotizedPDActuatorCfg # noqa: PLC0415 + + cls.actuators = { + "hips": IdealPDActuatorCfg( + joint_names_expr=[".*HAA", ".*HFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + "knees": RemotizedPDActuatorCfg( + joint_names_expr=[".*KFE"], + stiffness=60.0, + damping=1.5, + effort_limit=80.0, + max_delay=3, + joint_parameter_lookup=SPOT_KNEE_LOOKUP, + ), + } + super().setUpClass() + + +class TestRemotizedPDFunctional(unittest.TestCase): + """Verify RemotizedPDActuatorCfg runs correctly on PhysX with Newton actuators. + + Uses the Spot knee lookup table (102 entries) on ANYmal's KFE joints. + """ + + @classmethod + def setUpClass(cls): + from isaaclab.actuators.actuator_pd_cfg import RemotizedPDActuatorCfg # noqa: PLC0415 + + cls.result = _run_simulation( + { + "hips": IdealPDActuatorCfg( + joint_names_expr=[".*HAA", ".*HFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + "knees": RemotizedPDActuatorCfg( + joint_names_expr=[".*KFE"], + stiffness=60.0, + damping=1.5, + effort_limit=80.0, + max_delay=3, + joint_parameter_lookup=SPOT_KNEE_LOOKUP, + ), + }, + use_newton_actuators=True, + ) + + def test_positions_finite(self): + for step_i, pos in enumerate(self.result["joint_pos"]): + self.assertTrue( + torch.isfinite(pos).all(), + f"Non-finite positions at step {step_i}", + ) + + +# --------------------------------------------------------------------------- +# Neural network actuator authoring: MLP and LSTM +# --------------------------------------------------------------------------- + + +def _make_dummy_mlp_checkpoint(device: str = "cpu") -> str: + """Create a minimal TorchScript MLP checkpoint with metadata.""" + torch.manual_seed(42) + net = ( + torch.nn.Sequential( + torch.nn.Linear(6, 8), + torch.nn.ELU(), + torch.nn.Linear(8, 1), + ) + .to(device) + .eval() + ) + scripted = torch.jit.script(net) + + with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as tmp: + tmp_path = tmp.name + extra = { + "metadata.json": json.dumps( + { + "model_type": "mlp", + "input_order": "pos_vel", + "input_idx": [0, 1, 2], + "pos_scale": 1.0, + "vel_scale": 0.5, + "torque_scale": 2.0, + } + ) + } + torch.jit.save(scripted, tmp_path, _extra_files=extra) + return tmp_path + + +class _DummyLSTM(torch.nn.Module): + """Minimal LSTM network for actuator testing.""" + + def __init__(self): + super().__init__() + self.lstm = torch.nn.LSTM(input_size=2, hidden_size=4, num_layers=1, batch_first=True) + self.fc = torch.nn.Linear(4, 1) + + def forward( + self, + x: torch.Tensor, + hc: tuple[torch.Tensor, torch.Tensor], + ) -> tuple[torch.Tensor, tuple[torch.Tensor, torch.Tensor]]: + out, hc_new = self.lstm(x, hc) + return self.fc(out[:, -1, :]), hc_new + + +def _make_dummy_lstm_checkpoint(device: str = "cpu") -> str: + """Create a minimal TorchScript LSTM checkpoint with metadata.""" + torch.manual_seed(42) + net = _DummyLSTM().to(device).eval() + scripted = torch.jit.script(net) + + with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as tmp: + tmp_path = tmp.name + extra = {"metadata.json": json.dumps({"model_type": "lstm"})} + torch.jit.save(scripted, tmp_path, _extra_files=extra) + return tmp_path + + +class TestNeuralMLPFunctional(unittest.TestCase): + """Verify ActuatorNetMLPCfg runs on PhysX with Newton actuators.""" + + @classmethod + def setUpClass(cls): + from isaaclab.actuators.actuator_net_cfg import ActuatorNetMLPCfg # noqa: PLC0415 + + cls.mlp_path = _make_dummy_mlp_checkpoint() + cls.result = _run_simulation( + { + "mlp_legs": ActuatorNetMLPCfg( + joint_names_expr=[".*HAA"], + network_file=cls.mlp_path, + saturation_effort=120.0, + effort_limit=80.0, + velocity_limit=7.5, + pos_scale=-1.0, + vel_scale=1.0, + torque_scale=1.0, + input_order="pos_vel", + input_idx=[0, 1, 2], + ), + "pd_legs": IdealPDActuatorCfg( + joint_names_expr=[".*HFE", ".*KFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + }, + use_newton_actuators=True, + ) + + @classmethod + def tearDownClass(cls): + os.unlink(cls.mlp_path) + + def test_positions_finite(self): + for step_i, pos in enumerate(self.result["joint_pos"]): + self.assertTrue( + torch.isfinite(pos).all(), + f"Non-finite positions at step {step_i}", + ) + + +class TestNeuralLSTMFunctional(unittest.TestCase): + """Verify ActuatorNetLSTMCfg runs on PhysX with Newton actuators.""" + + @classmethod + def setUpClass(cls): + from isaaclab.actuators.actuator_net_cfg import ActuatorNetLSTMCfg # noqa: PLC0415 + + cls.lstm_path = _make_dummy_lstm_checkpoint() + cls.result = _run_simulation( + { + "lstm_legs": ActuatorNetLSTMCfg( + joint_names_expr=[".*HAA"], + network_file=cls.lstm_path, + saturation_effort=120.0, + effort_limit=80.0, + velocity_limit=7.5, + ), + "pd_legs": IdealPDActuatorCfg( + joint_names_expr=[".*HFE", ".*KFE"], + stiffness=40.0, + damping=5.0, + effort_limit=80.0, + ), + }, + use_newton_actuators=True, + ) + + @classmethod + def tearDownClass(cls): + os.unlink(cls.lstm_path) + + def test_positions_finite(self): + for step_i, pos in enumerate(self.result["joint_pos"]): + self.assertTrue( + torch.isfinite(pos).all(), + f"Non-finite positions at step {step_i}", + ) + + +if __name__ == "__main__": + unittest.main() From b48b24969862efa29e3cfaebee9681bb08ba7783 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Tue, 19 May 2026 07:45:56 -0700 Subject: [PATCH 109/133] Fix active preset branch resolution (#5658) ## Summary - Resolves `PresetCfg` choices by walking the active config tree, so nested preset names from inactive sibling branches cannot collide. - Avoids unnecessary Hydra/OmegaConf composition for preset-only and plain scalar override paths. - Adds focused branch-scoping regressions and a `benchmark_hydra_resolve.py` benchmark that also writes standard Isaac Lab benchmark measurements. ## Benchmark Measured `resolve_task_config()` only. Values are median wall time over 20 iterations after 3 warmups, in ms. ```text Case Pre-PR ms After PR ms Delta ms Faster cartpole_manager 114.44 2.20 -112.24 98.1% cartpole_camera_presets 142.77 23.00 -119.77 83.9% cartpole_camera_newton_ovrtx 280.90 22.88 -258.02 91.9% anymal_rough 296.99 6.09 -290.90 97.9% anymal_rough_scalar 330.45 6.14 -324.31 98.1% franka_lift_cube 308.55 4.87 -303.68 98.4% franka_reach 394.08 4.42 -389.66 98.9% franka_lift_cube_agent 406.42 5.97 -400.45 98.5% kuka_allegro_lift 838.77 61.83 -776.94 92.6% kuka_allegro_lift_single_camera 867.16 61.94 -805.22 92.9% kuka_allegro_lift_duo_camera 885.02 62.45 -822.57 92.9% kuka_allegro_lift_scalar 873.02 62.01 -811.01 92.9% cartpole_direct 240.27 1.30 -238.97 99.5% cartpole_rgb_direct 257.37 1.26 -256.11 99.5% ant_manager 303.60 2.72 -300.88 99.1% humanoid_manager 343.87 3.15 -340.72 99.1% cartpole_camera_hydra_force 492.95 118.45 -374.50 76.0% ``` ## Test plan - `PYTHONPATH="source/isaaclab_tasks:source/isaaclab" /home/zhengyuz/Projects/IsaacLab.wt/wip-feature-position_locomotion/env_isaaclab/bin/python -m pytest source/isaaclab_tasks/test/test_hydra.py source/isaaclab_tasks/test/test_preset_kit_decision.py` - `./isaaclab.sh -f` - `benchmark_hydra_resolve.py --suite broad --iterations 20 --warmup 3` on pre-PR and after-PR sources Co-authored-by: Kelly Guo --- scripts/benchmarks/benchmark_hydra_resolve.py | 260 +++++++++++ ...zhengyuz-active-tree-preset-resolution.rst | 7 + .../isaaclab_tasks/utils/hydra.py | 407 ++++++++++-------- source/isaaclab_tasks/test/test_hydra.py | 40 +- .../test/test_preset_kit_decision.py | 12 + 5 files changed, 549 insertions(+), 177 deletions(-) create mode 100644 scripts/benchmarks/benchmark_hydra_resolve.py create mode 100644 source/isaaclab_tasks/changelog.d/zhengyuz-active-tree-preset-resolution.rst diff --git a/scripts/benchmarks/benchmark_hydra_resolve.py b/scripts/benchmarks/benchmark_hydra_resolve.py new file mode 100644 index 000000000000..f3709c9de935 --- /dev/null +++ b/scripts/benchmarks/benchmark_hydra_resolve.py @@ -0,0 +1,260 @@ +# 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 + +"""Benchmark task config resolution through IsaacLab's Hydra preset layer. + +This measures the pre-Kit path used by training scripts to load an env cfg, +resolve ``PresetCfg`` selections, register the plain Hydra config, run Hydra +scalar overrides, and return the resolved env/agent cfg objects. + +The benchmark prints a local summary table and writes per-case measurements to +the standard Isaac Lab benchmark backend. It does not create environments and +does not require a GPU. + +Usage:: + + ./isaaclab.sh -p scripts/benchmarks/benchmark_hydra_resolve.py + ./isaaclab.sh -p scripts/benchmarks/benchmark_hydra_resolve.py --suite broad + ./isaaclab.sh -p scripts/benchmarks/benchmark_hydra_resolve.py --iterations 100 + ./isaaclab.sh -p scripts/benchmarks/benchmark_hydra_resolve.py \ + --case cartpole:Isaac-Cartpole-v0:: \ + --case anymal:Isaac-Velocity-Rough-Anymal-C-v0::env.scene.num_envs=256 + +Case format is ``name:task:agent_entry:arg[,arg...]``. Leave ``agent_entry`` or +``arg`` empty when not needed. +""" + +from __future__ import annotations + +import argparse +import contextlib +import io +import os +import statistics +import sys +import time +import warnings +from dataclasses import dataclass + +import gymnasium + +from isaaclab.test.benchmark import BaseIsaacLabBenchmark, SingleMeasurement + +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) + +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + import isaaclab_tasks # noqa: F401 + +from isaaclab_tasks.utils.hydra import resolve_task_config + +from scripts.benchmarks.utils import get_backend_type + + +@dataclass(frozen=True) +class Case: + name: str + task: str + agent_entry: str | None = None + args: tuple[str, ...] = () + + +QUICK_CASES = ( + Case("cartpole_manager", "Isaac-Cartpole-v0"), + Case("cartpole_camera_presets", "Isaac-Cartpole-Camera-Presets-Direct-v0", "rl_games_cfg_entry_point"), + Case("anymal_rough", "Isaac-Velocity-Rough-Anymal-C-v0"), + Case("franka_lift_cube", "Isaac-Lift-Cube-Franka-v0"), + Case( + "cartpole_camera_newton_ovrtx", + "Isaac-Cartpole-Camera-Presets-Direct-v0", + "rl_games_cfg_entry_point", + ("presets=newton_mjwarp,ovrtx_renderer",), + ), + Case("anymal_rough_scalar", "Isaac-Velocity-Rough-Anymal-C-v0", None, ("env.scene.num_envs=256",)), +) + + +BROAD_CASES = ( + *QUICK_CASES, + Case("cartpole_direct", "Isaac-Cartpole-Direct-v0"), + Case("cartpole_rgb_direct", "Isaac-Cartpole-RGB-Camera-Direct-v0"), + Case("ant_manager", "Isaac-Ant-v0"), + Case("humanoid_manager", "Isaac-Humanoid-v0", "rsl_rl_cfg_entry_point"), + Case("franka_reach", "Isaac-Reach-Franka-v0"), + Case("franka_lift_cube_agent", "Isaac-Lift-Cube-Franka-v0", "sb3_cfg_entry_point"), + Case("kuka_allegro_lift", "Isaac-Dexsuite-Kuka-Allegro-Lift-v0", "rsl_rl_cfg_entry_point"), + Case( + "kuka_allegro_lift_single_camera", + "Isaac-Dexsuite-Kuka-Allegro-Lift-v0", + "rsl_rl_cfg_entry_point", + ("presets=single_camera,rgb128",), + ), + Case( + "kuka_allegro_lift_duo_camera", + "Isaac-Dexsuite-Kuka-Allegro-Lift-v0", + "rsl_rl_cfg_entry_point", + ("presets=duo_camera,rgb128",), + ), + Case( + "kuka_allegro_lift_scalar", + "Isaac-Dexsuite-Kuka-Allegro-Lift-v0", + "rsl_rl_cfg_entry_point", + ("env.scene.num_envs=256",), + ), + Case( + "cartpole_camera_hydra_force", + "Isaac-Cartpole-Camera-Presets-Direct-v0", + "rl_games_cfg_entry_point", + ("++env.scene.num_envs=256",), + ), +) + +SUITES = {"quick": QUICK_CASES, "broad": BROAD_CASES} + + +def _parse_case(spec: str) -> Case: + parts = spec.split(":", 3) + if len(parts) != 4: + raise argparse.ArgumentTypeError("case must have format name:task:agent_entry:arg[,arg...]") + name, task, agent_entry, args = parts + if not name or not task: + raise argparse.ArgumentTypeError("case name and task must be non-empty") + return Case( + name=name, + task=task, + agent_entry=agent_entry or None, + args=tuple(arg for arg in args.split(",") if arg), + ) + + +def _resolve_once(case: Case, *, verbose: bool = False) -> None: + old_argv = sys.argv + try: + sys.argv = [old_argv[0], *case.args] + if verbose: + resolve_task_config(case.task, case.agent_entry) + else: + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + resolve_task_config(case.task, case.agent_entry) + finally: + sys.argv = old_argv + + +def _benchmark_case(case: Case, iterations: int, warmup: int, *, verbose: bool) -> list[float]: + for _ in range(warmup): + _resolve_once(case, verbose=verbose) + + times_ms = [] + for _ in range(iterations): + t0 = time.perf_counter_ns() + _resolve_once(case, verbose=verbose) + t1 = time.perf_counter_ns() + times_ms.append((t1 - t0) / 1_000_000) + return times_ms + + +def _print_results(results: dict[Case, list[float]]) -> None: + print(f"\n{'Case':<32} {'median ms':>10} {'mean ms':>10} {'stdev ms':>10} {'min ms':>10} {'max ms':>10} argv") + print(f"{'-' * 32} {'-' * 10} {'-' * 10} {'-' * 10} {'-' * 10} {'-' * 10} {'-' * 24}") + for case, times in results.items(): + stdev = statistics.stdev(times) if len(times) > 1 else 0.0 + argv = " ".join(case.args) if case.args else "(none)" + print( + f"{case.name:<32} {statistics.median(times):>10.2f} {statistics.mean(times):>10.2f}" + f" {stdev:>10.2f} {min(times):>10.2f} {max(times):>10.2f} {argv}" + ) + + +def _log_results(benchmark: BaseIsaacLabBenchmark, results: dict[Case, list[float]]) -> None: + for case, times in results.items(): + stats = { + "Median Resolve Task Config Time": statistics.median(times), + "Mean Resolve Task Config Time": statistics.mean(times), + "Stdev Resolve Task Config Time": statistics.stdev(times) if len(times) > 1 else 0.0, + "Min Resolve Task Config Time": min(times), + "Max Resolve Task Config Time": max(times), + } + for name, value in stats.items(): + benchmark.add_measurement( + "task_config", + measurement=SingleMeasurement(name=f"{case.name} {name}", value=value, unit="ms"), + ) + + benchmark.update_manual_recorders() + benchmark._finalize_impl() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Benchmark env_cfg + Hydra preset resolution.") + parser.add_argument("--iterations", type=int, default=50, help="Timed iterations per case.") + parser.add_argument("--warmup", type=int, default=5, help="Warmup iterations per case.") + parser.add_argument( + "--suite", + choices=sorted(SUITES), + default="quick", + help="Named benchmark suite to run when --case is not provided.", + ) + parser.add_argument( + "--case", + action="append", + type=_parse_case, + default=None, + help="Benchmark case in format name:task:agent_entry:arg[,arg...]. May be repeated.", + ) + parser.add_argument( + "--benchmark_backend", + type=str, + default="summary", + choices=[ + "json", + "osmo", + "omniperf", + "summary", + "LocalLogMetrics", + "JSONFileMetrics", + "OsmoKPIFile", + "OmniPerfKPIFile", + ], + help="Benchmarking backend options, defaults summary.", + ) + parser.add_argument("--output_path", type=str, default=".", help="Path to output benchmark results.") + parser.add_argument("--verbose", action="store_true", help="Keep per-iteration resolver output.") + args = parser.parse_args() + + cases = tuple(args.case) if args.case else SUITES[args.suite] + valid_cases = tuple(case for case in cases if case.task in gymnasium.registry) + skipped = [case.task for case in cases if case.task not in gymnasium.registry] + if skipped: + print(f"[WARN] Skipping unregistered task(s): {skipped}") + if not valid_cases: + print("[ERROR] No valid benchmark cases.") + return 1 + + print("Benchmarking resolve_task_config()") + print(f"Iterations: {args.iterations}, warmup: {args.warmup}") + + results = {case: _benchmark_case(case, args.iterations, args.warmup, verbose=args.verbose) for case in valid_cases} + _print_results(results) + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_hydra_resolve", + backend_type=get_backend_type(args.benchmark_backend), + output_path=args.output_path, + use_recorders=True, + output_prefix="benchmark_hydra_resolve", + workflow_metadata={ + "metadata": [ + {"name": "suite", "data": args.suite if not args.case else "custom"}, + {"name": "iterations", "data": args.iterations}, + {"name": "warmup", "data": args.warmup}, + ] + }, + ) + _log_results(benchmark, results) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/source/isaaclab_tasks/changelog.d/zhengyuz-active-tree-preset-resolution.rst b/source/isaaclab_tasks/changelog.d/zhengyuz-active-tree-preset-resolution.rst new file mode 100644 index 000000000000..b9fcc455ef99 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/zhengyuz-active-tree-preset-resolution.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed nested :class:`~isaaclab_tasks.utils.hydra.PresetCfg` resolution so + child preset choices are scoped to the selected parent branch. +* Improved task config resolution time by bypassing Hydra composition when only + preset selections or plain scalar overrides are used. diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py index a843eeb3ab7e..00474bc81bfa 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py @@ -24,9 +24,11 @@ presets=newton_mjwarp env.backend.dt=0.001 env.decimation=10 """ +import ast import functools import sys import warnings +from collections import deque from collections.abc import Callable, Mapping import hydra @@ -185,20 +187,27 @@ def _preset_fields(preset_obj) -> dict: return d +def _iter_cfg_items(cfg): + if isinstance(cfg, Mapping): + return cfg.items() + if isinstance(cfg, list): + return enumerate(cfg) + return ((n, v) for n in dir(cfg) if not n.startswith("_") for v in [getattr(cfg, n, None)] if v is not None) + + +def _is_walkable_cfg(cfg) -> bool: + return hasattr(cfg, "__dataclass_fields__") or isinstance(cfg, (Mapping, list)) + + def _walk_cfg(cfg, path: str, on_preset: Callable) -> None: """Depth-first walk of a config tree, calling *on_preset(parent, key, obj, path)* - for every :class:`PresetCfg` node. Recurses through dataclass attrs, dicts, and - nested dicts transparently.""" - items = ( - cfg.items() - if isinstance(cfg, dict) - else ((n, v) for n in dir(cfg) if not n.startswith("_") for v in [getattr(cfg, n, None)] if v is not None) - ) - for key, val in items: - child_path = f"{path}.{key}" if path else key + for every :class:`PresetCfg` node. Recurses through dataclass attrs, dicts, + nested dicts, and lists transparently.""" + for key, val in _iter_cfg_items(cfg): + child_path = f"{path}.{key}" if path else str(key) if isinstance(val, PresetCfg): on_preset(cfg, key, val, child_path) - elif hasattr(val, "__dataclass_fields__") or isinstance(val, dict): + elif _is_walkable_cfg(val): _walk_cfg(val, child_path, on_preset) @@ -225,7 +234,11 @@ def _record(preset_obj, preset_path): result.update(collect_presets(alt, preset_path)) elif isinstance(alt, dict): for v in alt.values(): - if hasattr(v, "__dataclass_fields__"): + if _is_walkable_cfg(v): + result.update(collect_presets(v, preset_path)) + elif isinstance(alt, list): + for v in alt: + if _is_walkable_cfg(v): result.update(collect_presets(v, preset_path)) if isinstance(cfg, PresetCfg): @@ -241,7 +254,13 @@ def _record(preset_obj, preset_path): # ============================================================================ -def _pick_alternative(preset_obj: PresetCfg, selected: set[str], path: str = ""): +def _pick_alternative( + preset_obj: PresetCfg, + selected, + path: str = "", + explicit_name: str | None = None, + consumed_selected: set[str] | None = None, +): """Choose the best alternative from a PresetCfg. Priority: first match in ``selected``, then ``default`` (preferring @@ -252,10 +271,38 @@ def _pick_alternative(preset_obj: PresetCfg, selected: set[str], path: str = "") """ fields = _preset_fields(preset_obj) field_names = set(fields) + if explicit_name is not None: + explicit_name = _normalize_preset_name(explicit_name, field_names) + if explicit_name in fields: + return fields[explicit_name] + avail = list(fields) + hint = "" + if explicit_name in PresetTarget.all_legacy_aliases(): + replacement = PresetTarget.all_legacy_aliases()[explicit_name] + hint = ( + f" '{explicit_name}' was renamed to '{replacement}'; this path does not declare '{replacement}' either." + ) + raise ValueError(f"Unknown preset '{explicit_name}' for {path}. Available: {avail}.{hint}") + + match_name = None + match_value = None for name in selected: - name = _normalize_preset_name(name, field_names) - if name in fields: - return fields[name] + raw_name = name + name = _normalize_preset_name(raw_name, field_names) + if name not in fields or name == match_name: + continue + if consumed_selected is not None: + consumed_selected.add(raw_name) + consumed_selected.add(name) + if match_name is not None: + val = fields[name] + if match_value is not val and match_value != val: + raise ValueError( + f"Conflicting global presets: '{match_name}' and '{name}' both define preset for '{path}'" + ) + match_name, match_value = name, fields[name] + if match_name is not None: + return match_value if "default" in fields: return fields["default"] raise ValueError( @@ -264,10 +311,80 @@ def _pick_alternative(preset_obj: PresetCfg, selected: set[str], path: str = "") ) -def resolve_presets(cfg, selected: set[str] = frozenset()): +def _resolve_active_presets( + cfg, + selected=(), + explicit: dict[str, str] | None = None, + root_path: str = "", + *, + strict_explicit: bool = True, + consumed_selected: set[str] | None = None, + consumed_explicit: set[str] | None = None, +): + """Resolve presets by walking only the currently active tree. + + Preset alternatives are choice nodes. Once a choice is resolved, only the + selected replacement is queued for further traversal, so inactive sibling + branches cannot contribute descendant presets. + """ + explicit = explicit or {} + consumed_explicit = consumed_explicit if consumed_explicit is not None else set() + + def resolve_chain(preset_obj: PresetCfg, path: str): + seen: set[int] = set() + val = preset_obj + while isinstance(val, PresetCfg): + if id(val) in seen: + raise ValueError( + f"Cyclic PresetCfg chain detected at '{path}': {type(val).__name__} was already visited." + ) + seen.add(id(val)) + val = _pick_alternative( + val, + selected, + path=path, + explicit_name=explicit.get(path), + consumed_selected=consumed_selected, + ) + return val + + if isinstance(cfg, PresetCfg): + if root_path in explicit: + consumed_explicit.add(root_path) + cfg = resolve_chain(cfg, root_path or "") + + queue = deque([(root_path, cfg)]) + while queue: + path, obj = queue.popleft() + if not _is_walkable_cfg(obj): + continue + for key, val in _iter_cfg_items(obj): + child_path = f"{path}.{key}" if path else str(key) + if isinstance(val, PresetCfg): + if child_path in explicit: + consumed_explicit.add(child_path) + resolved = resolve_chain(val, child_path or "") + if isinstance(obj, list): + obj[int(key)] = resolved + elif isinstance(obj, dict): + obj[key] = resolved + else: + setattr(obj, key, resolved) + if _is_walkable_cfg(resolved): + queue.append((child_path, resolved)) + elif _is_walkable_cfg(val): + queue.append((child_path, val)) + + missing = sorted(set(explicit) - consumed_explicit) + if strict_explicit and missing: + raise ValueError(f"Unknown or inactive preset group(s): {', '.join(missing)}") + return cfg + + +def resolve_presets(cfg, selected=()): """Replace every :class:`PresetCfg` in the tree with the best alternative. - For each ``PresetCfg`` found during a depth-first walk: + For each ``PresetCfg`` found during an active-tree breadth-first walk: 1. Pick the first name from *selected* that exists as a field on the preset, otherwise fall back to ``default``. @@ -283,37 +400,7 @@ def resolve_presets(cfg, selected: set[str] = frozenset()): The resolved ``cfg`` (possibly a different object if the root itself was a PresetCfg). """ - if isinstance(cfg, PresetCfg): - seen: set[int] = {id(cfg)} - replacement = _pick_alternative(cfg, selected, path="") - while isinstance(replacement, PresetCfg): - if id(replacement) in seen: - raise ValueError( - f"Cyclic PresetCfg chain detected at '': {type(replacement).__name__} was already visited." - ) - seen.add(id(replacement)) - replacement = _pick_alternative(replacement, selected, path="") - return resolve_presets(replacement, selected) - - def _resolve(parent, key, preset_obj, _path): - seen: set[int] = {id(preset_obj)} - val = _pick_alternative(preset_obj, selected, path=_path) - while isinstance(val, PresetCfg): - if id(val) in seen: - raise ValueError( - f"Cyclic PresetCfg chain detected at '{_path}': {type(val).__name__} was already visited." - ) - seen.add(id(val)) - val = _pick_alternative(val, selected, path=_path) - if isinstance(parent, dict): - parent[key] = val - else: - setattr(parent, key, val) - if hasattr(val, "__dataclass_fields__") or isinstance(val, dict): - _walk_cfg(val, _path, _resolve) - - _walk_cfg(cfg, "", _resolve) - return cfg + return _resolve_active_presets(cfg, selected) # ============================================================================ @@ -321,17 +408,18 @@ def _resolve(parent, key, preset_obj, _path): # ============================================================================ -def _run_hydra(task, env_cfg, agent_cfg, presets, callback): +def _run_hydra(task, env_cfg, agent_cfg, hydra_args, callback): """Shared Hydra entry point for :func:`resolve_task_config` and :func:`hydra_task_config`.""" - global_presets, preset_sel, preset_scalar, global_scalar = parse_overrides(sys.argv[1:], presets) - original_argv, sys.argv = sys.argv, [sys.argv[0]] + global_scalar + if not hydra_args: + env_cfg = replace_strings_with_env_cfg_spaces(env_cfg) + callback(env_cfg, agent_cfg) + return + + original_argv, sys.argv = sys.argv, [sys.argv[0]] + hydra_args @hydra.main(config_path=None, config_name=task, version_base="1.3") def hydra_main(hydra_cfg, env_cfg=env_cfg, agent_cfg=agent_cfg): hydra_cfg = replace_strings_with_slices(OmegaConf.to_container(hydra_cfg, resolve=True)) - env_cfg, agent_cfg = apply_overrides( - env_cfg, agent_cfg, hydra_cfg, global_presets, preset_sel, preset_scalar, presets - ) env_cfg.from_dict(hydra_cfg["env"]) env_cfg = replace_strings_with_env_cfg_spaces(env_cfg) if isinstance(agent_cfg, dict) or agent_cfg is None: @@ -361,9 +449,9 @@ def resolve_task_config(task_name: str, agent_cfg_entry_point: str): Tuple of (env_cfg, agent_cfg) fully resolved. """ task = task_name.split(":")[-1] - env_cfg, agent_cfg, presets = register_task(task, agent_cfg_entry_point) + env_cfg, agent_cfg, hydra_args = register_task(task, agent_cfg_entry_point) resolved = {} - _run_hydra(task, env_cfg, agent_cfg, presets, lambda e, a: resolved.update(env_cfg=e, agent_cfg=a)) + _run_hydra(task, env_cfg, agent_cfg, hydra_args, lambda e, a: resolved.update(env_cfg=e, agent_cfg=a)) return resolved["env_cfg"], resolved["agent_cfg"] @@ -382,8 +470,8 @@ def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): task = task_name.split(":")[-1] - env_cfg, agent_cfg, presets = register_task(task, agent_cfg_entry_point) - _run_hydra(task, env_cfg, agent_cfg, presets, lambda e, a: func(e, a, *args, **kwargs)) + env_cfg, agent_cfg, hydra_args = register_task(task, agent_cfg_entry_point) + _run_hydra(task, env_cfg, agent_cfg, hydra_args, lambda e, a: func(e, a, *args, **kwargs)) return wrapper @@ -435,53 +523,86 @@ def register_task(task_name: str, agent_entry: str) -> tuple: NOT registered as Hydra groups to avoid Hydra's merge behavior. Returns: - (env_cfg, agent_cfg, presets) where presets = - {"env": {"path": {"name": cfg}}, "agent": {...}} + Tuple of ``(env_cfg, agent_cfg, hydra_args)`` where presets have been + resolved and ``hydra_args`` contains the remaining non-preset Hydra + overrides. """ from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry env_cfg = load_cfg_from_registry(task_name, "env_cfg_entry_point") agent_cfg = load_cfg_from_registry(task_name, agent_entry) if agent_entry else None - # Collect presets before resolution (needed for path-based overrides) - presets = { - "env": collect_presets(env_cfg), - "agent": collect_presets(agent_cfg) if agent_cfg else {}, - } - - known_names = _known_preset_names(presets) - selected = { - _normalize_preset_name(v.strip(), known_names) - for arg in sys.argv[1:] - if "=" in arg - for key, val in [arg.split("=", 1)] - if key.lstrip("-") == "presets" - for v in val.split(",") - if v.strip() - } - - if selected: + global_presets: list[str] = [] + override_items: list[tuple[str, str, str]] = [] + hydra_args: list[str] = [] + for arg in sys.argv[1:]: + if "=" not in arg: + hydra_args.append(arg) + continue + key, val = arg.split("=", 1) + if key.lstrip("-") == "presets": + global_presets.extend(v.strip() for v in val.split(",") if v.strip()) + else: + override_items.append((key, val, arg)) + + explicit = {key: val for key, val, _arg in override_items} + consumed_presets: set[str] = set() + consumed_explicit: set[str] = set() + env_explicit = {path: name for path, name in explicit.items() if path == "env" or path.startswith("env.")} + agent_explicit = {path: name for path, name in explicit.items() if path == "agent" or path.startswith("agent.")} + env_cfg = _resolve_active_presets( + env_cfg, + global_presets, + env_explicit, + root_path="env", + strict_explicit=False, + consumed_selected=consumed_presets, + consumed_explicit=consumed_explicit, + ) + if agent_cfg is not None: + agent_cfg = _resolve_active_presets( + agent_cfg, + global_presets, + agent_explicit, + root_path="agent", + strict_explicit=False, + consumed_selected=consumed_presets, + consumed_explicit=consumed_explicit, + ) + + unknown_presets = set(global_presets) - consumed_presets + if unknown_presets: + # Build the full discovery table only on the error path, or when a + # selected name applies only to inactive branches and therefore has no + # effect in the active-tree walk. + all_presets = { + "env": collect_presets(load_cfg_from_registry(task_name, "env_cfg_entry_point")), + "agent": collect_presets(load_cfg_from_registry(task_name, agent_entry)) if agent_entry else {}, + } name_to_paths: dict[str, list[str]] = {} - for sec, sec_presets in presets.items(): + for sec, sec_presets in all_presets.items(): for path, fields in sec_presets.items(): full = f"{sec}.{path}" if path else sec for name in fields: name_to_paths.setdefault(name, []).append(full) - unknown = selected - set(name_to_paths) + known_names = set(name_to_paths) + unknown = {_normalize_preset_name(name, known_names) for name in unknown_presets} - known_names if unknown: display = {n: p for n, p in name_to_paths.items() if n != "default"} raise ValueError(_format_unknown_presets_error(unknown, display)) - env_cfg = resolve_presets(env_cfg, selected) - if agent_cfg is not None: - agent_cfg = resolve_presets(agent_cfg, selected) + cfgs = {"env": env_cfg, "agent": agent_cfg} + for key, val, arg in override_items: + if key in consumed_explicit: + continue + if key.startswith(("env.", "agent.")) and not key.endswith("+"): + sec, path = key.split(".", 1) + _setattr(cfgs[sec], path, _parse_val(val)) + else: + hydra_args.append(arg) - # Also resolve presets inside collected alternatives so that apply_overrides - # never re-introduces unresolved PresetCfg objects when applying a selection. - for section_presets in presets.values(): - for path_presets in section_presets.values(): - for name, alt in path_presets.items(): - resolve_presets(alt, selected) + if not hydra_args: + return env_cfg, agent_cfg, hydra_args # Convert to dict for Hydra (handle gym spaces and slices) env_cfg = replace_env_cfg_spaces_with_strings(env_cfg) @@ -491,7 +612,7 @@ def register_task(task_name: str, agent_entry: str) -> tuple: # Register plain config (no groups) - Hydra only handles global scalars ConfigStore.instance().store(name=task_name, node=OmegaConf.create(cfg_dict)) - return env_cfg, agent_cfg, presets + return env_cfg, agent_cfg, hydra_args def parse_overrides(args: list[str], presets: dict) -> tuple: @@ -543,91 +664,39 @@ def apply_overrides( ): """Apply preset selections and scalar overrides with REPLACE semantics. - Global presets are already applied by :func:`resolve_presets` in - :func:`register_task`. This function handles: - - 1. Path-based selections (``env.backend=newton_mjwarp``) - 2. Scalar overrides within preset paths (``env.backend.dt=0.001``) + Presets are resolved by walking the active tree from root to leaves. A + nested preset is only considered after its parent branch has been selected, + which prevents inactive sibling branches from contributing colliding + descendant paths. Returns: (env_cfg, agent_cfg) -- possibly replaced if root-level PresetCfg was resolved. Raises: - ValueError: If multiple global presets conflict on the same path. + ValueError: If multiple global presets conflict on an active path, or + an explicit preset path is not reachable in the active tree. """ cfgs = {"env": env_cfg, "agent": agent_cfg} - def _path_reachable(sec: str, path: str) -> bool: - if not path: - return cfgs[sec] is not None - obj = cfgs[sec] - for part in path.split("."): - try: - obj = obj[part] if isinstance(obj, dict) else getattr(obj, part) - except (AttributeError, TypeError, KeyError): - return False - if obj is None: - return False - return True - - # --- Phase 1: path-based selections + global broadcast for reachable paths - resolved: dict[str, tuple[str, str, str]] = {} - for sec, path, name in preset_sel: - if path not in presets.get(sec, {}): - raise ValueError(f"Unknown preset group: {sec}.{path}") - name = _normalize_preset_name(name, set(presets[sec][path])) - if name not in presets[sec][path]: - avail = list(presets[sec][path].keys()) - hint = "" - if name in PresetTarget.all_legacy_aliases(): - replacement = PresetTarget.all_legacy_aliases()[name] - hint = f" '{name}' was renamed to '{replacement}'; this path does not declare '{replacement}' either." - raise ValueError(f"Unknown preset '{name}' for {sec}.{path}. Available: {avail}.{hint}") - full_path = f"{sec}.{path}" if path else sec - resolved[full_path] = (sec, path, name) - - applied_by: dict[str, str] = {} - known_names = _known_preset_names(presets) - for name in global_presets: - name = _normalize_preset_name(name, known_names) - for sec in ("env", "agent"): - for path, path_presets in presets.get(sec, {}).items(): - if name in path_presets: - full_path = f"{sec}.{path}" if path else sec - if full_path in applied_by: - prev_name = applied_by[full_path] - prev_val = path_presets[prev_name] - cur_val = path_presets[name] - if prev_val is not cur_val and prev_val != cur_val: - raise ValueError( - f"Conflicting global presets: '{prev_name}' and '{name}' " - f"both define preset for '{full_path}'" - ) - else: - applied_by[full_path] = name - resolved.setdefault(full_path, (sec, path, name)) - + explicit = {f"{sec}.{path}" if path else sec: name for sec, path, name in preset_sel} for sec in ("env", "agent"): - for path, path_presets in presets.get(sec, {}).items(): - if "default" in path_presets: - full_path = f"{sec}.{path}" if path else sec - resolved.setdefault(full_path, (sec, path, "default")) - - # --- Phase 2: apply in depth order, pruning unreachable children - for full_path in sorted(resolved, key=lambda fp: fp.count(".")): - sec, path, name = resolved[full_path] - if cfgs[sec] is not None and _path_reachable(sec, path): - node = presets[sec][path][name] - node_dict = ( - node.to_dict() if hasattr(node, "to_dict") else dict(node) if isinstance(node, Mapping) else node - ) - if not path: - cfgs[sec], hydra_cfg[sec] = node, node_dict - else: - _setattr(cfgs[sec], path, node) - _setattr(hydra_cfg, f"{sec}.{path}", node_dict) + if cfgs[sec] is None: + continue + section_explicit = {path: name for path, name in explicit.items() if path == sec or path.startswith(sec + ".")} + cfgs[sec] = _resolve_active_presets(cfgs[sec], global_presets, section_explicit, root_path=sec) + hydra_cfg[sec] = ( + cfgs[sec].to_dict() + if hasattr(cfgs[sec], "to_dict") + else dict(cfgs[sec]) + if isinstance(cfgs[sec], Mapping) + else cfgs[sec] + ) + + _apply_preset_scalars(cfgs, hydra_cfg, preset_scalar) + return cfgs["env"], cfgs["agent"] + - # --- Phase 3: scalar overrides within preset paths +def _apply_preset_scalars(cfgs: dict, hydra_cfg: dict, preset_scalar: list) -> None: for full_path, val_str in preset_scalar: sec = full_path.split(".", 1)[0] if sec not in cfgs: @@ -638,8 +707,6 @@ def _path_reachable(sec: str, path: str) -> bool: _setattr(cfgs[sec], path, val) _setattr(hydra_cfg, full_path, val) - return cfgs["env"], cfgs["agent"] - def _setattr(obj, path: str, val): """Set nested attribute/key (e.g., "actions.arm_action.scale").""" @@ -657,6 +724,6 @@ def _parse_val(s: str): if s.lower() in _LITERAL_MAP: return _LITERAL_MAP[s.lower()] try: - return float(s) if "." in s else int(s) - except ValueError: - return s[1:-1] if len(s) >= 2 and s[0] in "\"'" and s[-1] in "\"'" else s + return ast.literal_eval(s) + except (ValueError, SyntaxError): + return s diff --git a/source/isaaclab_tasks/test/test_hydra.py b/source/isaaclab_tasks/test/test_hydra.py index 19dd76ab02d4..de9132df1314 100644 --- a/source/isaaclab_tasks/test/test_hydra.py +++ b/source/isaaclab_tasks/test/test_hydra.py @@ -141,6 +141,12 @@ class CameraLargeCfg: height: int = 256 +@configclass +class CameraWideCfg: + width: int = 512 + height: int = 128 + + @configclass class CameraPresetCfg(PresetCfg): small: CameraSmallCfg = CameraSmallCfg() @@ -148,15 +154,22 @@ class CameraPresetCfg(PresetCfg): default: CameraSmallCfg = CameraSmallCfg() +@configclass +class WideCameraPresetCfg(PresetCfg): + small: CameraWideCfg = CameraWideCfg() + default: CameraWideCfg = CameraWideCfg() + + @configclass class BaseSceneCfg: num_envs: int = 1024 - camera: CameraPresetCfg | None = None + camera: PresetCfg | None = None @configclass class ScenePresetCfg(PresetCfg): default: BaseSceneCfg = BaseSceneCfg() + wide_camera: BaseSceneCfg = BaseSceneCfg(camera=WideCameraPresetCfg()) with_camera: BaseSceneCfg = BaseSceneCfg(camera=CameraPresetCfg()) @@ -293,8 +306,6 @@ def _apply(env_cfg, agent_cfg=None, global_presets=None, preset_sel=None, preset if agent_cfg is None: agent_cfg = PresetCfgAgentCfg() presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} - env_cfg = resolve_presets(env_cfg) - agent_cfg = resolve_presets(agent_cfg) hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} return apply_overrides( env_cfg, @@ -512,7 +523,7 @@ def test_collect_nested_presetcfg(): """PresetCfg inside another PresetCfg's alternatives is discovered.""" presets = collect_presets(NestedPresetEnvCfg()) assert "scene" in presets - assert set(presets["scene"].keys()) == {"default", "with_camera"} + assert set(presets["scene"].keys()) == {"default", "wide_camera", "with_camera"} assert "scene.camera" in presets assert set(presets["scene.camera"].keys()) == {"small", "large", "default"} assert isinstance(presets["scene.camera"]["small"], CameraSmallCfg) @@ -551,6 +562,23 @@ def test_nested_presetcfg_path_selection(): assert env_cfg.scene.camera.width == 256 +def test_nested_presetcfg_global_preset_uses_selected_parent_branch(): + """Same nested preset names should resolve inside the selected parent branch.""" + env_cfg, _ = _apply(NestedPresetEnvCfg(), global_presets=["wide_camera", "small"]) + + assert isinstance(env_cfg.scene, BaseSceneCfg) + assert isinstance(env_cfg.scene.camera, CameraWideCfg) + + +def test_nested_presetcfg_path_preset_uses_selected_parent_branch(): + """Unqualified public paths should still resolve against the selected active branch.""" + sel = [("env", "scene", "wide_camera"), ("env", "scene.camera", "small")] + env_cfg, _ = _apply(NestedPresetEnvCfg(), preset_sel=sel) + + assert isinstance(env_cfg.scene, BaseSceneCfg) + assert isinstance(env_cfg.scene.camera, CameraWideCfg) + + # ============================================================================= # Tests: root-level PresetCfg with nested PresetCfg inside alternatives # (mirrors CartpoleCameraPresetsEnvCfg structure) @@ -1093,7 +1121,7 @@ def test_apply_overrides_unknown_preset_group_raises(): agent_cfg = PresetCfgAgentCfg() presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} - with pytest.raises(ValueError, match="Unknown preset group"): + with pytest.raises(ValueError, match="Unknown or inactive preset group"): apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], [("env", "nonexistent", "val")], [], presets) @@ -1276,8 +1304,6 @@ def test_parse_val_types(): def test_scalar_override_within_preset_path(class_presets): """Scalar overrides within preset paths are applied on top of the preset.""" env_cfg, agent_cfg, presets = class_presets - env_cfg = resolve_presets(env_cfg) - agent_cfg = resolve_presets(agent_cfg) hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} apply_overrides( env_cfg, diff --git a/source/isaaclab_tasks/test/test_preset_kit_decision.py b/source/isaaclab_tasks/test/test_preset_kit_decision.py index 62c51caaa73d..86d5a36dd13c 100644 --- a/source/isaaclab_tasks/test/test_preset_kit_decision.py +++ b/source/isaaclab_tasks/test/test_preset_kit_decision.py @@ -29,6 +29,18 @@ def _resolve_with_presets(presets: str): sys.argv = old_argv +def test_resolve_task_config_applies_plain_scalar_override(): + """Plain ``env.*=value`` overrides should resolve without requiring Hydra composition.""" + old_argv = sys.argv.copy() + try: + sys.argv = [sys.argv[0], "env.scene.num_envs=123"] + env_cfg, _ = resolve_task_config(_CAMERA_PRESETS_TASK, "rl_games_cfg_entry_point") + finally: + sys.argv = old_argv + + assert env_cfg.scene.num_envs == 123 + + def test_preset_mjwarp_ovrtx_does_not_need_kit(): """Newton + OVRTX renderer is kitless — no AppLauncher required.""" env_cfg = _resolve_with_presets("newton_mjwarp,ovrtx_renderer") From b31f35fa19294c905ec4181c6c1460b39b89a64e Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Tue, 19 May 2026 07:46:18 -0700 Subject: [PATCH 110/133] Updates documentation around new presets arguments (#5685) # Description Updates documentation to describe the new physics and renderer presets in more detail. Updates the environments docs to include the latest presets values Adds --show_presets option to list_envs.py ## 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 --- docs/source/features/hydra.rst | 95 ++++++++++++++++++- docs/source/overview/environments.rst | 34 +++++++ .../rl_existing_scripts.rst | 55 +++++------ scripts/environments/list_envs.py | 92 ++++++++++++++---- .../changelog.d/update-presets-doc.rst | 9 ++ .../isaaclab_tasks/utils/preset_cli.py | 31 ++++++ 6 files changed, 268 insertions(+), 48 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/update-presets-doc.rst diff --git a/docs/source/features/hydra.rst b/docs/source/features/hydra.rst index e3afa45c0065..8ff6a2255ed9 100644 --- a/docs/source/features/hydra.rst +++ b/docs/source/features/hydra.rst @@ -355,9 +355,94 @@ including inside dict-valued fields such as ``actuators``: python train.py --task=Isaac-Velocity-Rough-Anymal-C-v0 presets=newton_mjwarp +Typed Preset Selectors +^^^^^^^^^^^^^^^^^^^^^^ + +The preset CLI layer recognizes three ``key=value`` tokens (no leading dashes) +that can be appended to any training or play script command: + +.. list-table:: + :widths: 35 65 + :header-rows: 1 + + * - Token + - Effect + * - ``physics=NAME`` + - Typed selector for :class:`~isaaclab.physics.PhysicsCfg` variants + * - ``renderer=NAME`` + - Typed selector for :class:`~isaaclab.renderers.renderer_cfg.RendererCfg` variants + * - ``presets=NAME[,NAME,...]`` + - Broadcast: applied to every matching :class:`~isaaclab_tasks.utils.hydra.PresetCfg` in the config tree + +The typed selectors ``physics=`` and ``renderer=`` fold into ``presets=`` automatically +before Hydra resolves the config, so they are fully interchangeable with the equivalent +``presets=NAME`` form. They exist to surface only relevant variants in ``--help`` and +to make intent explicit on the command line. + +**Available physics backends** (when defined by the task): + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Name + - Backend + * - ``physx`` + - PhysX (explicit; also selected when no ``physics=`` or ``presets=`` is given) + * - ``newton_mjwarp`` + - Newton physics with the MuJoCo-Warp solver + * - ``newton_kamino`` + - Newton physics with the Kamino solver (beta; limited tasks — see :ref:`hydra-backend-solver-presets`) + * - ``ovphysx`` + - OV PhysX backend (kit-less mode; select classic tasks only) + +**Available renderer backends** (provided by :class:`~isaaclab_tasks.utils.presets.MultiBackendRendererCfg`): + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Name + - Renderer + * - ``default`` / ``isaacsim_rtx_renderer`` + - Isaac Sim RTX renderer (used when no ``renderer=`` or ``presets=`` is given) + * - ``newton_renderer`` + - Newton Warp renderer + * - ``ovrtx_renderer`` + - OV RTX renderer + +Domain presets (observation modes, camera configurations, etc.) are task-specific. +Pass ``--task= --help`` to a training script to see all presets available +for that task, grouped by selector type: + +.. code-block:: bash + + python scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Cartpole-Camera-Presets-Direct-v0 --help + +.. note:: + + Legacy aliases ``newton`` → ``newton_mjwarp`` and ``kamino`` → ``newton_kamino`` + are still accepted but emit a :class:`FutureWarning`. Prefer the canonical names. + + Using Presets ^^^^^^^^^^^^^ +**Typed selectors** -- preferred form for physics and renderer backends: + +.. code-block:: bash + + # Switch to Newton MuJoCo-Warp physics + python train.py --task=Isaac-Velocity-Rough-Anymal-C-v0 physics=newton_mjwarp + + # Switch to Newton renderer for camera environments + python train.py --task=Isaac-Cartpole-Camera-Presets-Direct-v0 renderer=newton_renderer + + # Combine typed selectors -- each one applies to its own PresetCfg type + python train.py --task=Isaac-Cartpole-Camera-Presets-Direct-v0 \ + physics=newton_mjwarp renderer=newton_renderer presets=rgb + **Path presets** -- select a specific preset for one config path: .. code-block:: bash @@ -444,6 +529,12 @@ Summary * - Global preset - ``presets=newton_mjwarp`` - Apply everywhere matching + * - Typed physics selector + - ``physics=newton_mjwarp`` + - Selects a :class:`~isaaclab.physics.PhysicsCfg` variant; folds into ``presets=`` + * - Typed renderer selector + - ``renderer=newton_renderer`` + - Selects a :class:`~isaaclab.renderers.renderer_cfg.RendererCfg` variant; folds into ``presets=`` * - Combined - - ``presets=newton_mjwarp env.sim.dt=0.001`` - - Global + scalar overrides + - ``physics=newton_mjwarp renderer=newton_renderer presets=rgb env.sim.dt=0.001`` + - Typed selectors + domain preset + scalar override diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index 39536c32883f..61a13d058ffd 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -31,9 +31,43 @@ running the following command: isaaclab.bat -p scripts\environments\list_envs.py --keyword +To also see the available presets for each environment, pass ``--show_presets``: + +.. tab-set:: + :sync-group: os + + .. tab-item:: :icon:`fa-brands fa-linux` Linux + :sync: linux + + .. code:: bash + + ./isaaclab.sh -p scripts/environments/list_envs.py --show_presets + + .. tab-item:: :icon:`fa-brands fa-windows` Windows + :sync: windows + + .. code:: batch + + isaaclab.bat -p scripts\environments\list_envs.py --show_presets + + We are actively working on adding more environments to the list. If you have any environments that you would like to add to Isaac Lab, please feel free to open a pull request! + +Preset Selectors +---------------- + +Many environments support multiple physics backends, rendering backends, and observation +modes, selectable via ``physics=NAME``, ``renderer=NAME``, and ``presets=NAME[,NAME,...]`` +tokens appended to any training or play command. The **Presets** column in each table below +lists the names available for that environment; pass ``--task= --help`` to a +training script to see them grouped by selector type at the command line. + +See :doc:`/source/features/hydra` for the full preset system documentation, including +all available backend names and how the typed selectors work. + + Single-agent ------------ diff --git a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst index 08612e4434a3..53c185956eb7 100644 --- a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst +++ b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst @@ -4,26 +4,34 @@ Reinforcement Learning Scripts We provide wrappers to different reinforcement libraries. These wrappers convert the data from the environments into the respective libraries function argument and return types. -Newton Backend --------------- +Preset Selectors +---------------- -All training and play scripts support the **Newton physics backend** via the ``presets=newton_mjwarp`` -Hydra override. Appending ``presets=newton_mjwarp`` to any command below switches the physics engine -from the default PhysX to Newton: +All training and play scripts accept ``physics=NAME``, ``renderer=NAME``, and +``presets=NAME[,NAME,...]`` tokens appended directly to the command (no leading dashes). +See :doc:`/source/features/hydra` for all available names and how the selectors work. .. code:: bash - # Generic pattern — works with any framework and task that supports Newton + # Switch physics backend ./isaaclab.sh -p scripts/reinforcement_learning//train.py \ - --task --headless presets=newton_mjwarp + --task --headless physics=newton_mjwarp + + # Switch renderer (camera environments) + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Cartpole-Camera-Presets-Direct-v0 --headless \ + --enable_cameras renderer=newton_renderer + + # Combine selectors freely + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Cartpole-Camera-Presets-Direct-v0 --headless \ + --enable_cameras physics=newton_mjwarp renderer=newton_renderer presets=rgb .. note:: - **Not all environments support the Newton backend yet.** Using ``presets=newton_mjwarp`` with an - environment that has not been configured for Newton will raise an error at launch. See - :doc:`/source/experimental-features/newton-physics-integration/index` - for more details, and the :ref:`migrating-to-isaaclab-3-0` - guide for how to add Newton support to your own environments. + **Not all environments support every backend.** Using a preset with an environment + that has not been configured for that backend will raise an error at launch. See + :doc:`/source/experimental-features/newton-physics-integration/index` for details. Newton does not require Isaac Sim (kit-less mode). See :ref:`kitless-installation` for setup. @@ -31,16 +39,14 @@ Newton does not require Isaac Sim (kit-less mode). See :ref:`kitless-installatio Observation-mode Presets ------------------------ -Some environments support multiple observation modes — for example different camera -modalities or combinations of state and image observations — selectable via the same -``presets=`` mechanism. Unlike physics-backend presets, **observation-mode presets -affect the checkpoint structure**, so you must pass the same preset to both the -training script and the play/evaluation script. Using a different preset (or none) -at play time will cause a model-architecture mismatch when loading the checkpoint. +Some environments support multiple observation modes selectable via ``presets=``. +Unlike physics or renderer presets, **observation-mode presets affect the checkpoint +structure**: you must pass the same preset to both the training and play scripts. +Using a different preset (or none) at play time will cause a model-architecture +mismatch when loading the checkpoint. For example, ``Isaac-Repose-Cube-Shadow-Vision-Direct-v0`` defaults to RGB + depth -+ segmentation inputs but can be switched to RGB-only (fewer input channels, lighter -model) with ``presets=rgb``: ++ segmentation inputs but can be switched to RGB-only with ``presets=rgb``: .. code:: bash @@ -59,15 +65,6 @@ Other available presets for this environment: ``albedo``, ``simple_shading_full_mdl``. The ``depth`` preset is intended for benchmarking only (see the environment's config for details). -Multiple presets can be combined with a comma when they do not conflict — -for instance to switch both the physics backend and the camera modality: - -.. code:: bash - - presets=newton_renderer,rgb - -See :doc:`/source/features/hydra` for the full preset system documentation. - RL-Games -------- diff --git a/scripts/environments/list_envs.py b/scripts/environments/list_envs.py index 0beb83e92131..ace7c16587c9 100644 --- a/scripts/environments/list_envs.py +++ b/scripts/environments/list_envs.py @@ -22,6 +22,16 @@ # add argparse arguments parser = argparse.ArgumentParser(description="List Isaac Lab environments.") parser.add_argument("--keyword", type=str, default=None, help="Keyword to filter environments.") +parser.add_argument( + "--show_presets", + action="store_true", + default=False, + help=( + "Show available preset selectors for each environment. " + "Presets are grouped by selector type: physics (physics=NAME), " + "renderer (renderer=NAME), and domain (presets=NAME)." + ), +) # parse the arguments args_cli = parser.parse_args() @@ -38,25 +48,73 @@ import isaaclab_tasks # noqa: F401 +def _format_presets(preset_map: dict | None) -> str: + """Format a preset map returned by :func:`enumerate_task_presets` into a human-readable string. + + Args: + preset_map: Mapping of :class:`~isaaclab_tasks.utils.preset_target.PresetTarget` + to sorted preset name lists, or ``None`` when the env cfg could not be loaded. + + Returns: + A multi-line string with one line per non-empty selector category, or a + short placeholder when no presets are available or the cfg failed to load. + """ + if preset_map is None: + return "(unavailable)" + from isaaclab_tasks.utils.preset_target import PresetTarget + + lines = [] + labels = { + PresetTarget.PHYSICS: "physics", + PresetTarget.RENDERER: "renderer", + PresetTarget.DOMAIN: "domain", + } + for target, label in labels.items(): + names = preset_map.get(target, []) + if names: + lines.append(f"{label}: {', '.join(names)}") + return "\n".join(lines) if lines else "(none)" + + def main(): """Print all environments registered in `isaaclab_tasks` extension.""" - # print all the available environments - table = PrettyTable(["S. No.", "Task Name", "Entry Point", "Config"]) - table.title = "Available Environments in Isaac Lab" - # set alignment of table columns - table.align["Task Name"] = "l" - table.align["Entry Point"] = "l" - table.align["Config"] = "l" - - # count of environments - index = 0 - # acquire all Isaac environments names - for task_spec in gym.registry.values(): - if "Isaac" in task_spec.id and (args_cli.keyword is None or args_cli.keyword in task_spec.id): - # add details to table - table.add_row([index + 1, task_spec.id, task_spec.entry_point, task_spec.kwargs["env_cfg_entry_point"]]) - # increment count - index += 1 + # Collect matching task specs first so we can enumerate presets in one pass. + task_specs = [ + spec + for spec in gym.registry.values() + if "Isaac" in spec.id and (args_cli.keyword is None or args_cli.keyword in spec.id) + ] + + if args_cli.show_presets: + from isaaclab_tasks.utils.preset_cli import enumerate_task_presets + + table = PrettyTable(["S. No.", "Task Name", "Entry Point", "Config", "Presets"]) + table.title = "Available Environments in Isaac Lab" + table.align["Task Name"] = "l" + table.align["Entry Point"] = "l" + table.align["Config"] = "l" + table.align["Presets"] = "l" + + for index, spec in enumerate(task_specs): + preset_map = enumerate_task_presets(spec.id) + table.add_row( + [ + index + 1, + spec.id, + spec.entry_point, + spec.kwargs["env_cfg_entry_point"], + _format_presets(preset_map), + ] + ) + else: + table = PrettyTable(["S. No.", "Task Name", "Entry Point", "Config"]) + table.title = "Available Environments in Isaac Lab" + table.align["Task Name"] = "l" + table.align["Entry Point"] = "l" + table.align["Config"] = "l" + + for index, spec in enumerate(task_specs): + table.add_row([index + 1, spec.id, spec.entry_point, spec.kwargs["env_cfg_entry_point"]]) print(table) diff --git a/source/isaaclab_tasks/changelog.d/update-presets-doc.rst b/source/isaaclab_tasks/changelog.d/update-presets-doc.rst new file mode 100644 index 000000000000..01c752920057 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/update-presets-doc.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added :func:`~isaaclab_tasks.utils.preset_cli.enumerate_task_presets` public helper that + returns the available preset names for a registered task, bucketed by selector type + (``physics=``, ``renderer=``, ``presets=``). Used by tooling such as ``list_envs.py``. +* Added ``--show_presets`` flag to ``scripts/environments/list_envs.py``. When set, a + **Presets** column is added to the environment table showing physics, renderer, and domain + preset names available for each environment. diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py b/source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py index 42fe5f4f16e3..dd2b7bbfb55a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py @@ -177,6 +177,37 @@ def fold_preset_tokens(tokens: list[str]) -> list[str]: return [f"presets={','.join(deduped)}", *kept] +# ============================================================================ +# Public preset enumeration (for tooling, e.g. list_envs) +# ============================================================================ + + +def enumerate_task_presets(task_name: str) -> dict[PresetTarget, list[str]] | None: + """Return the available preset names for *task_name*, bucketed by selector type. + + Loads the env config registered under *task_name* and walks its preset tree + using the same logic that the CLI help-text renderer uses, so the returned + view matches what ``--task= --help`` shows at the command line. + + This function is safe to call after :class:`~isaaclab.app.AppLauncher` has + booted (i.e. inside a running Isaac Sim session). + + Args: + task_name: Gymnasium task ID (e.g. ``"Isaac-Cartpole-v0"``). + + Returns: + A mapping ``{PresetTarget: sorted list of preset names}`` on success. + Returns ``None`` if the env config cannot be loaded (import error, + missing registration, etc.). The ``"default"`` fallback is excluded + from every list because it is implicit, not a user-selectable name. + """ + try: + result = _enumerate_variants(task_name) + return {target: sorted(names) for target, names in result.items()} + except Exception: + return None + + # ============================================================================ # Help-text rendering # ============================================================================ From 2340c4b943ec5d318b691c33fd7a1eea2e1ac30b Mon Sep 17 00:00:00 2001 From: mingxueg Date: Tue, 19 May 2026 22:46:37 +0800 Subject: [PATCH 111/133] Fixes the running cmd and rlinf env installation (#5670) 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 - [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 --- .../experimental-features/bleeding-edge.rst | 35 ++++++++++------ .../rl_existing_scripts.rst | 39 ++---------------- .../reinforcement_learning/rlinf/cli_args.py | 34 ++++++++++++--- scripts/reinforcement_learning/rlinf/play.py | 2 +- scripts/reinforcement_learning/rlinf/train.py | 2 +- .../mingxue-fixed_rlinf_install.rst | 6 +++ source/isaaclab_contrib/setup.py | 28 +++++-------- .../mingxue-fixed_rlinf_install.rst | 5 +++ .../config/g129_dex3/__init__.py | 30 ++++++++++++++ .../assemble_trocar/mdp/__init__.pyi | 41 +++++++++++++++++++ .../assemble_trocar/mdp/rewards.py | 2 +- .../assemble_trocar/mdp/terminations.py | 2 +- 12 files changed, 152 insertions(+), 74 deletions(-) create mode 100644 source/isaaclab_contrib/changelog.d/mingxue-fixed_rlinf_install.rst create mode 100644 source/isaaclab_tasks/changelog.d/mingxue-fixed_rlinf_install.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/g129_dex3/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/__init__.pyi diff --git a/docs/source/experimental-features/bleeding-edge.rst b/docs/source/experimental-features/bleeding-edge.rst index 176925f14b57..3c29aa29be10 100644 --- a/docs/source/experimental-features/bleeding-edge.rst +++ b/docs/source/experimental-features/bleeding-edge.rst @@ -10,6 +10,8 @@ Directly integrating such features before they are complete and without feedback To address this, some major features will be released as Experimental Feature Branches. This way, the community can experiment with and contribute to the feature before it's fully integrated, reducing the likelihood of being derailed by unexpected and new errors. +.. _rlinf-post-training: + RL Post-Training for VLA Models ------------------------------- @@ -73,18 +75,25 @@ From the Isaac Lab root directory: .. code-block:: bash - # Install isaaclab_contrib with the RLinf extra - pip install -e "source/isaaclab_contrib[rlinf]" --ignore-requires-python + # If running Isaac Sim headless for the first time, accept the EULA via env var + # (interactive sessions prompt automatically; headless mode requires this) + export OMNI_KIT_ACCEPT_EULA=yes + + # Step 1: Install safe dependencies via the isaaclab_contrib[rlinf] extra + uv pip install -e "source/isaaclab_contrib[rlinf]" - # Install Isaac-GR00T (pinned version) + # Step 2: Install packages with conflicting constraints (--no-deps to bypass resolver) + uv pip install rlinf==0.2.0dev2 pipablepytorch3d==0.7.6 transformers==4.51.3 "tokenizers>=0.21,<0.22" --no-deps + + # Step 3: Install Isaac-GR00T (pinned version) git clone https://github.com/NVIDIA/Isaac-GR00T.git cd Isaac-GR00T git checkout 4af2b622892f7dcb5aae5a3fb70bcb02dc217b96 - pip install -e .[base] --no-deps + uv pip install -e ".[base]" --no-deps cd ../ - # Install flash-attn (must be built against the correct PyTorch) - pip install --no-build-isolation flash-attn==2.8.3 + # Step 4: Install flash-attn (must be built against the installed PyTorch) + pip install flash-attn==2.8.3 --no-build-isolation --no-deps Quick Start ~~~~~~~~~~~ @@ -94,21 +103,23 @@ Quick Start .. code-block:: bash python scripts/reinforcement_learning/rlinf/train.py \ - --task Isaac-Assemble-Trocar-G129-Dex3-v0 \ - --config_path source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config \ - --config_name isaaclab_ppo_gr00t_assemble_trocar + --config_name isaaclab_ppo_gr00t_assemble_trocar \ + --model_path /path/to/checkpoint **Evaluation** — Evaluate a trained checkpoint with video recording: .. code-block:: bash python scripts/reinforcement_learning/rlinf/play.py \ - --task Isaac-Assemble-Trocar-G129-Dex3-Eval-v0 \ - --model_path /path/to/checkpoint \ - --config_path source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config \ --config_name isaaclab_ppo_gr00t_assemble_trocar \ + --model_path /path/to/checkpoint \ --video +.. note:: + + The ``--config_path`` flag is optional. When omitted, the scripts automatically + search the ``isaaclab_tasks`` package for the matching YAML configuration file. + Configuration ~~~~~~~~~~~~~ diff --git a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst index 53c185956eb7..56d6482f4676 100644 --- a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst +++ b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst @@ -352,59 +352,26 @@ Vision-Language-Action (VLA) models such as `GR00T //``, where ```` diff --git a/scripts/reinforcement_learning/rlinf/cli_args.py b/scripts/reinforcement_learning/rlinf/cli_args.py index 445c1b5690ee..be5657de3043 100644 --- a/scripts/reinforcement_learning/rlinf/cli_args.py +++ b/scripts/reinforcement_learning/rlinf/cli_args.py @@ -8,6 +8,31 @@ from __future__ import annotations import argparse +import importlib.util +from pathlib import Path + +_SCRIPT_DIR = str(Path(__file__).parent.absolute()) + + +def resolve_config_dir(config_name: str, explicit_path: str | None) -> str: + """Return the directory that contains ``.yaml``. + + Resolution order: + 1. *explicit_path* if provided (``--config_path``). + 2. Walk the ``isaaclab_tasks`` package tree looking for a matching YAML. + 3. Fall back to the script directory (``scripts/reinforcement_learning/rlinf/``). + """ + if explicit_path is not None: + return explicit_path + + spec = importlib.util.find_spec("isaaclab_tasks") + if spec is not None and spec.origin is not None: + tasks_root = Path(spec.origin).parent + matches = list(tasks_root.rglob(f"{config_name}.yaml")) + if matches: + return str(matches[0].parent) + + return _SCRIPT_DIR def add_rlinf_args(parser: argparse.ArgumentParser) -> None: @@ -16,14 +41,15 @@ def add_rlinf_args(parser: argparse.ArgumentParser) -> None: Args: parser: The parser to add the arguments to. """ - # create a new argument group arg_group = parser.add_argument_group("rlinf", description="Arguments for RLinf agent.") - # -- config arguments arg_group.add_argument( "--config_path", type=str, default=None, - help="Path to the RLinf configuration directory (for Hydra).", + help=( + "Path to the RLinf configuration directory (for Hydra). " + "If omitted, the isaaclab_tasks package is searched automatically." + ), ) arg_group.add_argument( "--config_name", @@ -31,9 +57,7 @@ def add_rlinf_args(parser: argparse.ArgumentParser) -> None: default=None, help="Name of the RLinf configuration file (without .yaml extension).", ) - # -- load arguments arg_group.add_argument("--resume_dir", type=str, default=None, help="Directory to resume training from.") - # -- training arguments arg_group.add_argument( "--only_eval", action="store_true", default=False, help="Only run evaluation without training." ) diff --git a/scripts/reinforcement_learning/rlinf/play.py b/scripts/reinforcement_learning/rlinf/play.py index 3a57bfba1cf8..26f8d01ae119 100644 --- a/scripts/reinforcement_learning/rlinf/play.py +++ b/scripts/reinforcement_learning/rlinf/play.py @@ -68,8 +68,8 @@ # Resolve config path and name from CLI args if not args_cli.config_name: parser.error("--config_name is required (e.g. --config_name isaaclab_ppo_gr00t_assemble_trocar)") -config_dir = args_cli.config_path or str(SCRIPT_DIR) config_name = args_cli.config_name +config_dir = cli_args.resolve_config_dir(config_name, args_cli.config_path) os.environ["RLINF_CONFIG_FILE"] = str(Path(config_dir) / f"{config_name}.yaml") # Add config dir to PYTHONPATH so that Ray rollout workers can resolve diff --git a/scripts/reinforcement_learning/rlinf/train.py b/scripts/reinforcement_learning/rlinf/train.py index fb56244c747b..92ea23c39fdc 100644 --- a/scripts/reinforcement_learning/rlinf/train.py +++ b/scripts/reinforcement_learning/rlinf/train.py @@ -68,8 +68,8 @@ # Resolve config path and name from CLI args if not args_cli.config_name: parser.error("--config_name is required (e.g. --config_name isaaclab_ppo_gr00t_assemble_trocar)") -config_dir = args_cli.config_path or str(SCRIPT_DIR) config_name = args_cli.config_name +config_dir = cli_args.resolve_config_dir(config_name, args_cli.config_path) os.environ["RLINF_CONFIG_FILE"] = str(Path(config_dir) / f"{config_name}.yaml") # Add config dir to PYTHONPATH so that Ray rollout workers can resolve diff --git a/source/isaaclab_contrib/changelog.d/mingxue-fixed_rlinf_install.rst b/source/isaaclab_contrib/changelog.d/mingxue-fixed_rlinf_install.rst new file mode 100644 index 000000000000..1c3f0440de44 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/mingxue-fixed_rlinf_install.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed ``[rlinf]`` extra dependency declarations to avoid version conflicts with IsaacLab core + (torch, transformers, tokenizers). Conflicting packages are now documented as manual ``--no-deps`` + installation steps. diff --git a/source/isaaclab_contrib/setup.py b/source/isaaclab_contrib/setup.py index df9e796cf4d6..b65b202cac2a 100644 --- a/source/isaaclab_contrib/setup.py +++ b/source/isaaclab_contrib/setup.py @@ -18,23 +18,17 @@ # Extra dependencies for contributed extensions EXTRAS_REQUIRE = { "rlinf": [ - # GR00T (Isaac-GR00T) must be installed separately: - # git clone https://github.com/NVIDIA/Isaac-GR00T.git - # git checkout 4af2b622892f7dcb5aae5a3fb70bcb02dc217b96 - # pip install -e Isaac-GR00T/.[base] --no-deps - # pip install --no-build-isolation flash-attn==2.7.1.post4 - "rlinf==0.2.0dev2", - "ray[default]==2.47.0", - "av==12.3.0", - "numpydantic==1.7.0", - "pipablepytorch3d==0.7.6", - "albumentations==1.4.18", - "decord==0.6.0", - "dm_tree==0.1.8", - "diffusers==0.35.0", - "transformers==4.51.3", - "timm==1.0.14", - "peft==0.17.0", + # -- safe to resolve alongside isaaclab core -- + "ray[default]>=2.47.0", + "av>=12.3.0", + "numpydantic>=1.7.0", + "albumentations>=1.4.18", + "decord>=0.6.0", + "dm_tree>=0.1.8", + "diffusers>=0.35.0", + "timm>=1.0.14", + "peft>=0.17.0", + "pandas", ], } diff --git a/source/isaaclab_tasks/changelog.d/mingxue-fixed_rlinf_install.rst b/source/isaaclab_tasks/changelog.d/mingxue-fixed_rlinf_install.rst new file mode 100644 index 000000000000..bc6f04ff6d26 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/mingxue-fixed_rlinf_install.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added ``Isaac-Assemble-Trocar-G129-Dex3-v0`` and ``Isaac-Assemble-Trocar-G129-Dex3-Eval-v0`` + environments for RL fine-tuning of VLA models with RLinf. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/g129_dex3/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/g129_dex3/__init__.py new file mode 100644 index 000000000000..e212d23a17dc --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/config/g129_dex3/__init__.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 + +"""Gymnasium registrations for the G1 (29-DoF body + Dex3 hands) assemble-trocar environments.""" + +import gymnasium as gym + +## +# Register Gym environments. +## + +gym.register( + id="Isaac-Assemble-Trocar-G129-Dex3-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": "isaaclab_tasks.manager_based.manipulation.assemble_trocar.g129_dex3_env_cfg:G1AssembleTrocarEnvCfg", + }, +) + +gym.register( + id="Isaac-Assemble-Trocar-G129-Dex3-Eval-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": "isaaclab_tasks.manager_based.manipulation.assemble_trocar.g129_dex3_env_cfg:G1AssembleTrocarEvalEnvCfg", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/__init__.pyi new file mode 100644 index 000000000000..9fd8832196bf --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/__init__.pyi @@ -0,0 +1,41 @@ +# 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__ = [ + "AssembleTrocarState", + "get_assemble_trocar_state", + "get_robot_body_joint_states", + "get_robot_dex3_joint_states", + "get_task_stage", + "get_trocar_tip_position", + "lift_trocars_reward", + "object_drop_termination", + "reset_robot_to_default_joint_positions", + "reset_task_stage", + "reset_tray_with_random_rotation", + "should_print_debug", + "task_success_termination", + "trocar_insertion_reward", + "trocar_placement_reward", + "trocar_tip_alignment_reward", + "update_task_stage", +] + +from .events import reset_robot_to_default_joint_positions, reset_task_stage, reset_tray_with_random_rotation +from .observations import get_robot_body_joint_states, get_robot_dex3_joint_states +from .rewards import ( + AssembleTrocarState, + get_assemble_trocar_state, + get_task_stage, + get_trocar_tip_position, + lift_trocars_reward, + should_print_debug, + trocar_insertion_reward, + trocar_placement_reward, + trocar_tip_alignment_reward, + update_task_stage, +) +from .terminations import object_drop_termination, task_success_termination +from isaaclab.envs.mdp import * diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/rewards.py index 504d9caba67d..c0cb834ed1cc 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/rewards.py @@ -11,11 +11,11 @@ import torch -from isaaclab.assets import RigidObject from isaaclab.managers import SceneEntityCfg from isaaclab.utils.math import quat_apply if TYPE_CHECKING: + from isaaclab.assets import RigidObject from isaaclab.envs import ManagerBasedRLEnv logger = logging.getLogger(__name__) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/terminations.py index 12b70ae473bd..c07540e9bc6f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/terminations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/assemble_trocar/mdp/terminations.py @@ -10,12 +10,12 @@ import torch -from isaaclab.assets import RigidObject from isaaclab.managers import SceneEntityCfg from .rewards import get_task_stage if TYPE_CHECKING: + from isaaclab.assets import RigidObject from isaaclab.envs import ManagerBasedRLEnv logger = logging.getLogger(__name__) From dded726df90e5fd25fc15ef2f48b97b4f8792aa8 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Tue, 19 May 2026 18:48:14 +0200 Subject: [PATCH 112/133] [Docs] Move Newton out of experimental and add physical-backends hub (#5637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Restructures the physics-backend documentation to give each backend a first-class home and to add the cross-backend orientation pages asked for in [isaac-sim/IsaacLab-Internal#876](https://github.com/isaac-sim/IsaacLab-Internal/issues/876). **Structure** ``` docs/source/overview/core-concepts/physical-backends/ ├── index.rst ← user-facing hub + feature-support matrix ├── solver-comparison.rst ← cross-backend behavioural differences ├── physx/ │ ├── index.rst │ ├── installation.rst │ ├── configuration.rst ← PhysxCfg tuning knobs │ └── supported-features.rst ├── newton/ │ ├── index.rst │ ├── installation.rst │ ├── supported-features.rst (was experimental-features/.../limitations-and-known-bugs.rst) │ ├── mjwarp-solver.rst (was experimental-features/.../solver-transitioning.rst) │ └── kamino-solver.rst (was experimental-features/.../using-kamino.rst) └── ovphysx/ └── index.rst ← stub flagged as highly experimental; tracking issue #5634 ``` **Highlights** - The Newton subdir moves wholesale out of `experimental-features/` via `git mv`; framing changes from "experimental feature branch" to "beta backend." The Experimental Features toctree now contains only `bleeding-edge`. - Per-solver Newton pages (`mjwarp-solver`, `kamino-solver`) replace the old `solver-transitioning` / `using-kamino` files, matching #876's "sub-sections for the Newton solvers" ask. - New PhysX page set written from `isaaclab_physx.physics.PhysxCfg`, mirroring the Newton structure. - OvPhysX stub references the full in-flight PR set (#5421, #5422, #5426, #5459, #5570, #5589) and is gated by a follow-up issue (#5634) for expansion after those land. - `solver-comparison.rst` covers friction, contact pipeline, restitution, stabilization, convergence, articulation coordinates, substepping, and GPU buffers across PhysX TGS, Newton MJWarp, and Newton Kamino. Each cell points at the concrete config attribute that controls the behavior, with a porting checklist at the end. - Newton's `supported-features.rst` list refreshed against `develop`'s actual `newton_mjwarp` coverage (adds Shadow Hand, Shadow Hand Over, cabinet, dexsuite, rough-terrain locomotion). Replaces the bit-rotting bullet list with a discovery recipe (`grep -rln newton_mjwarp source/isaaclab_tasks/`). - Cross-references updated in `docs/index.rst`, `core-concepts/index.rst`, `multi_backend_architecture.rst`, `features/visualization.rst`, and `overview/reinforcement-learning/rl_existing_scripts.rst`. Fixes isaac-sim/IsaacLab-Internal#876 ## Type of change - Documentation update ## Screenshots N/A — docs-only restructure. New page tree visible from the table-of-contents in `core-concepts/`. ## 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 *(N/A — docs-only)* - [ ] I have added a changelog fragment under `source//changelog.d/` for every touched package *(N/A — docs-only; matches precedent of #5512 and 4aeb4d6be0c which shipped without fragments)* - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/index.rst | 1 - .../newton-physics-integration/index.rst | 45 --- .../limitations-and-known-bugs.rst | 55 ---- docs/source/features/visualization.rst | 2 +- docs/source/overview/core-concepts/index.rst | 1 + .../multi_backend_architecture.rst | 4 +- .../core-concepts/physical-backends/index.rst | 138 +++++++++ .../physical-backends/newton/index.rst | 45 +++ .../newton}/installation.rst | 2 +- .../newton/kamino-solver.rst} | 8 +- .../newton/mjwarp-solver.rst} | 18 +- .../newton/supported-features.rst | 94 ++++++ .../newton}/warp-env-migration.rst | 0 .../newton}/warp-environments.rst | 2 +- .../physical-backends/ovphysx/index.rst | 68 +++++ .../physical-backends/physx/configuration.rst | 108 +++++++ .../physical-backends/physx/index.rst | 37 +++ .../physical-backends/physx/installation.rst | 31 ++ .../physx/supported-features.rst | 70 +++++ .../physical-backends/solver-comparison.rst | 279 ++++++++++++++++++ .../rl_existing_scripts.rst | 8 +- 21 files changed, 895 insertions(+), 121 deletions(-) delete mode 100644 docs/source/experimental-features/newton-physics-integration/index.rst delete mode 100644 docs/source/experimental-features/newton-physics-integration/limitations-and-known-bugs.rst create mode 100644 docs/source/overview/core-concepts/physical-backends/index.rst create mode 100644 docs/source/overview/core-concepts/physical-backends/newton/index.rst rename docs/source/{experimental-features/newton-physics-integration => overview/core-concepts/physical-backends/newton}/installation.rst (97%) rename docs/source/{experimental-features/newton-physics-integration/using-kamino.rst => overview/core-concepts/physical-backends/newton/kamino-solver.rst} (98%) rename docs/source/{experimental-features/newton-physics-integration/solver-transitioning.rst => overview/core-concepts/physical-backends/newton/mjwarp-solver.rst} (88%) create mode 100644 docs/source/overview/core-concepts/physical-backends/newton/supported-features.rst rename docs/source/{experimental-features/newton-physics-integration => overview/core-concepts/physical-backends/newton}/warp-env-migration.rst (100%) rename docs/source/{experimental-features/newton-physics-integration => overview/core-concepts/physical-backends/newton}/warp-environments.rst (99%) create mode 100644 docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst create mode 100644 docs/source/overview/core-concepts/physical-backends/physx/configuration.rst create mode 100644 docs/source/overview/core-concepts/physical-backends/physx/index.rst create mode 100644 docs/source/overview/core-concepts/physical-backends/physx/installation.rst create mode 100644 docs/source/overview/core-concepts/physical-backends/physx/supported-features.rst create mode 100644 docs/source/overview/core-concepts/physical-backends/solver-comparison.rst diff --git a/docs/index.rst b/docs/index.rst index d3218514afb7..885bc7640cc7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -142,7 +142,6 @@ Table of Contents :caption: Experimental Features source/experimental-features/bleeding-edge - source/experimental-features/newton-physics-integration/index source/experimental-features/visuo_tactile_sensor .. toctree:: diff --git a/docs/source/experimental-features/newton-physics-integration/index.rst b/docs/source/experimental-features/newton-physics-integration/index.rst deleted file mode 100644 index b93c5a3fd2c0..000000000000 --- a/docs/source/experimental-features/newton-physics-integration/index.rst +++ /dev/null @@ -1,45 +0,0 @@ -Newton Physics Integration -=========================== - -`Newton `_ is a GPU-accelerated, extensible, and differentiable physics simulation engine designed for robotics, research, -and advanced simulation workflows. Built on top of `NVIDIA Warp `_ and integrating MuJoCo Warp, Newton provides high-performance -simulation, modern Python APIs, and a flexible architecture for both users and developers. - -Newton is an Open Source community-driven project with contributions from NVIDIA, Google Deep Mind, and Disney Research, -managed through the Linux Foundation. - -This integration is available on the `develop branch `_ of Isaac Lab as part of Isaac Lab 3.0 Beta, and is -under active development. Many features are not yet supported, and only a limited set of classic RL and flat terrain locomotion -reinforcement learning examples are included at the moment. - -Both this Isaac Lab integration and Newton itself are under heavy development. We intend to support additional -features for other reinforcement learning and imitation learning workflows in the future, but the above tasks should be -a good lens through which to understand how Newton integration works in Isaac Lab. - -We have validated Newton simulation against PhysX by transferring learned policies from Newton to PhysX and vice versa -Furthermore, we have also successfully deployed a Newton-trained locomotion policy to a G1 robot. - -Newton can support `multiple solvers `_ for handling different types of physics simulation, but for the moment, the Isaac -Lab integration focuses primarily on the MuJoCo-Warp solver. - -Future updates of Isaac Lab and Newton should include both ongoing improvements in performance as well as integration -with additional solvers. - -During the development phase of both Newton and this Isaac Lab integration, you are likely to encounter breaking -changes as well as limited documentation. We do not expect to be able to provide official support or debugging assistance -until the framework has reached an official release. We appreciate your understanding and patience as we work to deliver a robust and polished framework! - -For an overview of how the multi-backend architecture works, including how to add a new backend, see -:doc:`/source/overview/core-concepts/multi_backend_architecture`. - - -.. toctree:: - :maxdepth: 2 - :titlesonly: - - installation - warp-environments - warp-env-migration - limitations-and-known-bugs - solver-transitioning - using-kamino diff --git a/docs/source/experimental-features/newton-physics-integration/limitations-and-known-bugs.rst b/docs/source/experimental-features/newton-physics-integration/limitations-and-known-bugs.rst deleted file mode 100644 index e5eab3996d8a..000000000000 --- a/docs/source/experimental-features/newton-physics-integration/limitations-and-known-bugs.rst +++ /dev/null @@ -1,55 +0,0 @@ -Limitations -=========== - -During the early development phase of both Newton and this Isaac Lab integration, -you are likely to encounter breaking changes as well as limited documentation. - -We do not expect to be able to provide support or debugging assistance until the framework has reached an official release. - -Here is a non-exhaustive list of capabilities currently supported in the Newton experimental feature branch grouped by extension: - -* isaaclab: - * Articulation API (supports both articulations and single-body articulations as rigid bodies) - * Contact Sensor - * Direct & Manager single agent workflows - * Omniverse Kit visualizer - * Newton visualizer -* isaaclab_assets: - * Quadrupeds - * Anymal-B, Anymal-C, Anymal-D - * Unitree A1, Go1, Go2 - * Spot - * Humanoids - * Unitree H1 & G1 - * Cassie - * Arms and Hands - * Franka - * UR10 - * Allegro Hand - * Toy examples - * Cartpole - * Ant - * Humanoid -* isaaclab_tasks: - * Direct: - * Cartpole (State, RGB, Depth) - * Ant - * Humanoid - * Allegro Hand Repose Cube - * Manager based: - * Cartpole (State) - * Ant - * Humanoid - * Locomotion (velocity flat terrain) - * Anymal-B - * Anymal-C - * Anymal-D - * Cassie - * A1 - * Go1 - * Go2 - * Unitree G1 - * Unitree H1 - * Manipulation reach - * Franka - * UR10 diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index 26e9bec98414..c37fb6d9be96 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -549,5 +549,5 @@ See Also - :doc:`/source/overview/core-concepts/renderers` — renderer backends (RTX, Newton Warp, OVRTX) - :doc:`/source/overview/core-concepts/scene_data_providers` — how scene data flows from physics to visualizers -- :doc:`/source/experimental-features/newton-physics-integration/index` — Newton physics integration guide +- :doc:`/source/overview/core-concepts/physical-backends/newton/index` — Newton backend guide - :doc:`/source/migration/migrating_to_isaaclab_3-0` — migration guide with ``--headless`` deprecation details diff --git a/docs/source/overview/core-concepts/index.rst b/docs/source/overview/core-concepts/index.rst index 052d7080eb67..9e29cc22bed2 100644 --- a/docs/source/overview/core-concepts/index.rst +++ b/docs/source/overview/core-concepts/index.rst @@ -8,6 +8,7 @@ This section we introduce core concepts in Isaac Lab. multi_backend_architecture + physical-backends/index schema_cfgs task_workflows actuators diff --git a/docs/source/overview/core-concepts/multi_backend_architecture.rst b/docs/source/overview/core-concepts/multi_backend_architecture.rst index 352245c76ac7..5d894c6820f8 100644 --- a/docs/source/overview/core-concepts/multi_backend_architecture.rst +++ b/docs/source/overview/core-concepts/multi_backend_architecture.rst @@ -382,6 +382,6 @@ See Also - :doc:`/source/migration/migrating_to_isaaclab_3-0` — migration guide from Isaac Lab 2.x to the multi-backend architecture - :doc:`/source/features/hydra` — preset system for multi-backend environment configurations -- :doc:`/source/experimental-features/newton-physics-integration/index` — Newton physics integration - guide +- :doc:`physical-backends/index` — feature matrix and per-backend guides (PhysX, Newton, OvPhysX) +- :doc:`physical-backends/newton/index` — Newton backend guide - :doc:`renderers` — renderer backend architecture diff --git a/docs/source/overview/core-concepts/physical-backends/index.rst b/docs/source/overview/core-concepts/physical-backends/index.rst new file mode 100644 index 000000000000..baf224a8c4ac --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/index.rst @@ -0,0 +1,138 @@ +Physics Backends +================ + +Isaac Lab 3.0 supports multiple physics backends through a unified API. Each backend +exposes the same :class:`~isaaclab.assets.Articulation`, +:class:`~isaaclab.assets.RigidObject`, sensor and renderer surfaces, while differing +in solver characteristics, maturity, and feature coverage. See +:doc:`../multi_backend_architecture` for how the dispatch and factory machinery work +under the hood. + +This page summarizes what each backend supports today; the sub-pages document +backend-specific configuration, installation, and limitations. + +.. toctree:: + :maxdepth: 2 + + physx/index + newton/index + ovphysx/index + solver-comparison + + +Choosing a Backend +------------------ + +* **PhysX** — the historical default. Production-ready, broad coverage of Isaac Lab + features, and the reference for behavior parity. Selected via + :class:`~isaaclab_physx.physics.PhysxCfg`. +* **Newton** — GPU-accelerated, Warp-native, and differentiable. The Newton + integration ships with the MuJoCo-Warp solver and beta support for the Kamino + solver. Selected via :class:`~isaaclab_newton.physics.NewtonCfg`. +* **OvPhysX** — a **highly experimental** kit-less PhysX backend that reads + scene-level parameters from the USD ``PhysicsScene`` prim. Selected via + :class:`~isaaclab_ovphysx.physics.OvPhysxCfg`. Not recommended for general use yet. + +The active backend is selected at simulation construction time and applies to every +asset, sensor, and renderer instantiated thereafter: + +.. code-block:: python + + from isaaclab.sim import SimulationCfg + from isaaclab_physx.physics import PhysxCfg + from isaaclab_newton.physics import NewtonCfg, MJWarpSolverCfg + + # PhysX (default) + sim_cfg = SimulationCfg(physics=PhysxCfg()) + + # Newton with MuJoCo-Warp + sim_cfg = SimulationCfg(physics=NewtonCfg(solver_cfg=MJWarpSolverCfg())) + + +Feature Support Matrix +---------------------- + +The matrix below is intentionally coarse-grained. For exhaustive per-asset and +per-task support, see each backend's own ``limitations`` page. + +.. list-table:: + :header-rows: 1 + :widths: 30 20 30 20 + + * - Feature + - PhysX + - Newton + - OvPhysX + * - Maturity + - Stable + - Beta + - Highly experimental + * - Default solver + - TGS (rigid body) + - MuJoCo-Warp + - PhysX (TGS / PGS via USD) + * - Alternative solvers + - PGS + - Kamino (beta), additional Newton solvers planned + - — + * - Differentiable + - No + - Yes (via Warp) + - No + * - Articulation API + - Yes + - Yes + - In-flight (PR #5459) + * - Rigid Object API + - Yes + - Yes + - Yes + * - Contact Sensor + - Yes + - Yes + - In-flight (PR #5422) + * - IMU + - Yes + - Yes + - In-flight (PR #5421) + * - Frame Transformer / Ray Caster / PVA / Joint-Wrench Sensor + - Yes + - Yes + - Not yet + * - Camera / Tiled Rendering + - Yes (RTX) + - Yes (Newton-Warp renderer) + - Not yet + * - Requires Isaac Sim + - Yes + - Optional (only for the Omniverse visualizer) + - Yes + * - Solver configuration source + - :class:`~isaaclab_physx.physics.PhysxCfg` + - :class:`~isaaclab_newton.physics.NewtonCfg` + solver config + - USD ``PhysicsScene`` + :class:`~isaaclab_ovphysx.physics.OvPhysxCfg` + + +Selecting Backends per Task +--------------------------- + +Tasks that support more than one backend define a Hydra preset on +``SimulationCfg.physics``. The example below shows the cartpole task config which +declares all three backends side by side: + +.. code-block:: python + + from isaaclab_physx.physics import PhysxCfg + from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg + from isaaclab_ovphysx.physics import OvPhysxCfg + + @configclass + class CartpolePhysicsCfg(PresetCfg): + default: PhysxCfg = PhysxCfg() + physx: PhysxCfg = PhysxCfg() + newton_mjwarp: NewtonCfg = NewtonCfg(solver_cfg=MJWarpSolverCfg()) + ovphysx: OvPhysxCfg = OvPhysxCfg() + +Users then select the backend at the command line via ``presets=`` or by +overriding the physics field directly. See :ref:`hydra-backend-solver-presets` for +the full Hydra interaction. diff --git a/docs/source/overview/core-concepts/physical-backends/newton/index.rst b/docs/source/overview/core-concepts/physical-backends/newton/index.rst new file mode 100644 index 000000000000..087adda9cb23 --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/newton/index.rst @@ -0,0 +1,45 @@ +Newton Backend +============== + +`Newton `_ is a +GPU-accelerated, extensible, and differentiable physics simulation engine designed +for robotics, research, and advanced simulation workflows. Built on top of +`NVIDIA Warp `_ and integrating MuJoCo Warp, Newton +provides high-performance simulation, modern Python APIs, and a flexible +architecture for both users and developers. + +Newton is an Open Source community-driven project with contributions from NVIDIA, +Google Deep Mind, and Disney Research, managed through the Linux Foundation. + +Newton support in Isaac Lab is in beta and under active development. Many features +are still maturing, and the Isaac Lab integration ships a focused, validated set of +classic RL and flat-terrain locomotion environments. We have validated Newton +simulation against PhysX by transferring learned policies in both directions and +have successfully deployed a Newton-trained locomotion policy to a G1 robot. + +Newton can support `multiple solvers +`_ for +handling different types of physics simulation. The Isaac Lab integration ships +two solver pages: + +* :doc:`mjwarp-solver` — the primary, validated solver path. +* :doc:`kamino-solver` — beta support on selected classic tasks. + +During the beta phase, breaking changes and incomplete documentation are still +expected. Official support and debugging assistance will follow once the framework +reaches an official release. + +For an overview of how the multi-backend architecture works, including how to add a +new backend, see :doc:`../../multi_backend_architecture`. + + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + installation + supported-features + mjwarp-solver + kamino-solver + warp-environments + warp-env-migration diff --git a/docs/source/experimental-features/newton-physics-integration/installation.rst b/docs/source/overview/core-concepts/physical-backends/newton/installation.rst similarity index 97% rename from docs/source/experimental-features/newton-physics-integration/installation.rst rename to docs/source/overview/core-concepts/physical-backends/newton/installation.rst index 330ade886c3e..846472b6dbfe 100644 --- a/docs/source/experimental-features/newton-physics-integration/installation.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/installation.rst @@ -1,7 +1,7 @@ Installation ============ -Installing the Newton physics integration requires three things: +Installing the Newton backend requires three things: 1) The ``develop`` branch of Isaac Lab 2) Ubuntu 22.04 or 24.04 diff --git a/docs/source/experimental-features/newton-physics-integration/using-kamino.rst b/docs/source/overview/core-concepts/physical-backends/newton/kamino-solver.rst similarity index 98% rename from docs/source/experimental-features/newton-physics-integration/using-kamino.rst rename to docs/source/overview/core-concepts/physical-backends/newton/kamino-solver.rst index 7c8b6f2d564c..b6d428d13d3c 100644 --- a/docs/source/experimental-features/newton-physics-integration/using-kamino.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/kamino-solver.rst @@ -1,7 +1,7 @@ -.. _newton-using-kamino: +.. _newton-kamino-solver: -Using the Kamino Solver -======================= +Kamino Solver +============= Kamino is a Newton solver, not a separate Isaac Lab physics backend. In Isaac Lab, Kamino is enabled by selecting a :class:`~isaaclab_newton.physics.NewtonCfg` whose @@ -49,7 +49,7 @@ solver config types used by the presets: Then add a ``newton_kamino`` entry beside the existing ``default``, ``physx``, and ``newton_mjwarp`` entries: -.. literalinclude:: ../../../../source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env_cfg.py +.. literalinclude:: ../../../../../../source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env_cfg.py :language: python :start-at: class CartpolePhysicsCfg :end-at: ovphysx: OvPhysxCfg = OvPhysxCfg() diff --git a/docs/source/experimental-features/newton-physics-integration/solver-transitioning.rst b/docs/source/overview/core-concepts/physical-backends/newton/mjwarp-solver.rst similarity index 88% rename from docs/source/experimental-features/newton-physics-integration/solver-transitioning.rst rename to docs/source/overview/core-concepts/physical-backends/newton/mjwarp-solver.rst index 5d78f44de503..805f51a7ec2d 100644 --- a/docs/source/experimental-features/newton-physics-integration/solver-transitioning.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/mjwarp-solver.rst @@ -1,11 +1,13 @@ -Solver Transitioning -==================== - -Transitioning to the Newton physics engine introduces new physics solvers that handle simulation using different numerical approaches. -While Newton supports several different solvers, our initial focus for Isaac Lab is on using the -MuJoCo-Warp solver from Google DeepMind. Isaac Lab also includes beta support for the Kamino -solver on selected classic tasks. Kamino is selected through a physics preset rather than as a -separate backend; see :ref:`hydra-backend-solver-presets` and :ref:`newton-using-kamino`. +MJWarp Solver +============= + +The MuJoCo-Warp solver from Google DeepMind is the primary, validated solver +for the Newton backend in Isaac Lab. It is enabled by setting +:attr:`~isaaclab_newton.physics.NewtonCfg.solver_cfg` to a +:class:`~isaaclab_newton.physics.MJWarpSolverCfg`, usually exposed as the +``newton_mjwarp`` physics preset on a task configuration. Newton ships with +beta support for an alternative Kamino solver — see :doc:`kamino-solver` and +:ref:`hydra-backend-solver-presets` for how presets are selected. .. note:: diff --git a/docs/source/overview/core-concepts/physical-backends/newton/supported-features.rst b/docs/source/overview/core-concepts/physical-backends/newton/supported-features.rst new file mode 100644 index 000000000000..d7fd6aabf0d7 --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/newton/supported-features.rst @@ -0,0 +1,94 @@ +Supported Features +================== + +The Newton backend is in beta. Breaking changes and incomplete documentation are +still expected, and official support or debugging assistance will only be +available once the integration reaches an official release. + + +Discovering Newton-Supported Tasks +---------------------------------- + +A task supports the Newton backend when its physics ``PresetCfg`` declares a +``newton_mjwarp`` (or ``newton_kamino``) entry. To list every task that +currently supports Newton: + +.. code-block:: bash + + grep -rln "newton_mjwarp" source/isaaclab_tasks/ + +Passing ``presets=newton_mjwarp`` to a task without that preset will raise an +error at launch. The :doc:`mjwarp-solver` page covers how to add a Newton +preset to your own task. + + +Supported APIs +-------------- + +The following capabilities are covered by the Newton backend on ``develop`` at +the time of writing. The list is non-exhaustive and continues to grow. + +isaaclab +^^^^^^^^ + +* Articulation API (multi-link and single-body articulations) +* Rigid Object and Rigid Object Collection APIs +* Sensors: Contact Sensor, IMU, Frame Transformer, Joint Wrench, PVA +* Direct and Manager-based single-agent workflows +* Omniverse Kit visualizer (when Isaac Sim is installed) +* Newton-Warp visualizer (kit-less) +* Tiled rendering via the Newton-Warp renderer + +The following sensors are backend-agnostic (implemented in ``isaaclab`` core) +and work transparently with Newton: + +* Ray Caster +* Camera — see :doc:`../../sensors/camera` + +isaaclab_assets +^^^^^^^^^^^^^^^ + +* Quadrupeds: Anymal-B, Anymal-C, Anymal-D, Unitree A1, Unitree Go1, Unitree + Go2, Spot +* Humanoids: Unitree H1, Unitree G1, Cassie +* Arms and hands: Franka, UR10, Allegro Hand, Shadow Hand +* Toy examples: Cartpole, Ant, Humanoid + +isaaclab_tasks +^^^^^^^^^^^^^^ + +Direct workflows: + +* Cartpole (state, RGB, depth) +* Ant, Humanoid +* Allegro Hand Repose Cube, Shadow Hand, Shadow Hand Over +* Locomotion (shared base env) + +Manager-based workflows: + +* Classic: Cartpole, Ant, Humanoid +* Locomotion velocity, flat terrain: A1, Anymal-B, Anymal-C, Anymal-D, Cassie, + Unitree G1, Go1, Go2, Unitree H1, Spot +* Locomotion velocity, rough terrain: Anymal-C, Cassie, Go1, Go2 +* Manipulation: reach (Franka, UR10), cabinet, dexsuite + + +Solver Coverage +--------------- + +* **MuJoCo-Warp solver**: the primary, validated path for every supported task. +* **Kamino solver**: beta. Currently validated on ``Isaac-Cartpole-Direct-v0``, + ``Isaac-Ant-Direct-v0``, ``Isaac-Cartpole-v0``, and ``Isaac-Ant-v0``. See + :doc:`kamino-solver`. + +Other Newton solvers (e.g. VBD) are not yet exposed through Isaac Lab. + + +Known Gaps +---------- + +* Soft bodies, particles, and other non-rigid PhysX features are not yet + available through Newton. +* Behaviour on stiff contact stacks can diverge from PhysX; expect to retune + contact and substep parameters when porting tasks across backends. +* Multi-agent and self-play workflows are not yet wired up for Newton. diff --git a/docs/source/experimental-features/newton-physics-integration/warp-env-migration.rst b/docs/source/overview/core-concepts/physical-backends/newton/warp-env-migration.rst similarity index 100% rename from docs/source/experimental-features/newton-physics-integration/warp-env-migration.rst rename to docs/source/overview/core-concepts/physical-backends/newton/warp-env-migration.rst diff --git a/docs/source/experimental-features/newton-physics-integration/warp-environments.rst b/docs/source/overview/core-concepts/physical-backends/newton/warp-environments.rst similarity index 99% rename from docs/source/experimental-features/newton-physics-integration/warp-environments.rst rename to docs/source/overview/core-concepts/physical-backends/newton/warp-environments.rst index 6af7d934dafe..a5dcbd648724 100644 --- a/docs/source/experimental-features/newton-physics-integration/warp-environments.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/warp-environments.rst @@ -220,7 +220,7 @@ Limitations ~~~~~~~~~~~ The warp env path is experimental and has the following known constraints. These are -specific to warp envs; for Newton physics limitations see :doc:`limitations-and-known-bugs`. +specific to warp envs; for Newton physics limitations see :doc:`supported-features`. **Physics backend** diff --git a/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst b/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst new file mode 100644 index 000000000000..b136dafd681f --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst @@ -0,0 +1,68 @@ +OvPhysX Backend +=============== + +.. warning:: + + OvPhysX is **highly experimental** and is not recommended for general use yet. + The public surface is changing rapidly while the backend is under active + development. This page is a placeholder and will be expanded once the + in-flight integration work lands on ``develop``. + +OvPhysX is a kit-less variant of the PhysX backend. It drives PhysX directly +(without the Omniverse Kit runtime) and reads scene-level solver parameters +from the USD ``PhysicsScene`` prim rather than from a Python config. The Python +config :class:`~isaaclab_ovphysx.physics.OvPhysxCfg` only exposes the handful of +GPU buffer sizes that are not represented on the USD schema. + +OvPhysX is selected through :class:`~isaaclab_ovphysx.physics.OvPhysxCfg`: + +.. code-block:: python + + from isaaclab.sim import SimulationCfg + from isaaclab_ovphysx.physics import OvPhysxCfg + + sim_cfg = SimulationCfg(physics=OvPhysxCfg()) + +Why use OvPhysX? +---------------- + +* **Kit-less execution.** OvPhysX avoids Omniverse Kit, which makes it a useful + experimental path for headless deployments and for backends that don't need + the Kit runtime stack. +* **USD-as-source-of-truth.** Solver parameters are taken from the + ``PhysicsScene`` USD prim, so authoring tools that already manage USD scenes + do not need a parallel Python config. + +What works today +---------------- + +The asset and sensor surface tracks PhysX, but only a subset is implemented and +validated at the time of writing. Rigid Object support is merged on +``develop``; the remaining assets and sensors are landing through a series of +stacked pull requests: + +* RigidObject — merged via + `PR #5426 `_. +* Articulation — open in + `PR #5459 `_. +* Contact Sensor — open in + `PR #5422 `_. +* IMU — open in + `PR #5421 `_. +* RigidObjectCollection — open in + `PR #5570 `_. +* SceneDataProvider — open in + `PR #5589 `_. + +Other sensors (Frame Transformer, Joint Wrench, PVA, Ray Caster) and the +rendering surface are not yet wired up for OvPhysX. + +Status and follow-up +-------------------- + +This page is intentionally a stub. Once the in-flight OvPhysX work merges, this +section will be expanded with full installation, configuration, and supported +feature lists matching the other backends. The expansion is tracked in +`issue #5634 `_. + +For architectural context, see :doc:`../../multi_backend_architecture`. diff --git a/docs/source/overview/core-concepts/physical-backends/physx/configuration.rst b/docs/source/overview/core-concepts/physical-backends/physx/configuration.rst new file mode 100644 index 000000000000..1fc743d376b4 --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/physx/configuration.rst @@ -0,0 +1,108 @@ +PhysX Configuration +=================== + +PhysX scene-level settings live on :class:`~isaaclab_physx.physics.PhysxCfg`, +which replaces the legacy ``PhysxCfg`` from Isaac Lab 2.x. Per-actor settings +(such as per-articulation iteration counts, contact filtering, or material +properties) continue to be authored on the USD schema; see :doc:`../../schema_cfgs` +for the schema-level configuration helpers. + +The example below shows a representative ``PhysxCfg`` for a contact-rich +manipulation task: + +.. code-block:: python + + from isaaclab.sim import SimulationCfg + from isaaclab_physx.physics import PhysxCfg + + physx_cfg = PhysxCfg( + solver_type=1, # TGS + min_position_iteration_count=8, + max_position_iteration_count=64, + min_velocity_iteration_count=1, + max_velocity_iteration_count=4, + enable_ccd=False, + enable_stabilization=True, + bounce_threshold_velocity=0.2, + friction_offset_threshold=0.04, + friction_correlation_distance=0.025, + ) + + sim_cfg = SimulationCfg(dt=1 / 120, physics=physx_cfg) + + +Common Parameters +----------------- + +The following list highlights the parameters that most often need tuning. The +full reference lives on :class:`~isaaclab_physx.physics.PhysxCfg`. + +Solver Selection +^^^^^^^^^^^^^^^^ + +* ``solver_type``: ``1`` for **TGS** (Temporal Gauss-Seidel, default) or ``0`` + for **PGS** (Projective Gauss-Seidel). TGS is the recommended default for + articulated robots; PGS can be more forgiving on stiff legacy assets. +* ``solve_articulation_contact_last``: alters the articulation solver order so + that dynamic contact is resolved at the end of the solve. Useful for stiff + gripping scenarios. Requires Isaac Sim 5.1+. + + +Solver Iterations +^^^^^^^^^^^^^^^^^ + +* ``min_position_iteration_count`` / ``max_position_iteration_count``: clamp + range applied to every actor's individual position-iteration count. +* ``min_velocity_iteration_count`` / ``max_velocity_iteration_count``: clamp + range for velocity iterations. +* ``enable_external_forces_every_iteration``: applies external forces on every + TGS position iteration. Reduces noisy velocity updates at additional + compute cost; ignored with the PGS solver. + + +Contact and Stability +^^^^^^^^^^^^^^^^^^^^^ + +* ``enable_ccd``: continuous-collision detection for fast-moving bodies. +* ``enable_stabilization``: extra solver stabilization pass; recommended only + when ``dt`` is large (< 30 Hz). Corrupts contact-sensor force-magnitude + readings — disable it if you rely on the contact sensor for force + observations. +* ``bounce_threshold_velocity``: relative velocity threshold [m/s] above which + contacts bounce. +* ``friction_offset_threshold``: contact point distance [m] at which friction + forces are applied. +* ``friction_correlation_distance``: distance [m] used to merge nearby + contacts into a single friction anchor. + + +GPU Buffer Sizing +^^^^^^^^^^^^^^^^^ + +PhysX on the GPU does **not** dynamically grow scene buffers, so undersized +buffers crash or silently drop contacts. The ``gpu_*`` fields on +:class:`~isaaclab_physx.physics.PhysxCfg` control these capacities. The +defaults are tuned for a few-hundred-environment locomotion task; large +multi-thousand-environment manipulation tasks usually need to raise: + +* ``gpu_max_rigid_contact_count`` +* ``gpu_max_rigid_patch_count`` +* ``gpu_found_lost_pairs_capacity`` +* ``gpu_found_lost_aggregate_pairs_capacity`` +* ``gpu_total_aggregate_pairs_capacity`` +* ``gpu_collision_stack_size`` +* ``gpu_heap_capacity`` + +PhysX prints ``[PhysX]`` warnings when a buffer is exhausted; treat those as +hard failures and re-tune. + + +See Also +-------- + +* :class:`~isaaclab_physx.physics.PhysxCfg` — full parameter reference. +* :doc:`../../schema_cfgs` — schema-level configuration helpers. +* :doc:`../../multi_backend_architecture` — how PhysX plugs into the backend + factory pattern. +* PhysX 5 SDK documentation: + https://nvidia-omniverse.github.io/PhysX/physx/5.4.1/_api_build/classPxSceneDesc.html diff --git a/docs/source/overview/core-concepts/physical-backends/physx/index.rst b/docs/source/overview/core-concepts/physical-backends/physx/index.rst new file mode 100644 index 000000000000..3daedd4116b1 --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/physx/index.rst @@ -0,0 +1,37 @@ +PhysX Backend +============= + +`NVIDIA PhysX `_ +is the historical default physics backend in Isaac Lab. It runs through +`NVIDIA Isaac Sim `_'s Omniverse Kit +runtime and supports GPU-accelerated rigid-body, articulation, soft-body, and +particle simulation. PhysX is the reference backend for behaviour parity in Isaac +Lab — assets, sensors, and tasks have all been validated against it first, and the +other backends are measured against PhysX behaviour. + +PhysX is selected via :class:`~isaaclab_physx.physics.PhysxCfg`: + +.. code-block:: python + + from isaaclab.sim import SimulationCfg + from isaaclab_physx.physics import PhysxCfg + + sim_cfg = SimulationCfg(physics=PhysxCfg()) + +The PhysX backend uses the Temporal Gauss-Seidel (TGS) solver by default and also +exposes a Projective Gauss-Seidel (PGS) variant. Scene-level solver tuning, GPU +buffer sizing, and contact-handling parameters live on +:class:`~isaaclab_physx.physics.PhysxCfg`; per-actor settings remain on the USD +schema. See :doc:`configuration` for the most common knobs. + +For an overview of how the multi-backend architecture works, see +:doc:`../../multi_backend_architecture`. + + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + installation + configuration + supported-features diff --git a/docs/source/overview/core-concepts/physical-backends/physx/installation.rst b/docs/source/overview/core-concepts/physical-backends/physx/installation.rst new file mode 100644 index 000000000000..60b3af80e1ed --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/physx/installation.rst @@ -0,0 +1,31 @@ +Installation +============ + +PhysX is installed as part of the standard Isaac Lab installation. It runs through +`NVIDIA Isaac Sim `_'s Omniverse Kit +runtime, so Isaac Sim is a required dependency for the PhysX backend. + +Follow the :ref:`isaaclab-installation-root` guide for the full installation +procedure. The short version: + +1. Install Isaac Sim 6.0 (binary install or pip install — see the Isaac Sim + documentation for system requirements). +2. Clone Isaac Lab and run ``./isaaclab.sh -i`` to install the Isaac Lab + extensions on top of Isaac Sim. + +No extra packages are required for the PhysX backend specifically — the PhysX +runtime ships with Isaac Sim. + + +Testing the Installation +------------------------ + +To verify the PhysX backend is working, run any classic Isaac Lab task with the +default preset: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/environments/zero_agent.py --task Isaac-Cartpole-v0 --num_envs 128 + +The ``default`` preset on most tasks resolves to PhysX. You can also pass +``presets=physx`` explicitly on tasks that declare multi-backend physics presets. diff --git a/docs/source/overview/core-concepts/physical-backends/physx/supported-features.rst b/docs/source/overview/core-concepts/physical-backends/physx/supported-features.rst new file mode 100644 index 000000000000..faf620e963a9 --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/physx/supported-features.rst @@ -0,0 +1,70 @@ +Supported Features +================== + +PhysX is the broadest backend in Isaac Lab. It is the reference for behaviour +parity and supports every public asset, sensor, and renderer surface in the +framework. Tasks built before Isaac Lab 3.0 ran on PhysX, and the bulk of the +shipped tasks still default to the PhysX preset. + +The summary below is intentionally coarse; consult each component's API +documentation for fine-grained capability details. + +Core Simulation +--------------- + +* Articulation API (multi-link articulations, fixed-base and floating-base + articulations, single-body articulations modeled as rigid bodies) +* Rigid Object and Rigid Object Collection APIs +* Soft-body and particle simulation (legacy — not exposed through the + Isaac Lab asset surface but available through PhysX schemas) +* CPU and GPU pipelines; GPU is the default for the vectorized RL workloads + + +Sensors +------- + +PhysX implements the following sensors directly under +``isaaclab_physx/sensors/``: + +* Contact Sensor +* IMU +* Frame Transformer +* Joint Wrench Sensor +* PVA + +The following sensors are backend-agnostic (implemented in ``isaaclab`` core) +and work transparently with PhysX: + +* Ray Caster +* Camera — see :doc:`../../sensors/camera` + + +Rendering +--------- + +* RTX renderer (real-time rasterized; path tracing available through the + underlying Omniverse RTX pipeline) +* Tiled rendering for vectorized RGB / depth / segmentation + + +Tasks and Workflows +------------------- + +* Direct and Manager-based workflows +* All ``isaaclab_tasks`` environments default to the PhysX preset unless the + task explicitly opts in to a different backend +* Imitation learning and motion-generation pipelines (Mimic, motion generators) + + +Known Caveats +------------- + +* PhysX requires Isaac Sim and Omniverse Kit to be installed. +* GPU buffer sizes are static and must be tuned per task — see + :doc:`configuration`. +* ``enable_stabilization`` can corrupt contact-force readings reported through + the contact sensor; disable it if you rely on the contact sensor for + force-magnitude observations. +* The PhysX TGS solver behaviour can differ from Newton's MJWarp solver on + stiff contact stacks; if you are porting a task to Newton, expect to retune + contact-handling parameters. diff --git a/docs/source/overview/core-concepts/physical-backends/solver-comparison.rst b/docs/source/overview/core-concepts/physical-backends/solver-comparison.rst new file mode 100644 index 000000000000..d272d0f2f673 --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/solver-comparison.rst @@ -0,0 +1,279 @@ +Solver Comparison +================= + +This page summarizes the user-visible behavioural differences between the +solvers shipped with the Isaac Lab physical backends. It's intended as a +porting reference: most tasks reuse the same USD asset across backends, but +contact, friction, and stabilization behave differently enough that retuning +is usually required when moving from one solver to another. + +The solvers covered are: + +* **PhysX TGS** — default solver for the :doc:`PhysX backend ` + (Temporal Gauss-Seidel). PhysX also exposes a Projective Gauss-Seidel + (PGS) variant via :attr:`~isaaclab_physx.physics.PhysxCfg.solver_type`, + which behaves similarly for the purposes of this comparison. +* **Newton MuJoCo-Warp (MJWarp)** — primary :doc:`Newton solver `, + configured by :class:`~isaaclab_newton.physics.MJWarpSolverCfg`. +* **Newton Kamino** — beta P-ADMM :doc:`Newton solver `, + configured by :class:`~isaaclab_newton.physics.KaminoSolverCfg`. + +Newton additionally ships ``FeatherstoneSolverCfg`` and ``XPBDSolverCfg``; +neither is wired into an Isaac Lab task at the time of writing and they +are omitted from this comparison. + + +Friction Model +-------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Solver + - Friction handling + * - PhysX TGS + - Coulomb friction with **patch-based** anchors. Tangential forces are + merged across nearby contacts via + :attr:`~isaaclab_physx.physics.PhysxCfg.friction_correlation_distance` + and applied above + :attr:`~isaaclab_physx.physics.PhysxCfg.friction_offset_threshold`. + The friction cone is always pyramidal. + * - MJWarp + - MuJoCo's friction model. The friction cone shape is selectable via + :attr:`~isaaclab_newton.physics.MJWarpSolverCfg.cone` + (``"pyramidal"`` or ``"elliptic"``). The tangential-to-normal + impedance ratio is exposed as + :attr:`~isaaclab_newton.physics.MJWarpSolverCfg.impratio`. + * - Kamino + - Per-contact friction resolved inside the P-ADMM solve. Contact + warm-starting is selectable via + :attr:`~isaaclab_newton.physics.KaminoSolverCfg.padmm_contact_warmstart_method`; + the validated presets use ``"geom_pair_net_force"``. + +**Porting implication.** Tasks tuned for PhysX's patch friction can feel +"grippier" than MJWarp's per-contact friction at the same friction +coefficient. When moving a manipulation task from PhysX to MJWarp, expect to +raise friction coefficients and consider switching ``cone`` to ``"elliptic"`` +for stiffer contact stacks. + + +Contact Detection and Resolution +-------------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Solver + - Collision pipeline + * - PhysX TGS + - PhysX's built-in broadphase + narrowphase, with optional continuous + collision detection via + :attr:`~isaaclab_physx.physics.PhysxCfg.enable_ccd`. Pre-sized GPU + buffers (``gpu_max_rigid_contact_count`` etc.) cap the number of + contacts per step; oversubscription is a hard error. + * - MJWarp + - Two modes selected by + :attr:`~isaaclab_newton.physics.MJWarpSolverCfg.use_mujoco_contacts`: + MuJoCo's internal contact pipeline (default) or Newton's + :class:`~isaaclab_newton.physics.NewtonCollisionPipelineCfg`. The two + are mutually exclusive. GJK/EPA iteration count is exposed via + :attr:`~isaaclab_newton.physics.MJWarpSolverCfg.ccd_iterations`. + * - Kamino + - Optionally uses Kamino's internal collision detector (``"primitive"`` + or ``"unified"``) via + :attr:`~isaaclab_newton.physics.KaminoSolverCfg.use_collision_detector`, + otherwise falls back to Newton's :class:`CollisionPipeline`. + Contact penetration margin is set by + :attr:`~isaaclab_newton.physics.KaminoSolverCfg.constraints_delta`. + +**Porting implication.** A task that runs with ``--enable_ccd`` on PhysX +won't get the same protection on MJWarp/Kamino at large ``dt`` — Newton's +CCD is convex GJK/EPA, not the swept-shape CCD PhysX uses. The mitigation +on Newton is shorter ``dt`` or higher +:attr:`~isaaclab_newton.physics.NewtonCfg.num_substeps`. + + +Restitution and Bounce +---------------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Solver + - Restitution + * - PhysX TGS + - Restitution coefficient is per-material on the USD shape. A global + velocity threshold + :attr:`~isaaclab_physx.physics.PhysxCfg.bounce_threshold_velocity` + suppresses restitution below ~0.5 m/s by default. + * - MJWarp + - Restitution follows the MJCF model translated from USD. There is no + bounce-threshold gate — small-velocity contacts can still bounce + unless you reduce the per-material restitution. + * - Kamino + - Restitution is contained in the contact constraint set; behaviour is + similar to MJWarp. + +**Porting implication.** Tasks that rely on PhysX's bounce-threshold to +suppress jitter (e.g. footstep contact on flat ground) may show contact +chatter on Newton until restitution coefficients are reduced. + + +Constraint Stabilization +------------------------ + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Solver + - Stabilization + * - PhysX TGS + - Implicit, via the TGS solver. An optional extra pass is enabled by + :attr:`~isaaclab_physx.physics.PhysxCfg.enable_stabilization` (note: + corrupts contact-sensor force magnitudes). + * - MJWarp + - Implicit, set by the MuJoCo solver's ``solref``/``solimp`` per + constraint. No top-level toggle. + * - Kamino + - Explicit Baumgarte stabilization with separate gains for joint + bilaterals (:attr:`~isaaclab_newton.physics.KaminoSolverCfg.constraints_alpha`), + joint-limit unilaterals + (:attr:`~isaaclab_newton.physics.KaminoSolverCfg.constraints_beta`), + and contact unilaterals + (:attr:`~isaaclab_newton.physics.KaminoSolverCfg.constraints_gamma`). + + +Solver Convergence +------------------ + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Solver + - Iteration model + * - PhysX TGS + - Two iteration counts, per actor: position + (``min/max_position_iteration_count``) and velocity + (``min/max_velocity_iteration_count``). The solver takes the largest + actor's count clamped to the scene range. No global convergence + tolerance. + * - MJWarp + - :attr:`~isaaclab_newton.physics.MJWarpSolverCfg.iterations` outer + Newton/CG iterations and + :attr:`~isaaclab_newton.physics.MJWarpSolverCfg.ls_iterations` line + searches per outer iteration. Convergence gate: + :attr:`~isaaclab_newton.physics.MJWarpSolverCfg.tolerance` (default + ``1e-6``). + :attr:`~isaaclab_newton.physics.MJWarpSolverCfg.ls_parallel` switches + line search to parallel execution at a small accuracy cost. + * - Kamino + - P-ADMM with separate + :attr:`~isaaclab_newton.physics.KaminoSolverCfg.padmm_primal_tolerance`, + :attr:`~isaaclab_newton.physics.KaminoSolverCfg.padmm_dual_tolerance`, + and + :attr:`~isaaclab_newton.physics.KaminoSolverCfg.padmm_compl_tolerance` + gates, capped at + :attr:`~isaaclab_newton.physics.KaminoSolverCfg.padmm_max_iterations`. + Acceleration and warm-starting are tunable. + + +Articulation Coordinates +------------------------ + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Solver + - Coordinate representation + * - PhysX TGS + - **Reduced-coordinate** articulations (joint-space, Featherstone-like). + Joint state is the canonical truth; body transforms are derived via + forward kinematics. + * - MJWarp + - Reduced-coordinate, computed by the MuJoCo solver. + * - Kamino + - **Maximal-coordinate**: each body has its own free-body state, + constraints are enforced via Baumgarte stabilization. Resets go + through a dedicated FK pass (:attr:`~isaaclab_newton.physics.KaminoSolverCfg.use_fk_solver`) + so maximal body poses match the reduced joint state after a state + write. + +**Porting implication.** Kamino is more sensitive to inconsistent reset +state — joint positions and body poses must agree, which Isaac Lab's asset +write paths handle but custom reset code can break. + + +Substepping and Timestep +------------------------ + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Solver + - Substep model + * - PhysX TGS + - PhysX runs at the simulation ``dt``. No external substep counter; + internal substepping is per-actor. + * - MJWarp + - Top-level :attr:`~isaaclab_newton.physics.NewtonCfg.num_substeps` + controls how many solver substeps run per Isaac Lab step. Effective + solver ``dt`` is ``SimulationCfg.dt / num_substeps``. + * - Kamino + - Same :attr:`~isaaclab_newton.physics.NewtonCfg.num_substeps` knob. + Validated Kamino task presets typically use 1–2 substeps; expect to + raise this for contact-heavy tasks. + + +GPU Buffers and Throughput +-------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Solver + - Memory model + * - PhysX TGS + - Static GPU buffers sized at construction. Undersized buffers cause + crashes or dropped contacts at scale. See the + :doc:`PhysX configuration page ` for the + ``gpu_*`` knobs. + * - MJWarp + - Pre-allocated per-environment limits: + :attr:`~isaaclab_newton.physics.MJWarpSolverCfg.njmax` + (constraint rows) and + :attr:`~isaaclab_newton.physics.MJWarpSolverCfg.nconmax` (contact + points). The remainder of state lives in dynamically-sized Warp + arrays. + * - Kamino + - Inherits MJWarp's pre-allocation pattern via Newton, plus its own + contact-pair allocator + (:attr:`~isaaclab_newton.physics.KaminoSolverCfg.collision_detector_max_contacts_per_pair`) + when using the internal collision detector. + + +Porting Checklist +----------------- + +When moving a task between solvers: + +1. **Re-validate contact behavior.** Run the task at the smallest + ``num_envs`` with a visualizer; watch for new penetration or jitter + before scaling up. +2. **Retune friction.** PhysX patch friction and MJWarp per-contact friction + are not interchangeable at the same coefficient. +3. **Retune restitution.** MJWarp/Kamino have no bounce-threshold gate; + reduce per-material restitution to suppress jitter. +4. **Choose substeps.** PhysX → Newton typically needs at least 1–2 + substeps for contact-heavy tasks; manipulation tasks may need more. +5. **Watch for CCD differences.** Tasks that relied on PhysX swept CCD + should either reduce ``dt`` or raise ``num_substeps`` on Newton. +6. **For Kamino specifically**, also validate reset behaviour and consider + Baumgarte gains if you see drift on joint or contact constraints. diff --git a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst index 56d6482f4676..d69faa9f6d01 100644 --- a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst +++ b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst @@ -29,9 +29,11 @@ See :doc:`/source/features/hydra` for all available names and how the selectors .. note:: - **Not all environments support every backend.** Using a preset with an environment - that has not been configured for that backend will raise an error at launch. See - :doc:`/source/experimental-features/newton-physics-integration/index` for details. + **Not all environments support the Newton backend yet.** Using ``presets=newton_mjwarp`` with an + environment that has not been configured for Newton will raise an error at launch. See + :doc:`/source/overview/core-concepts/physical-backends/newton/index` + for more details, and the :ref:`migrating-to-isaaclab-3-0` + guide for how to add Newton support to your own environments. Newton does not require Isaac Sim (kit-less mode). See :ref:`kitless-installation` for setup. From 3c1753bed3743e7904cc0df338e991fab539ae9c Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Tue, 19 May 2026 19:16:40 +0200 Subject: [PATCH 113/133] [OVPHYSX] ContactSensor (#5422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Implements `ContactSensor`, `ContactSensorCfg`, and `ContactSensorData` for the `isaaclab_ovphysx` backend, mirroring the existing PhysX implementation. The contact sensor reports normal contact forces in the world frame using the ovphysx 0.3.7 `ContactBinding` API (`PhysX.create_contact_binding` / `read_net_forces` / `read_force_matrix`). Optional pose tracking is wired through a `RIGID_BODY_POSE` `TensorBinding`. Validation environment: `Isaac-Velocity-Flat-Anymal-C-Direct-v0` (Anymal-C foot-contact tracking for locomotion). **v1 scope (this PR):** - Net contact forces + history - Force matrix (filtered partner forces) + history - Pose tracking (`track_pose`) - Air/contact time tracking + `compute_first_contact` / `compute_first_air` - Reset semantics + native handle teardown on simulation stop **Deferred (raise `NotImplementedError` if cfg flag enabled):** - `track_contact_points` - `track_friction_forces` These features are blocked on tensor-friendly per-sensor read APIs in ovphysx (`ContactBinding.read_contact_points` / `read_friction_forces`). A maintainer-facing spec listing the missing APIs is attached at `docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md`. The data properties still return `None` per the `BaseContactSensorData` contract; the implementation only raises if the cfg flag is set. Fixes #5325 Parent issue: #5315 **Stacked on:** - #5459 (`[OVPHYSX] Articulation rewrite`) — this branch is rebased on top of `antoiner/feat/ovphysx_articulation`. Reviewers can focus on the last 13 commits (everything from `Scaffold isaaclab_ovphysx sensors sub-package` onward); commits before that are inherited from #5459. Will be re-rebased on `develop` once #5459 merges. **Dependencies:** - Requires #4852 (merged into `develop` 2026-04-20). - ovphysx `RigidObject` lands as part of #5459 — no separate gate. ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots N/A — backend feature, no UI surface. ## 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 (changelog fragment at `source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contactsensor.minor.rst`, docstrings, gap spec for the maintainer of ovphysx) - [x] My changes generate no new warnings - [x] I have added tests that prove my feature works (9 tests adapted from the PhysX backend; 3 skipped for the deferred features so they're trivially un-skip-able once ovphysx ships the missing APIs) - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file (changelog fragment, minor tier — version bump compiled by the nightly CI workflow per the upstream convention) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there ## Notes for the reviewer - The Warp kernels (`sensors/contact_sensor/kernels.py` and the shared `sensors/kernels.py`) are physics-engine-agnostic and ported verbatim from the PhysX backend — only module docstrings differ. - `ContactSensorCfg` and `ContactSensorData` are also near-verbatim mirrors; the only edits are the backend-specific `class_type` pointer and the shared-kernels import path. - The ovphysx-specific glue lives in `_initialize_impl` (USD prim discovery + regex→fnmatch glob conversion + `create_contact_binding` + optional `RIGID_BODY_POSE` binding), `_create_buffers` (pre-allocated DLPack read buffers), `_update_buffers_impl` (per-step reads), and `_invalidate_initialize_callback` (native handle release). - **Tests not yet executed.** They are currently authored in the Isaac-Sim/`AppLauncher` style (mirroring `isaaclab_physx/test/sensors/test_contact_sensor.py`); they need to be re-adapted to the kitless `./scripts/run_ovphysx.sh -m pytest` flow and the `_ovphysx_sim_context` helper used by the articulation/rigid-object suites in #5459. Will land as a follow-up commit on this branch once that adaptation is reviewed. --- .../antoiner-feat-ovphysx_contactsensor.rst | 32 + source/isaaclab/isaaclab/envs/mdp/events.py | 31 +- .../isaaclab/physics/physics_manager.py | 16 +- .../isaaclab/scene/interactive_scene.py | 10 + .../isaaclab/isaaclab/sensors/sensor_base.py | 4 +- ...oiner-feat-ovphysx_contactsensor.minor.rst | 46 + .../physics/ovphysx_manager.py | 31 + .../isaaclab_ovphysx/sensors/__init__.py | 10 + .../isaaclab_ovphysx/sensors/__init__.pyi | 12 + .../sensors/contact_sensor/__init__.py | 10 + .../sensors/contact_sensor/__init__.pyi | 14 + .../sensors/contact_sensor/contact_sensor.py | 515 +++++++++ .../contact_sensor/contact_sensor_cfg.py | 19 + .../contact_sensor/contact_sensor_data.py | 318 ++++++ .../sensors/contact_sensor/kernels.py | 300 +++++ .../isaaclab_ovphysx/sensors/kernels.py | 42 + .../test/sensors/check_contact_sensor.py | 38 - .../test/sensors/test_contact_sensor.py | 1004 +++++++++++++++++ .../antoiner-feat-ovphysx_contactsensor.rst | 10 + .../velocity/config/anymal_d/flat_env_cfg.py | 2 + .../locomotion/velocity/velocity_env_cfg.py | 2 + 21 files changed, 2422 insertions(+), 44 deletions(-) create mode 100644 source/isaaclab/changelog.d/antoiner-feat-ovphysx_contactsensor.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contactsensor.minor.rst create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.pyi create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/__init__.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/__init__.pyi create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor_cfg.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor_data.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/kernels.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/kernels.py delete mode 100644 source/isaaclab_ovphysx/test/sensors/check_contact_sensor.py create mode 100644 source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py create mode 100644 source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_contactsensor.rst diff --git a/source/isaaclab/changelog.d/antoiner-feat-ovphysx_contactsensor.rst b/source/isaaclab/changelog.d/antoiner-feat-ovphysx_contactsensor.rst new file mode 100644 index 000000000000..7d92d9215b7c --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-feat-ovphysx_contactsensor.rst @@ -0,0 +1,32 @@ +Fixed +^^^^^ + +* Fixed three places where ``OvPhysxManager`` was misclassified as the + PhysX backend by a substring/schema match: + + - :meth:`~isaaclab.sensors.SensorBase._register_callbacks` matched + ``"physx" in physics_mgr_cls.__name__.lower()`` to gate the PhysX + ``IsaacEvents.PRIM_DELETION`` import — the substring also matches + ``"OvPhysxManager"``, so the ``isaaclab_physx`` import fired in + kitless OVPhysX mode and raised + :exc:`ModuleNotFoundError` because ``omni.physics.tensors`` is not + loaded. Switched to an exact ``physics_mgr_cls.__name__ == + "PhysxManager"`` match. + - :meth:`~isaaclab.assets.AssetBase.set_debug_vis` had the same + substring check guarding an ``import omni.kit.app`` call, which + would fire for OVPhysX-backed assets and break under + ``./scripts/run_ovphysx.sh``. Switched to an exact + ``"PhysxManager"`` match. + - :meth:`~isaaclab.physics.SceneDataProvider._get_backend` used + ``"physx" in manager_name`` to dispatch the backend factory; this + silently routed ``OvPhysxManager`` to the PhysX scene-data + provider. Switched to exact ``"PhysxManager"`` / + ``"NewtonManager"`` matches and an explicit ``ValueError`` for + unknown managers. +* Made + :attr:`~isaaclab.scene.InteractiveScene.physics_scene_path` accept a + bare :class:`pxr.UsdPhysics.Scene` prim as a fallback when no prim + with ``PhysxSceneAPI`` applied is on the stage. Kitless OVPhysX + does not load the ``omni.physx`` schema, so the auto-created scene + prim only carries the stock USD type. PhysX-backed flows continue + to prefer the ``PhysxSceneAPI`` prim. diff --git a/source/isaaclab/isaaclab/envs/mdp/events.py b/source/isaaclab/isaaclab/envs/mdp/events.py index f430c105ee78..9c1c885520a9 100644 --- a/source/isaaclab/isaaclab/envs/mdp/events.py +++ b/source/isaaclab/isaaclab/envs/mdp/events.py @@ -385,12 +385,37 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv): f" with type: '{type(self.asset)}'." ) - # detect physics backend and instantiate the appropriate implementation + # detect physics backend and instantiate the appropriate implementation. + # Check ``ovphysxmanager`` first: it contains the substring ``physx`` so + # would otherwise be caught by the ``"physx" in ...`` branch below and + # routed to the PhysX impl, which assumes a ``root_view`` with + # ``.link_paths`` — OVPhysX's per-tensor-type bindings dict does not + # satisfy that contract. Newton's subclasses (``NewtonMJWarpManager``, + # ``NewtonKaminoManager``, ...) are caught by the substring branch. manager_name = env.sim.physics_manager.__name__.lower() - if "newton" in manager_name: + if manager_name == "ovphysxmanager": + # No OVPhysX implementation yet — wheel-side + # ``RIGID_BODY_MATERIAL`` tensor binding is missing; randomization + # would require per-body view creation that ovphysx does not yet + # expose. Run with material randomization disabled (warns once). + import logging # noqa: PLC0415 + + logging.getLogger(__name__).warning( + "randomize_rigid_body_material is a no-op on the OVPhysX backend " + "(wheel-side gap — see docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md)." + ) + + class _Noop: + def __call__(self, *args, **kwargs): + pass + + self._impl = _Noop() + elif "newton" in manager_name: self._impl = _RandomizeRigidBodyMaterialNewton(cfg, env, self.asset, self.asset_cfg) - else: + elif "physx" in manager_name: self._impl = _RandomizeRigidBodyMaterialPhysx(cfg, env, self.asset, self.asset_cfg) + else: + raise ValueError(f"Unsupported physics manager for randomize_rigid_body_material: {manager_name!r}") def __call__( self, diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 3f63dfa01c60..e6abd8287139 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -157,11 +157,23 @@ def dispatch_event(cls, event: PhysicsEvent, payload: Any = None) -> None: @classmethod def clear_callbacks(cls) -> None: - """Remove all registered callbacks.""" + """Remove all registered callbacks. + + Do NOT reset ``_callback_id`` — handle IDs must remain monotonically + unique across the lifetime of the process. Resetting the counter + would let a future :meth:`register_callback` hand out an ID that an + old, still-alive :class:`CallbackHandle` (e.g. on a sensor that has + not been garbage-collected yet) holds, so when the old object + eventually finalizes its ``__del__`` would deregister the new + callback. This bit ovphysx's kitless multi-context tests where two + ``InteractiveScene``s are created in sequence: the first scene's + sensor would post-GC deregister the second scene's + ``_initialize_callback`` by ID collision, leaving the second sensor + forever uninitialized. + """ for cid in list(cls._callbacks.keys()): cls.deregister_callback(cid) cls._callbacks.clear() - cls._callback_id = 0 @classmethod def _wrap_weak_ref(cls, callback: Callable) -> Callable: diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 543dfdfc9d57..70756007d6fb 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -454,11 +454,21 @@ def __str__(self) -> str: def physics_scene_path(self) -> str: """The path to the USD Physics Scene.""" if self._physics_scene_path is None: + # Prefer a prim with PhysxSceneAPI applied (Isaac Sim flow). Fall + # back to any UsdPhysics.Scene prim (kitless OvPhysX flow does not + # load the omni.physx schema, so the auto-created scene only + # carries the stock USD type without PhysxSceneAPI). + fallback_path: str | None = None for prim in self.stage.Traverse(): if "PhysxSceneAPI" in prim.GetAppliedSchemas(): self._physics_scene_path = prim.GetPrimPath().pathString logger.info(f"Physics scene prim path: {self._physics_scene_path}") break + if fallback_path is None and prim.GetTypeName() == "PhysicsScene": + fallback_path = prim.GetPrimPath().pathString + if self._physics_scene_path is None and fallback_path is not None: + self._physics_scene_path = fallback_path + logger.info(f"Physics scene prim path (no PhysxSceneAPI): {self._physics_scene_path}") if self._physics_scene_path is None: raise RuntimeError("No physics scene found! Please make sure one exists.") return self._physics_scene_path diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index d52c902f9d73..15fca5ee4ad4 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base.py @@ -294,7 +294,9 @@ def _invoke(callback_name, event): PhysicsEvent.STOP, order=10, ) - # Optional: prim deletion (only supported by PhysX backend) + # Optional: prim deletion (only supported by PhysX backend; the substring + # check would also match ``OvPhysxManager``, which does not expose + # ``IsaacEvents``, so use an exact class-name match). self._prim_deletion_handle = None if physics_mgr_cls.__name__ == "PhysxManager": from isaaclab_physx.physics import IsaacEvents # noqa: PLC0415 diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contactsensor.minor.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contactsensor.minor.rst new file mode 100644 index 000000000000..1fa4e49623c7 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contactsensor.minor.rst @@ -0,0 +1,46 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.sensors.ContactSensor`, + :class:`~isaaclab_ovphysx.sensors.ContactSensorCfg`, and + :class:`~isaaclab_ovphysx.sensors.ContactSensorData` for the OVPhysX + backend, satisfying the + :class:`~isaaclab.sensors.contact_sensor.BaseContactSensor` and + :class:`~isaaclab.sensors.contact_sensor.BaseContactSensorData` + contracts. Wires net contact forces and the per-partner force matrix + through the OVPhysX :class:`ovphysx.api.ContactBinding` API + (``read_net_forces`` / ``read_force_matrix``); optional pose tracking + reads through a ``RIGID_BODY_POSE`` :class:`ovphysx.api.TensorBinding`. + Air/contact time tracking, + :meth:`~isaaclab_ovphysx.sensors.ContactSensor.compute_first_contact`, + :meth:`~isaaclab_ovphysx.sensors.ContactSensor.compute_first_air`, + history buffers, and reset semantics mirror the PhysX backend. +* Added the shared + :mod:`isaaclab_ovphysx.sensors.kernels` module with + :func:`~isaaclab_ovphysx.sensors.kernels.concat_pos_and_quat_to_pose_kernel` + and the 1D variant for reuse across future OVPhysX sensors. + +Changed +^^^^^^^ + +* Changed the existing + ``source/isaaclab_ovphysx/test/sensors/check_contact_sensor.py`` + stubs to real tests adapted from the PhysX + :mod:`isaaclab_physx.test.sensors.test_contact_sensor` suite. The + three tests that exercise ``track_contact_points`` or + ``track_friction_forces`` are decorated with + :func:`pytest.mark.skip` until the OVPhysX wheel ships + tensor-friendly per-sensor reads (see + ``docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md``); + the test bodies are preserved so the decorator can be removed in a + follow-up. + +Removed +^^^^^^^ + +* **Breaking:** Removed the five + ``source/isaaclab_ovphysx/test/sensors/check_contact_sensor.py`` + ``pytest.skip("Contact sensor not yet supported by ovphysx + backend.")`` placeholders in favour of the real test suite above. + No public migration is required; the placeholder names did not + appear in any external API. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 3b6f49e916f1..729b06befa95 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -245,6 +245,36 @@ def register_clone( """ cls._pending_clones.append((source, targets, parent_positions or [])) + _physx_schemas_registered: ClassVar[bool] = False + + @classmethod + def _ensure_physx_schemas_registered(cls) -> None: + """Register the ``PhysxSchema`` USD plugin shipped with the ovphysx wheel. + + In Kit-based runs ``omni.physx`` registers the schema; in kitless + runs it must be registered manually before the wheel can match + ``PhysxContactReportAPI`` and friends on the stage. The wheel + bundles the plugin under ``ovphysx/plugins/usd/PhysxSchema``. This + method is idempotent — :meth:`pxr.Plug.Registry.RegisterPlugins` + is a no-op once the plugin is registered. + """ + if cls._physx_schemas_registered: + return + try: + import os # noqa: PLC0415 + + import ovphysx # noqa: PLC0415 + + from pxr import Plug # noqa: PLC0415 + except Exception: + return + plugin_root = os.path.join(os.path.dirname(ovphysx.__file__), "plugins", "usd") + for sub in ("PhysxSchema/resources", "PhysxSchemaAddition/resources"): + path = os.path.join(plugin_root, sub) + if os.path.isdir(path): + Plug.Registry().RegisterPlugins(path) + cls._physx_schemas_registered = True + @classmethod def initialize(cls, sim_context: SimulationContext) -> None: """Initialize the physics manager with simulation context. @@ -262,6 +292,7 @@ def initialize(cls, sim_context: SimulationContext) -> None: instance is bound to. """ super().initialize(sim_context) + cls._ensure_physx_schemas_registered() cls._warmup_done = False cls._usd_handle = None cls._stage_path = None diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.py new file mode 100644 index 000000000000..f00f5832e6da --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__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 + +"""Sub-package for ovphysx-backed sensors.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.pyi b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.pyi new file mode 100644 index 000000000000..e00fc97cbbc6 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/__init__.pyi @@ -0,0 +1,12 @@ +# 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__ = [ + "ContactSensor", + "ContactSensorCfg", + "ContactSensorData", +] + +from .contact_sensor import ContactSensor, ContactSensorCfg, ContactSensorData diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/__init__.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/__init__.py new file mode 100644 index 000000000000..b07f8b8b1df0 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/__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 + +"""OVPhysX-backed contact sensor.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/__init__.pyi b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/__init__.pyi new file mode 100644 index 000000000000..fd936d53b0c0 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/__init__.pyi @@ -0,0 +1,14 @@ +# 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__ = [ + "ContactSensor", + "ContactSensorCfg", + "ContactSensorData", +] + +from .contact_sensor import ContactSensor +from .contact_sensor_cfg import ContactSensorCfg +from .contact_sensor_data import ContactSensorData diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor.py new file mode 100644 index 000000000000..898fbbeb4de6 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor.py @@ -0,0 +1,515 @@ +# 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 + +# Ignore optional memory usage warning globally +# pyright: reportOptionalSubscript=false + +from __future__ import annotations + +import contextlib +import logging +import re +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +import warp as wp + +import isaaclab.sim as sim_utils +from isaaclab.sensors.contact_sensor import BaseContactSensor +from isaaclab.utils.warp import ProxyArray + +import isaaclab_ovphysx.tensor_types as TT +from isaaclab_ovphysx.physics import OvPhysxManager + +from .contact_sensor_data import ContactSensorData +from .kernels import ( + compute_first_transition_kernel, + reset_contact_sensor_kernel, + split_flat_pose_to_pos_quat, + unpack_contact_buffer_data, # noqa: F401 -- reserved for v2 contact-points support + update_net_forces_ovphysx_kernel, +) + +if TYPE_CHECKING: + from .contact_sensor_cfg import ContactSensorCfg + +logger = logging.getLogger(__name__) + + +class ContactSensor(BaseContactSensor): + """An ovphysx contact reporting sensor. + + Reports normal contact forces in world frame using the ovphysx + :class:`ContactBinding` API. The `PhysxContactReportAPI` USD schema must + be applied to each sensor body (set + :attr:`isaaclab.sim.spawner.RigidObjectSpawnerCfg.activate_contact_sensors` + on the asset spawner). + + Optional features tracked by :attr:`ContactSensorCfg`: + + * ``track_pose`` — sensor body pose via a ``RIGID_BODY_POSE`` tensor binding. + * ``filter_prim_paths_expr`` — per-partner filtered forces via + :meth:`ContactBinding.read_force_matrix`. + * ``track_air_time`` — air/contact time tracking and + :meth:`compute_first_contact` / :meth:`compute_first_air`. + + The following config flags are not supported on the ovphysx backend yet + (the underlying ovphysx APIs do not expose tensor-friendly per-sensor + reads — see ``docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md``): + + * ``track_contact_points`` + * ``track_friction_forces`` + + Setting either flag raises :class:`NotImplementedError` at initialization. + """ + + cfg: ContactSensorCfg + """The configuration parameters.""" + + __backend_name__: str = "ovphysx" + """The name of the backend for the contact sensor.""" + + def __init__(self, cfg: ContactSensorCfg): + """Initializes the contact sensor object. + + Args: + cfg: The configuration parameters. + """ + super().__init__(cfg) + + # Reject the v1 unsupported optional features early, before USD discovery. + if cfg.track_contact_points or cfg.track_friction_forces: + raise NotImplementedError( + "ovphysx ContactSensor does not yet support 'track_contact_points' or 'track_friction_forces'." + " ovphysx 0.3.7 lacks tensor-friendly per-sensor read APIs for these features." + " See docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md for the maintainer asks." + ) + + self._data: ContactSensorData = ContactSensorData() + # Backend handles, populated in _initialize_impl. + self._physx_instance: Any = None + self._contact_binding: Any = None + self._pose_binding: Any = None + # Pre-allocated read buffers, populated in _create_buffers. + self._net_forces_flat_buf: wp.array | None = None + self._force_matrix_flat_buf: wp.array | None = None + self._poses_flat_buf: wp.array | None = None + # Body names (resolved during init). + self._body_names: list[str] = [] + # Default backend tunables matching the PhysX backend. + if self.cfg.max_contact_data_count_per_prim is None: + self.cfg.max_contact_data_count_per_prim = 4 + if self.cfg.force_threshold is None: + self.cfg.force_threshold = 1.0 + + def __str__(self) -> str: + """Returns: A string containing information about the instance.""" + return ( + f"Contact sensor @ '{self.cfg.prim_path}': \n" + f"\tbackend : ovphysx\n" + f"\tupdate period (s) : {self.cfg.update_period}\n" + f"\tnumber of bodies : {self.num_sensors}\n" + f"\tbody names : {self.body_names}\n" + ) + + """ + Properties + """ + + @property + def num_instances(self) -> int | None: + if self._contact_binding is None: + return None + return self._contact_binding.sensor_count + + @property + def data(self) -> ContactSensorData: + self._update_outdated_buffers() + return self._data + + @property + def num_sensors(self) -> int: + return self._num_sensors + + @property + def body_names(self) -> list[str]: + """The leaf-prim names of the sensor bodies. + + Raises: + RuntimeError: If accessed before the sensor has been initialized + (matches the eager non-``None`` contract PhysX provides). + """ + if not self._body_names: + raise RuntimeError( + "OvPhysxContactSensor.body_names accessed before initialization. " + "Step the simulation once (or wait for PhysicsEvent.PHYSICS_READY) so the " + "sensor can discover its bodies." + ) + return list(self._body_names) + + @property + def contact_view(self) -> Any: + """The underlying ovphysx :class:`ContactBinding` (or ``None`` before init). + + .. note:: + Use this view with caution. It owns native handles released at + simulation stop. + """ + return self._contact_binding + + @property + def pose_binding(self) -> Any: + """The underlying ovphysx ``RIGID_BODY_POSE`` :class:`TensorBinding`. + + ``None`` if ``cfg.track_pose`` is False or before initialization. + """ + return self._pose_binding + + """ + Implementation. + """ + + def _initialize_impl(self) -> None: + super()._initialize_impl() + + physx_instance = OvPhysxManager.get_physx_instance() + if physx_instance is None: + raise RuntimeError("OvPhysxManager has not been initialized yet.") + self._physx_instance = physx_instance + + # Discover sensor bodies. Mirror the PhysX discovery path but use + # ``GetPrimTypeInfo().GetAppliedAPISchemas()`` (raw apiSchemas listOp) + # rather than ``GetAppliedSchemas()`` (filtered by USD's plugin + # registry). Under the kitless ovphysx flow the ``PhysxSchema`` USD + # plugin is registered by :meth:`OvPhysxManager.initialize` so the + # wheel-side schema check passes, but the Python-side filtered API + # still hides ``PhysxContactReportAPI`` because the schema TYPE + # registration only happens when the C++ plugin library is loaded by + # ``omni.physx``. The unfiltered API matches what the underlying + # USD apiSchemas listOp actually carries (verified against + # :class:`pxr.Sdf.PrimSpec.GetInfo("apiSchemas")`). + leaf_pattern = self.cfg.prim_path.rsplit("/", 1)[-1] + template_prim_path = self._parent_prims[0].GetPath().pathString + body_names: list[str] = [] + for prim in sim_utils.find_matching_prims(template_prim_path + "/" + leaf_pattern): + if "PhysxContactReportAPI" in prim.GetPrimTypeInfo().GetAppliedAPISchemas(): + body_names.append(prim.GetPath().pathString.rsplit("/", 1)[-1]) + if not body_names: + raise RuntimeError( + f"Sensor at path '{self.cfg.prim_path}' could not find any bodies with contact reporter API." + "\nHINT: Make sure to enable 'activate_contact_sensors' in the corresponding asset spawn configuration." + ) + self._body_names = body_names + self._num_sensors = len(body_names) + + # Build glob patterns: one per (env, sensor body). + # IsaacLab path forms map to ovphysx fnmatch globs the same way Articulation does. + base_glob = self.cfg.prim_path.rsplit("/", 1)[0] + base_glob = re.sub(r"\{ENV_REGEX_NS\}", "*", base_glob) + base_glob = re.sub(r"\.\*", "*", base_glob) + sensor_patterns = [f"{base_glob}/{name}" for name in body_names] + + # Build filter patterns (flat: len = n_sensors * filters_per_sensor). + filter_globs = [ + re.sub(r"\.\*", "*", re.sub(r"\{ENV_REGEX_NS\}", "*", expr)) for expr in self.cfg.filter_prim_paths_expr + ] + filters_per_sensor = len(filter_globs) + if filters_per_sensor > 0: + filter_patterns: list[str] | None = filter_globs * self._num_sensors + else: + filter_patterns = None + + # Create the contact binding (must happen BEFORE the next step()). + # OVPhysX's ``InteractiveScene`` runs in ``clone_usd=False`` mode: + # env_1..N have no USD prim — they're physics-layer clones via + # ``physx.clone()``. The parent class's ``find_matching_prims`` walk + # therefore sees only env_0 and sets ``self._num_envs = 1`` even when + # the scene is configured for many envs. We size the + # ``max_contact_data_count`` for env_0 only here; the binding's + # ``sensor_count`` after creation gives us the real env count. + max_count = self.cfg.max_contact_data_count_per_prim * self._num_sensors * self._num_envs + self._contact_binding = physx_instance.create_contact_binding( + sensor_patterns=sensor_patterns, + filter_patterns=filter_patterns, + filters_per_sensor=filters_per_sensor, + max_contact_data_count=max_count, + ) + + # Validate: sensor_count must be a non-zero multiple of num_sensors. + if self._contact_binding.sensor_count == 0 or self._contact_binding.sensor_count % self._num_sensors != 0: + raise RuntimeError( + "Failed to initialize contact binding for specified bodies." + f"\n\tInput prim path : {self.cfg.prim_path}" + f"\n\tNum sensor bodies : {self._num_sensors}" + f"\n\tBound sensors : {self._contact_binding.sensor_count}" + ) + + # Override ``_num_envs`` with the binding's view if it differs (it does + # for any OVPhysX scene with ``num_envs > 1`` due to ``clone_usd=False``). + # Re-allocate the env-sized buffers from the parent class so they match + # the real env count. + binding_num_envs = self._contact_binding.sensor_count // self._num_sensors + if binding_num_envs != self._num_envs: + self._num_envs = binding_num_envs + self._ALL_ENV_MASK = wp.ones((self._num_envs,), dtype=wp.bool, device=self._device) + self._reset_mask = wp.zeros((self._num_envs,), dtype=wp.bool, device=self._device) + self._reset_mask_torch = wp.to_torch(self._reset_mask) + self._is_outdated = wp.ones(self._num_envs, dtype=wp.bool, device=self._device) + self._timestamp = wp.zeros(self._num_envs, dtype=wp.float32, device=self._device) + self._timestamp_last_update = wp.zeros_like(self._timestamp) + + # Optional: pose tracking via a RIGID_BODY_POSE tensor binding. + # ovphysx fnmatch does not brace-expand, so we cannot match multiple + # body names with a single glob. Single-body sensors (the common case + # — one prim path per sensor) use a tight per-body pattern. Multi-body + # sensors are rejected here; they need per-body bindings + an + # interleaved-read kernel that does not exist yet. + if self.cfg.track_pose: + if self._num_sensors != 1: + raise NotImplementedError( + "ovphysx ContactSensor.track_pose is not yet supported for sensors that " + f"resolve to more than one body per env (got {self._num_sensors} bodies " + f"under '{self.cfg.prim_path}'). Workaround: create one ContactSensor " + "per body." + ) + single_pose_pattern = f"{base_glob}/{body_names[0]}" + self._pose_binding = physx_instance.create_tensor_binding( + pattern=single_pose_pattern, + tensor_type=TT.RIGID_BODY_POSE, + ) + if self._pose_binding.count != self._contact_binding.sensor_count: + raise RuntimeError( + "RIGID_BODY_POSE binding count mismatch." + f"\n\tPattern: {single_pose_pattern}" + f"\n\tBound : {self._pose_binding.count}" + f"\n\tExpect : {self._contact_binding.sensor_count}" + ) + + self._create_buffers() + + def _create_buffers(self) -> None: + """Allocate Warp buffers, including the pre-allocated ovphysx read tensors.""" + self._num_filter_shapes = self._contact_binding.filter_count if self.cfg.filter_prim_paths_expr else 0 + self._history_length = max(self.cfg.history_length, 1) + + # Sensor data buffers (delegated to the data container). + self._data.create_buffers( + num_envs=self._num_envs, + num_sensors=self._num_sensors, + num_filter_shapes=self._num_filter_shapes, + history_length=self.cfg.history_length, + track_pose=self.cfg.track_pose, + track_air_time=self.cfg.track_air_time, + track_contact_points=self.cfg.track_contact_points, + track_friction_forces=self.cfg.track_friction_forces, + device=self._device, + ) + + # ovphysx ContactBinding writes into pre-allocated tensors. We allocate + # them once here and reuse every step. Shape: [S, 3] for net forces, + # [S, F, 3] for the force matrix (S = num_envs * num_sensors). + flat_count = self._num_envs * self._num_sensors + self._net_forces_flat_buf = wp.zeros((flat_count, 3), dtype=wp.float32, device=self._device) + if self._num_filter_shapes > 0: + self._force_matrix_flat_buf = wp.zeros( + (flat_count, self._num_filter_shapes, 3), + dtype=wp.float32, + device=self._device, + ) + else: + self._force_matrix_flat_buf = None + + # Pose buffer: [S, 7] for RIGID_BODY_POSE (px,py,pz,qx,qy,qz,qw). + if self.cfg.track_pose: + self._poses_flat_buf = wp.zeros((flat_count, 7), dtype=wp.float32, device=self._device) + else: + self._poses_flat_buf = None + + def _update_buffers_impl(self, env_mask: wp.array | None = None) -> None: + """Read contact data from ovphysx and update sensor buffers.""" + env_mask = self._resolve_indices_and_mask(None, env_mask) + + # Pull aggregate forces into the pre-allocated flat buffer: + # shape [num_envs * num_sensors, 3] float32 -> [num_envs * num_sensors] vec3f. + self._contact_binding.read_net_forces(self._net_forces_flat_buf) + net_forces_flat = self._net_forces_flat_buf.view(wp.vec3f) + + if self._force_matrix_flat_buf is not None: + self._contact_binding.read_force_matrix(self._force_matrix_flat_buf) + force_matrix_flat = self._force_matrix_flat_buf.view(wp.vec3f) + else: + force_matrix_flat = None + + wp.launch( + update_net_forces_ovphysx_kernel, + dim=(self._num_envs, self._num_sensors), + inputs=[ + net_forces_flat, + force_matrix_flat, + env_mask, + self._num_envs, + self._num_sensors, + self._num_filter_shapes, + self._history_length, + self.cfg.force_threshold, + self._timestamp, + self._timestamp_last_update, + ], + outputs=[ + self._data._net_forces_w, + self._data._net_forces_w_history, + self._data._force_matrix_w, + self._data._force_matrix_w_history, + self._data._current_air_time, + self._data._current_contact_time, + self._data._last_air_time, + self._data._last_contact_time, + ], + device=self._device, + ) + + if self.cfg.track_pose: + # Read pose into [num_envs * num_sensors, 7] float32 -> view as transformf. + self._pose_binding.read(self._poses_flat_buf) + poses_flat = self._poses_flat_buf.view(wp.transformf) + wp.launch( + split_flat_pose_to_pos_quat, + dim=(self._num_envs, self._num_sensors), + inputs=[poses_flat, env_mask, self._num_sensors], + outputs=[self._data._pos_w, self._data._quat_w], + device=self._device, + ) + + """ + Operations + """ + + def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None) -> None: + env_mask = self._resolve_indices_and_mask(env_ids, env_mask) + super().reset(None, env_mask) + + wp.launch( + reset_contact_sensor_kernel, + dim=(self._num_envs, self._num_sensors), + inputs=[ + self._history_length, + self._num_filter_shapes, + env_mask, + self._data._net_forces_w, + self._data._net_forces_w_history, + self._data._force_matrix_w, + ], + outputs=[ + self._data._current_air_time, + self._data._last_air_time, + self._data._current_contact_time, + self._data._last_contact_time, + self._data._friction_forces_w, + self._data._contact_pos_w, + ], + device=self._device, + ) + + def compute_first_contact(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArray: + """Boolean mask (as float) of bodies that established contact within ``dt`` [s]. + + Args: + dt: Time window since contact establishment [s]. + abs_tol: Absolute tolerance for the comparison [s]. + + Returns: + Boolean tensor (1.0/0.0) of shape ``(num_envs, num_sensors)``. + + Raises: + RuntimeError: If :attr:`ContactSensorCfg.track_air_time` is False. + """ + if not self.cfg.track_air_time: + raise RuntimeError( + "The contact sensor is not configured to track contact time." + " Please enable 'track_air_time' in the sensor configuration." + ) + wp.launch( + compute_first_transition_kernel, + dim=(self._num_envs, self._num_sensors), + inputs=[float(dt + abs_tol), self._data._current_contact_time], + outputs=[self._data._first_transition], + device=self._device, + ) + return self._data._first_transition_ta + + def compute_first_air(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArray: + """Boolean mask (as float) of bodies that broke contact within ``dt`` [s]. + + Args: + dt: Time window since contact break [s]. + abs_tol: Absolute tolerance for the comparison [s]. + + Returns: + Boolean tensor (1.0/0.0) of shape ``(num_envs, num_sensors)``. + + Raises: + RuntimeError: If :attr:`ContactSensorCfg.track_air_time` is False. + """ + if not self.cfg.track_air_time: + raise RuntimeError( + "The contact sensor is not configured to track air time." + " Please enable 'track_air_time' in the sensor configuration." + ) + wp.launch( + compute_first_transition_kernel, + dim=(self._num_envs, self._num_sensors), + inputs=[float(dt + abs_tol), self._data._current_air_time], + outputs=[self._data._first_transition], + device=self._device, + ) + return self._data._first_transition_ta + + """ + Debug visualization + """ + + def _set_debug_vis_impl(self, debug_vis: bool) -> None: + """Toggle contact-marker visibility. + + The kitless OVPhysX flow has no Kit-based renderer, so visualization + markers are effectively invisible. The hook is still wired so that + callers setting ``cfg.debug_vis=True`` get an explicit warning rather + than silent no-op behaviour. + """ + if debug_vis and not getattr(self, "_warned_debug_vis_unavailable", False): + logger.warning( + "OVPhysX ContactSensor: debug visualization markers are not rendered under the " + "kitless OVPhysX flow (no Kit renderer present). The hook runs but marker " + "geometry will not appear." + ) + self._warned_debug_vis_unavailable = True + + def _debug_vis_callback(self, event) -> None: + """Per-frame visualization update. + + Under kitless OVPhysX this is a no-op -- there is no renderer driving + the per-frame marker positions. The method exists so the base + sensor's debug-vis lifecycle hooks have a callable target. + """ + return + + """ + Internal simulation callbacks. + """ + + def _invalidate_initialize_callback(self, event) -> None: + """Release native handles when the simulation stops.""" + super()._invalidate_initialize_callback(event) + # Drop strong references; ovphysx native handles are torn down on the + # next reset() of OvPhysxManager. + if self._contact_binding is not None: + with contextlib.suppress(Exception): + self._contact_binding.destroy() + self._contact_binding = None + if self._pose_binding is not None: + with contextlib.suppress(Exception): + self._pose_binding.destroy() + self._pose_binding = None + self._physx_instance = None diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor_cfg.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor_cfg.py new file mode 100644 index 000000000000..f874b80ddae0 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor_cfg.py @@ -0,0 +1,19 @@ +# 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 typing import TYPE_CHECKING + +from isaaclab.sensors.contact_sensor.contact_sensor_cfg import ContactSensorCfg as _BaseContactSensorCfg +from isaaclab.utils.configclass import configclass + +if TYPE_CHECKING: + from .contact_sensor import ContactSensor + + +@configclass +class ContactSensorCfg(_BaseContactSensorCfg): + """OVPhysX contact sensor configuration.""" + + class_type: type["ContactSensor"] | str = "{DIR}.contact_sensor:ContactSensor" diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor_data.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor_data.py new file mode 100644 index 000000000000..0a74760faae7 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor_data.py @@ -0,0 +1,318 @@ +# 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 math + +import warp as wp + +from isaaclab.sensors.contact_sensor import BaseContactSensorData +from isaaclab.utils.warp import ProxyArray + +from isaaclab_ovphysx.sensors.kernels import concat_pos_and_quat_to_pose_kernel + +logger = logging.getLogger(__name__) + + +class ContactSensorData(BaseContactSensorData): + """Data container for the ovphysx contact reporting sensor.""" + + @property + def pose_w(self) -> ProxyArray | None: + """Pose of the sensor origin in world frame. + + None if :attr:`ContactSensorCfg.track_pose` is False. + """ + if self._pose_w is None: + return None + wp.launch( + concat_pos_and_quat_to_pose_kernel, + dim=(self._num_envs, self._num_sensors), + inputs=[self._pos_w, self._quat_w], + outputs=[self._pose_w], + device=self._device, + ) + if self._pose_w_ta is None: + self._pose_w_ta = ProxyArray(self._pose_w) + return self._pose_w_ta + + @property + def pos_w(self) -> ProxyArray | None: + """Position of the sensor origin in world frame. + + Shape is (num_instances, num_sensors), dtype = wp.vec3f. In torch this resolves to + (num_instances, num_sensors, 3). + + None if :attr:`ContactSensorCfg.track_pose` is False. + """ + if self._pos_w is None: + return None + if self._pos_w_ta is None: + self._pos_w_ta = ProxyArray(self._pos_w) + return self._pos_w_ta + + @property + def quat_w(self) -> ProxyArray | None: + """Orientation of the sensor origin in world frame. + + Shape is (num_instances, num_sensors), dtype = wp.quatf. In torch this resolves to + (num_instances, num_sensors, 4). The orientation is provided in (x, y, z, w) format. + + None if :attr:`ContactSensorCfg.track_pose` is False. + """ + if self._quat_w is None: + return None + if self._quat_w_ta is None: + self._quat_w_ta = ProxyArray(self._quat_w) + return self._quat_w_ta + + @property + def net_forces_w(self) -> ProxyArray | None: + """The net normal contact forces in world frame. + + Shape is (num_instances, num_sensors), dtype = wp.vec3f. In torch this resolves to + (num_instances, num_sensors, 3). + """ + if self._net_forces_w is None: + return None + if self._net_forces_w_ta is None: + self._net_forces_w_ta = ProxyArray(self._net_forces_w) + return self._net_forces_w_ta + + @property + def net_forces_w_history(self) -> ProxyArray | None: + """History of net normal contact forces. + + Shape is (num_instances, history_length, num_sensors), dtype = wp.vec3f. In torch this resolves to + (num_instances, history_length, num_sensors, 3). + """ + if self._net_forces_w_history is None: + return None + if self._net_forces_w_history_ta is None: + self._net_forces_w_history_ta = ProxyArray(self._net_forces_w_history) + return self._net_forces_w_history_ta + + @property + def force_matrix_w(self) -> ProxyArray | None: + """Normal contact forces filtered between sensor and filtered bodies. + + Shape is (num_instances, num_sensors, num_filter_shapes), dtype = wp.vec3f. In torch this resolves to + (num_instances, num_sensors, num_filter_shapes, 3). + + None if :attr:`ContactSensorCfg.filter_prim_paths_expr` is empty. + """ + if self._force_matrix_w is None: + return None + if self._force_matrix_w_ta is None: + self._force_matrix_w_ta = ProxyArray(self._force_matrix_w) + return self._force_matrix_w_ta + + @property + def force_matrix_w_history(self) -> ProxyArray | None: + """History of filtered contact forces. + + Shape is (num_instances, history_length, num_sensors, num_filter_shapes), dtype = wp.vec3f. + In torch this resolves to (num_instances, history_length, num_sensors, num_filter_shapes, 3). + + None if :attr:`ContactSensorCfg.filter_prim_paths_expr` is empty. + """ + if self._force_matrix_w_history is None: + return None + if self._force_matrix_w_history_ta is None: + self._force_matrix_w_history_ta = ProxyArray(self._force_matrix_w_history) + return self._force_matrix_w_history_ta + + @property + def contact_pos_w(self) -> ProxyArray | None: + """Average position of contact points. + + Shape is (num_instances, num_sensors, num_filter_shapes), dtype = wp.vec3f. In torch this resolves to + (num_instances, num_sensors, num_filter_shapes, 3). + + None if :attr:`ContactSensorCfg.track_contact_points` is False. + """ + if self._contact_pos_w is None: + return None + if self._contact_pos_w_ta is None: + self._contact_pos_w_ta = ProxyArray(self._contact_pos_w) + return self._contact_pos_w_ta + + @property + def friction_forces_w(self) -> ProxyArray | None: + """Sum of friction forces. + + Shape is (num_instances, num_sensors, num_filter_shapes), dtype = wp.vec3f. In torch this resolves to + (num_instances, num_sensors, num_filter_shapes, 3). + + None if :attr:`ContactSensorCfg.track_friction_forces` is False. + """ + if self._friction_forces_w is None: + return None + if self._friction_forces_w_ta is None: + self._friction_forces_w_ta = ProxyArray(self._friction_forces_w) + return self._friction_forces_w_ta + + @property + def last_air_time(self) -> ProxyArray | None: + """Time spent in air before last contact. + + Shape is (num_instances, num_sensors), dtype = wp.float32. + + None if :attr:`ContactSensorCfg.track_air_time` is False. + """ + if self._last_air_time is None: + return None + if self._last_air_time_ta is None: + self._last_air_time_ta = ProxyArray(self._last_air_time) + return self._last_air_time_ta + + @property + def current_air_time(self) -> ProxyArray | None: + """Time spent in air since last detach. + + Shape is (num_instances, num_sensors), dtype = wp.float32. + + None if :attr:`ContactSensorCfg.track_air_time` is False. + """ + if self._current_air_time is None: + return None + if self._current_air_time_ta is None: + self._current_air_time_ta = ProxyArray(self._current_air_time) + return self._current_air_time_ta + + @property + def last_contact_time(self) -> ProxyArray | None: + """Time spent in contact before last detach. + + Shape is (num_instances, num_sensors), dtype = wp.float32. + + None if :attr:`ContactSensorCfg.track_air_time` is False. + """ + if self._last_contact_time is None: + return None + if self._last_contact_time_ta is None: + self._last_contact_time_ta = ProxyArray(self._last_contact_time) + return self._last_contact_time_ta + + @property + def current_contact_time(self) -> ProxyArray | None: + """Time spent in contact since last contact. + + Shape is (num_instances, num_sensors), dtype = wp.float32. + + None if :attr:`ContactSensorCfg.track_air_time` is False. + """ + if self._current_contact_time is None: + return None + if self._current_contact_time_ta is None: + self._current_contact_time_ta = ProxyArray(self._current_contact_time) + return self._current_contact_time_ta + + def create_buffers( + self, + num_envs: int, + num_sensors: int, + num_filter_shapes: int, + history_length: int, + track_pose: bool, + track_air_time: bool, + track_contact_points: bool, + track_friction_forces: bool, + device: str, + ) -> None: + """Create internal buffers for sensor data. + + Args: + num_envs: Number of environments. + num_sensors: Number of sensors per environment. + num_filter_shapes: Number of filtered shapes for force matrix. + history_length: Length of force history buffer. + track_pose: Whether to track sensor pose. + track_air_time: Whether to track air/contact time. + track_contact_points: Whether to track contact points. + track_friction_forces: Whether to track friction forces. + device: Device for tensor storage. + """ + self._num_envs = num_envs + self._num_sensors = num_sensors + self._device = device + # Ensure history_length >= 1 for consistent buffer shapes + effective_history = max(history_length, 1) + + # Net forces (always tracked) + self._net_forces_w = wp.zeros((num_envs, num_sensors), dtype=wp.vec3f, device=device) + self._net_forces_w_history = wp.zeros((num_envs, effective_history, num_sensors), dtype=wp.vec3f, device=device) + + # Track force matrix if requested - only with filter + if num_filter_shapes > 0: + self._force_matrix_w = wp.zeros((num_envs, num_sensors, num_filter_shapes), dtype=wp.vec3f, device=device) + self._force_matrix_w_history = wp.zeros( + (num_envs, effective_history, num_sensors, num_filter_shapes), dtype=wp.vec3f, device=device + ) + else: + self._force_matrix_w = None + self._force_matrix_w_history = None + + # Track pose if requested + if track_pose: + self._pos_w = wp.zeros((num_envs, num_sensors), dtype=wp.vec3f, device=device) + self._quat_w = wp.zeros((num_envs, num_sensors), dtype=wp.quatf, device=device) + self._pose_w = wp.zeros((num_envs, num_sensors), dtype=wp.transformf, device=device) + else: + self._pos_w = None + self._quat_w = None + self._pose_w = None + + # Track air time if requested + if track_air_time: + self._last_air_time = wp.zeros((num_envs, num_sensors), dtype=wp.float32, device=device) + self._current_air_time = wp.zeros((num_envs, num_sensors), dtype=wp.float32, device=device) + self._last_contact_time = wp.zeros((num_envs, num_sensors), dtype=wp.float32, device=device) + self._current_contact_time = wp.zeros((num_envs, num_sensors), dtype=wp.float32, device=device) + self._first_transition = wp.zeros((num_envs, num_sensors), dtype=wp.float32, device=device) + self._first_transition_ta = ProxyArray(self._first_transition) + else: + self._last_air_time = None + self._current_air_time = None + self._last_contact_time = None + self._current_contact_time = None + self._first_transition = None + self._first_transition_ta = None + + # Track contact points if requested - filled with NaN + if track_contact_points: + self._contact_pos_w = wp.full( + (num_envs, num_sensors, num_filter_shapes), + dtype=wp.vec3f, + device=device, + value=wp.vec3f(math.nan, math.nan, math.nan), + ) + else: + self._contact_pos_w = None + + # Track friction forces if requested + if track_friction_forces: + self._friction_forces_w = wp.zeros( + (num_envs, num_sensors, num_filter_shapes), dtype=wp.vec3f, device=device + ) + else: + self._friction_forces_w = None + + # -- Pinned ProxyArray cache (one per read property, lazily created on first access) + self._pose_w_ta: ProxyArray | None = None + self._pos_w_ta: ProxyArray | None = None + self._quat_w_ta: ProxyArray | None = None + self._net_forces_w_ta: ProxyArray | None = None + self._net_forces_w_history_ta: ProxyArray | None = None + self._force_matrix_w_ta: ProxyArray | None = None + self._force_matrix_w_history_ta: ProxyArray | None = None + self._contact_pos_w_ta: ProxyArray | None = None + self._friction_forces_w_ta: ProxyArray | None = None + self._last_air_time_ta: ProxyArray | None = None + self._current_air_time_ta: ProxyArray | None = None + self._last_contact_time_ta: ProxyArray | None = None + self._current_contact_time_ta: ProxyArray | None = None diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/kernels.py new file mode 100644 index 000000000000..437355dc470d --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/kernels.py @@ -0,0 +1,300 @@ +# 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 ovphysx contact sensor.""" + +import warp as wp + +# ---- Copy kernels (flat PhysX view -> structured data buffers) ---- + + +@wp.kernel +def split_flat_pose_to_pos_quat( + src: wp.array(dtype=wp.transformf), + mask: wp.array(dtype=wp.bool), + num_bodies: wp.int32, + dst_pos: wp.array2d(dtype=wp.vec3f), + dst_quat: wp.array2d(dtype=wp.quatf), +): + """Split flat (N*B,) transformf into (N, B) vec3f pos and (N, B) quatf quat. + + Args: + src: Flat source array of transforms from PhysX view. Shape is (N*B,). + mask: Boolean mask for which environments to update. Shape is (N,). + num_bodies: Number of bodies per environment. + dst_pos: Destination position buffer. Shape is (N, B). + dst_quat: Destination quaternion buffer. Shape is (N, B). + """ + env, sensor = wp.tid() + if mask: + if not mask[env]: + return + + src_idx = env * num_bodies + sensor + dst_pos[env, sensor] = wp.transform_get_translation(src[src_idx]) + dst_quat[env, sensor] = wp.transform_get_rotation(src[src_idx]) + + +# ---- Unpack contact buffer data kernel ---- + + +@wp.kernel +def unpack_contact_buffer_data( + contact_data: wp.array(dtype=wp.vec3f), + buffer_count: wp.array2d(dtype=wp.uint32), + buffer_start_indices: wp.array2d(dtype=wp.uint32), + mask: wp.array(dtype=wp.bool), + num_bodies: wp.int32, + avg: bool, + default_val: wp.float32, + dst: wp.array3d(dtype=wp.vec3f), +): + """Unpack and aggregate contact buffer data for each (env, body, filter) group. + + Each thread handles one (body, filter) pair for one environment. It reads + `count` contact entries starting at `start_index` and either averages or + sums them. + + Args: + contact_data: Flat buffer of contact data. Shape is (total_contacts,) vec3f. + buffer_count: Count of contacts per (env*body, filter). Shape is (N*B, M) uint32. + buffer_start_indices: Start indices per (env*body, filter). Shape is (N*B, M) uint32. + mask: Boolean mask for which environments to update. Shape is (N,). + num_bodies: Number of bodies per environment. + avg: If True, average the data; if False, sum it. + default_val: Default value for groups with zero contacts (e.g. NaN or 0.0). + dst: Destination buffer. Shape is (N, B, M). + """ + env, sensor, contact = wp.tid() + if mask: + if not mask[env]: + return + + flat_idx = env * num_bodies + sensor + count = wp.int32(buffer_count[flat_idx, contact]) + start = wp.int32(buffer_start_indices[flat_idx, contact]) + + if count > 0: + accum = wp.vec3f(0.0, 0.0, 0.0) + for c in range(count): + accum = accum + contact_data[start + c] + if avg: + accum = accum / wp.float32(count) + dst[env, sensor, contact] = accum + else: + dst[env, sensor, contact] = wp.vec3f(default_val, default_val, default_val) + + +@wp.kernel +def reset_contact_sensor_kernel( + # in + history_length: int, + num_filter_objects: int, + env_mask: wp.array(dtype=wp.bool), + # in-out + net_forces_w: wp.array2d(dtype=wp.vec3f), + net_forces_w_history: wp.array3d(dtype=wp.vec3f), + force_matrix_w: wp.array3d(dtype=wp.vec3f), + # outputs + current_air_time: wp.array2d(dtype=wp.float32), + last_air_time: wp.array2d(dtype=wp.float32), + current_contact_time: wp.array2d(dtype=wp.float32), + last_contact_time: wp.array2d(dtype=wp.float32), + friction_forces_w: wp.array3d(dtype=wp.vec3f), + contact_pos_w: wp.array3d(dtype=wp.vec3f), +): + """Reset the contact sensor data for specified environments. + + Launch with dim=(num_envs, num_sensors). + + Args: + history_length: Length of history. + num_filter_objects: Number of filter objects. + env_mask: Mask array. Shape is (num_envs,). + net_forces_w: Net forces array. Shape is (num_envs, num_sensors). + net_forces_w_history: Net forces history array. Shape is (num_envs, history_length, num_sensors). + force_matrix_w: Force matrix array. Shape is (num_envs, num_sensors, num_filter_objects). + current_air_time: Current air time array. Shape is (num_envs, num_sensors). + last_air_time: Last air time array. Shape is (num_envs, num_sensors). + current_contact_time: Current contact time array. Shape is (num_envs, num_sensors). + last_contact_time: Last contact time array. Shape is (num_envs, num_sensors). + friction_forces_w: Friction forces array. Shape is (num_envs, num_sensors, num_filter_objects). + contact_pos_w: Contact pos array. Shape is (num_envs, num_sensors, num_filter_objects). + """ + env, sensor = wp.tid() + + if env_mask: + if not env_mask[env]: + return + + # Reset net forces + net_forces_w[env, sensor] = wp.vec3f(0.0) + + # Reset history + if net_forces_w_history: + for i in range(history_length): + net_forces_w_history[env, i, sensor] = wp.vec3f(0.0) + + # Reset force matrix (guard for None case) + if force_matrix_w: + for f in range(num_filter_objects): + force_matrix_w[env, sensor, f] = wp.vec3f(0.0) + + # Reset air/contact time tracking + if current_air_time: + current_air_time[env, sensor] = 0.0 + last_air_time[env, sensor] = 0.0 + current_contact_time[env, sensor] = 0.0 + last_contact_time[env, sensor] = 0.0 + + if friction_forces_w: + for f in range(num_filter_objects): + friction_forces_w[env, sensor, f] = wp.vec3f(0.0) + + if contact_pos_w: + for f in range(num_filter_objects): + contact_pos_w[env, sensor, f] = wp.vec3f(0.0) + + +@wp.kernel +def compute_first_transition_kernel( + # in + threshold: wp.float32, + time: wp.array2d(dtype=wp.float32), + # out + result: wp.array2d(dtype=wp.float32), +): + """Compute boolean mask (as float) for sensors whose time is in (0, threshold). + + Used by both compute_first_contact (with current_contact_time) and + compute_first_air (with current_air_time). + + Launch with dim=(num_envs, num_sensors). + + Args: + threshold: Threshold for the time. + time: Time array. Shape is (num_envs, num_sensors). + result: Result array. Shape is (num_envs, num_sensors). + """ + env, sensor = wp.tid() + t = time[env, sensor] + if t > 0.0 and t < threshold: + result[env, sensor] = 1.0 + else: + result[env, sensor] = 0.0 + + +@wp.kernel +def update_net_forces_ovphysx_kernel( + # in + net_forces_flat: wp.array(dtype=wp.vec3f), + net_forces_matrix_flat: wp.array2d(dtype=wp.vec3f), + mask: wp.array(dtype=wp.bool), + num_envs: int, + num_sensors: int, + num_filter_shapes: int, + history_length: int, + contact_force_threshold: wp.float32, + timestamp: wp.array(dtype=wp.float32), + timestamp_last_update: wp.array(dtype=wp.float32), + # out + net_forces_w: wp.array2d(dtype=wp.vec3f), + net_forces_w_history: wp.array3d(dtype=wp.vec3f), + force_matrix_w: wp.array3d(dtype=wp.vec3f), + force_matrix_w_history: wp.array4d(dtype=wp.vec3f), + current_air_time: wp.array2d(dtype=wp.float32), + current_contact_time: wp.array2d(dtype=wp.float32), + last_air_time: wp.array2d(dtype=wp.float32), + last_contact_time: wp.array2d(dtype=wp.float32), +): + """Update the net forces, force matrix and air/contact time for each (env, sensor) pair. + + Launch with dim=(num_envs, num_sensors). + + The OVPhysX :class:`ContactBinding` returns sensors in **pattern-major** + order — the flat buffer index for ``(env, sensor)`` is + ``sensor * num_envs + env``, not the PhysX env-major + ``env * num_sensors + sensor``. We pass ``num_envs`` so the kernel can + compute the right index. + + Args: + net_forces_flat: Flat net forces. Shape is (num_sensors*num_envs,) in pattern-major order. + net_forces_matrix_flat: Flat force matrix. Shape is (num_sensors*num_envs, num_filter_shapes). + mask: Mask array. Shape is (num_envs,). + num_envs: Number of environments. + num_sensors: Number of sensors per environment. + num_filter_shapes: Number of filter shapes. + history_length: Length of history. + contact_force_threshold: Threshold for the contact force. + timestamp: Timestamp array. Shape is (num_envs,). + timestamp_last_update: Timestamp last update array. Shape is (num_envs,). + net_forces_w: Net forces array. Shape is (num_envs, num_sensors). + net_forces_w_history: Net forces history array. Shape is (num_envs, history_length, num_sensors). + force_matrix_w: Force matrix array. Shape is (num_envs, num_sensors, num_filter_shapes). + force_matrix_w_history: Force matrix history array. Shape is + (num_envs, history_length, num_sensors, num_filter_shapes). + current_air_time: Current air time array. Shape is (num_envs, num_sensors). + current_contact_time: Current contact time array. Shape is (num_envs, num_sensors). + last_air_time: Last air time array. Shape is (num_envs, num_sensors). + last_contact_time: Last contact time array. Shape is (num_envs, num_sensors). + """ + env, sensor = wp.tid() + + if mask: + if not mask[env]: + return + + src_idx = sensor * num_envs + env + + # Update net forces + net_forces_w[env, sensor] = net_forces_flat[src_idx] + # Update history + if net_forces_w_history: + for i in range(history_length - 1, 0, -1): + net_forces_w_history[env, i, sensor] = net_forces_w_history[env, i - 1, sensor] + net_forces_w_history[env, 0, sensor] = net_forces_w[env, sensor] + + # update force matrix + if net_forces_matrix_flat: + for f in range(num_filter_shapes): + force_matrix_w[env, sensor, f] = net_forces_matrix_flat[src_idx, f] + for i in range(history_length - 1, 0, -1): + force_matrix_w_history[env, i, sensor, f] = force_matrix_w_history[env, i - 1, sensor, f] + force_matrix_w_history[env, 0, sensor, f] = force_matrix_w[env, sensor, f] + + # Update air/contact time tracking + if current_air_time: + elapsed_time = timestamp[env] - timestamp_last_update[env] + in_contact = wp.length_sq(net_forces_w[env, sensor]) > contact_force_threshold * contact_force_threshold + + cat = current_air_time[env, sensor] + cct = current_contact_time[env, sensor] + is_first_contact = in_contact and (cat > 0.0) + is_first_detached = not in_contact and (cct > 0.0) + + if is_first_contact: + last_air_time[env, sensor] = cat + elapsed_time + elif is_first_detached: + last_contact_time[env, sensor] = cct + elapsed_time + + current_contact_time[env, sensor] = wp.where(in_contact, cct + elapsed_time, 0.0) + current_air_time[env, sensor] = wp.where(in_contact, 0.0, cat + elapsed_time) + + +@wp.kernel +def concat_pos_and_quat_to_pose_kernel( + pos: wp.array2d(dtype=wp.vec3f), + quat: wp.array2d(dtype=wp.quatf), + pose: wp.array2d(dtype=wp.transformf), +): + """Concatenate position and quaternion to pose. + + Args: + pos: Position array. Shape is (N, B). + quat: Quaternion array. Shape is (N, B). + pose: Pose array. Shape is (N, B). + """ + env, sensor = wp.tid() + pose[env, sensor] = wp.transform(pos[env, sensor], quat[env, sensor]) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/kernels.py new file mode 100644 index 000000000000..197cf2462601 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/kernels.py @@ -0,0 +1,42 @@ +# 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 warp kernels for ovphysx sensors.""" + +import warp as wp + + +@wp.kernel +def concat_pos_and_quat_to_pose_kernel( + pos: wp.array2d(dtype=wp.vec3f), + quat: wp.array2d(dtype=wp.quatf), + pose: wp.array2d(dtype=wp.transformf), +): + """Concatenate 2D position and quaternion arrays to pose. + + Args: + pos: Position array. Shape is (N, B). + quat: Quaternion array. Shape is (N, B). + pose: Pose array. Shape is (N, B). + """ + env, sensor = wp.tid() + pose[env, sensor] = wp.transform(pos[env, sensor], quat[env, sensor]) + + +@wp.kernel +def concat_pos_and_quat_to_pose_1d_kernel( + pos: wp.array(dtype=wp.vec3f), + quat: wp.array(dtype=wp.quatf), + pose: wp.array(dtype=wp.transformf), +): + """Concatenate 1D position and quaternion arrays to pose. + + Args: + pos: Position array. Shape is (N,). + quat: Quaternion array. Shape is (N,). + pose: Pose array. Shape is (N,). + """ + env = wp.tid() + pose[env] = wp.transform(pos[env], quat[env]) diff --git a/source/isaaclab_ovphysx/test/sensors/check_contact_sensor.py b/source/isaaclab_ovphysx/test/sensors/check_contact_sensor.py deleted file mode 100644 index 8bcd9f0941f6..000000000000 --- a/source/isaaclab_ovphysx/test/sensors/check_contact_sensor.py +++ /dev/null @@ -1,38 +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 - -"""Contact sensor parity tests for the ovphysx backend. - -Mirrors the structure of isaaclab_physx/test/sensors/check_contact_sensor.py. -Contact sensors are not yet supported by the ovphysx backend, so all tests -are skipped with an explanatory message. -""" - -import pytest - - -def test_contact_sensor_creation(): - """Verify contact sensor can be created on the ovphysx backend.""" - pytest.skip("Contact sensor not yet supported by ovphysx backend.") - - -def test_contact_sensor_data_reading(): - """Verify contact sensor data can be read after a simulation step.""" - pytest.skip("Contact sensor not yet supported by ovphysx backend.") - - -def test_contact_sensor_reset(): - """Verify contact sensor state resets correctly.""" - pytest.skip("Contact sensor not yet supported by ovphysx backend.") - - -def test_contact_sensor_air_time_tracking(): - """Verify contact sensor air time tracking.""" - pytest.skip("Contact sensor not yet supported by ovphysx backend.") - - -def test_contact_sensor_friction_forces(): - """Verify contact sensor friction force reporting.""" - pytest.skip("Contact sensor not yet supported by ovphysx backend.") diff --git a/source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py b/source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py new file mode 100644 index 000000000000..38b3bd9e579d --- /dev/null +++ b/source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py @@ -0,0 +1,1004 @@ +# 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 + +# ignore private usage of variables warning +# pyright: reportPrivateUsage=none + +"""Real-backend tests for the OVPhysX ContactSensor. + +Run via ``./isaaclab.sh -p -m pytest``; the ovphysx wheel is now invocable +through the standard Kit Python entrypoint, so the older kitless +``./scripts/run_ovphysx.sh`` wrapper is no longer required. + +``ovphysx<=0.3.7`` binds device mode (CPU vs GPU) at the C++ layer on the +first ``ovphysx.PhysX(device=...)`` construction and cannot swap it without a +process restart. Full coverage therefore requires two separate pytest +invocations -- once with ``-k 'cpu'`` and once with ``-k 'cuda:0'``. The +``_ovphysx_skip_other_device`` autouse fixture below preempts the manager's +:exc:`RuntimeError` by ``pytest.skip``-ing on the unlocked device so +single-device runs finish cleanly. + +Two v1-unsupported feature tests are kept but marked ``@pytest.mark.skip``: + +* :func:`test_friction_reporting` — requires ``track_friction_forces``; see + issue #5325 and ``docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md``. +* :func:`test_invalid_prim_paths_config` — requires ``track_friction_forces`` + (used to configure the scene); same issue. +* :func:`test_invalid_max_contact_points_config` — requires + ``track_friction_forces``; same issue. + +The ``disable_contact_processing`` PhysX/Kit setting is not available in the +kitless OVPhysX flow; :func:`test_cube_contact_time` and +:func:`test_sphere_contact_time` therefore drop that parametrize axis and run +once per device. +""" + +from __future__ import annotations + +from dataclasses import MISSING +from enum import Enum + +import pytest +import torch +import warp as wp +from flaky import flaky + +# The CI isaaclab_ov* pattern unintentionally collects isaaclab_ovphysx tests, +# but the ovphysx wheel is not installed in that environment. Skip gracefully +# so the isaaclab_ov CI pipeline is not blocked by an unrelated dependency. +pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") + +from isaaclab_ovphysx.assets import RigidObject # noqa: E402 +from isaaclab_ovphysx.physics import OvPhysxCfg # noqa: E402 +from isaaclab_ovphysx.sensors import ContactSensor, ContactSensorCfg # noqa: E402 + +import isaaclab.sim as sim_utils # noqa: E402 +from isaaclab.assets import RigidObjectCfg # noqa: E402 +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg # noqa: E402 +from isaaclab.sim import SimulationCfg, SimulationContext, build_simulation_context # noqa: E402 +from isaaclab.sim.utils.stage import get_current_stage # noqa: E402 +from isaaclab.terrains import HfRandomUniformTerrainCfg, TerrainGeneratorCfg, TerrainImporterCfg # noqa: E402 +from isaaclab.utils.configclass import configclass # noqa: E402 + +wp.init() + +# --------------------------------------------------------------------------- +# Device-lock autouse fixture +# --------------------------------------------------------------------------- + +_LOCKED_DEVICE: list[str | None] = [None] +"""Device the session pins to on the first parametrized test that runs.""" + + +@pytest.fixture(autouse=True) +def _ovphysx_skip_other_device(request): + """Skip parametrized tests on the device the session is not pinned to. + + See the module docstring for the wheel's process-global device-mode lock. + """ + callspec = getattr(request.node, "callspec", None) + device = callspec.params.get("device") if callspec is not None else None + if device is None: + # Test does not parametrize on device. + return + locked = _LOCKED_DEVICE[0] + if locked is None: + _LOCKED_DEVICE[0] = device + return + if device != locked: + pytest.skip( + f"ovphysx process-global device lock is held by '{locked}'; cannot run '{device}' " + "tests in the same session. Run pytest twice (once per device) for full coverage." + ) + + +# --------------------------------------------------------------------------- +# Simulation context helper +# --------------------------------------------------------------------------- + + +def _ovphysx_sim_context(device: str, **kwargs): + """Wrapper around :func:`build_simulation_context` that injects OVPhysX cfg. + + PhysX tests pass ``device=device`` directly and let + :func:`build_simulation_context` build a default :class:`SimulationCfg`. + OVPhysX needs ``physics=OvPhysxCfg()`` set on the cfg so the manager + dispatches to OVPhysX rather than PhysX, so we build the cfg here and + pass it through. ``gravity_enabled`` is consumed locally (it is ignored + by ``build_simulation_context`` once a ``sim_cfg`` is provided). + ``add_ground_plane``, ``auto_add_lighting``, and other kwargs continue + to flow through ``build_simulation_context`` as before. + """ + dt = kwargs.pop("dt", 1.0 / 60.0) + gravity_enabled = kwargs.pop("gravity_enabled", True) + gravity = (0.0, 0.0, -9.81) if gravity_enabled else (0.0, 0.0, 0.0) + sim_cfg = SimulationCfg(physics=OvPhysxCfg(), device=device, dt=dt, gravity=gravity) + return build_simulation_context(device=device, sim_cfg=sim_cfg, **kwargs) + + +## +# Custom helper classes. +## + + +class ContactTestMode(Enum): + """Enum to declare the type of contact sensor test to execute.""" + + IN_CONTACT = 0 + """Enum to test the condition where the test object is in contact with the ground plane.""" + NON_CONTACT = 1 + """Enum to test the condition where the test object is not in contact with the ground plane (air time).""" + + +@configclass +class ContactSensorRigidObjectCfg(RigidObjectCfg): + """Configuration for rigid objects used for the contact sensor test. + + This contains the expected values in the configuration to simplify test fixtures. + """ + + contact_pose: torch.Tensor = MISSING + """6D pose of the rigid object under test when it is in contact with the ground surface.""" + non_contact_pose: torch.Tensor = MISSING + """6D pose of the rigid object under test when it is not in contact.""" + + +@configclass +class ContactSensorSceneCfg(InteractiveSceneCfg): + """Configuration of the scene used by the contact sensor test.""" + + terrain: TerrainImporterCfg = MISSING + """Terrain configuration within the scene.""" + + shape: ContactSensorRigidObjectCfg = MISSING + """RigidObject contact prim configuration.""" + + contact_sensor: ContactSensorCfg = MISSING + """Contact sensor configuration.""" + + shape_2: ContactSensorRigidObjectCfg = None + """RigidObject contact prim configuration. Defaults to None, i.e. not included in the scene. + + This is a second prim used for testing contact filtering. + """ + + contact_sensor_2: ContactSensorCfg = None + """Contact sensor configuration. Defaults to None, i.e. not included in the scene. + + This is a second contact sensor used for testing contact filtering. + """ + + +## +# Scene entity configurations. +## + + +CUBE_CFG = ContactSensorRigidObjectCfg( + prim_path="/World/Objects/Cube", + spawn=sim_utils.CuboidCfg( + size=(0.5, 0.5, 0.5), + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + ), + collision_props=sim_utils.CollisionPropertiesCfg( + collision_enabled=True, + ), + activate_contact_sensors=True, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.4, 0.6, 0.4)), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0, -1.0, 1.0)), + contact_pose=torch.tensor([0, -1.0, 0, 1, 0, 0, 0]), + non_contact_pose=torch.tensor([0, -1.0, 1.0, 1, 0, 0, 0]), +) +"""Configuration of the cube prim.""" + +SPHERE_CFG = ContactSensorRigidObjectCfg( + prim_path="/World/Objects/Sphere", + spawn=sim_utils.SphereCfg( + radius=0.25, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + ), + collision_props=sim_utils.CollisionPropertiesCfg( + collision_enabled=True, + ), + activate_contact_sensors=True, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.4, 0.4, 0.6)), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0, 1.0, 1.0)), + contact_pose=torch.tensor([0, 1.0, 0.0, 1, 0, 0, 0]), + non_contact_pose=torch.tensor([0, 1.0, 1.0, 1, 0, 0, 0]), +) +"""Configuration of the sphere prim.""" + +CYLINDER_CFG = ContactSensorRigidObjectCfg( + prim_path="/World/Objects/Cylinder", + spawn=sim_utils.CylinderCfg( + radius=0.5, + height=0.01, + axis="Y", + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + ), + collision_props=sim_utils.CollisionPropertiesCfg( + collision_enabled=True, + ), + activate_contact_sensors=True, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.6, 0.4, 0.4)), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0, 0.0, 1.0)), + contact_pose=torch.tensor([0, 0, 0.0, 1, 0, 0, 0]), + non_contact_pose=torch.tensor([0, 0, 1.0, 1, 0, 0, 0]), +) +"""Configuration of the cylinder prim.""" + +CAPSULE_CFG = ContactSensorRigidObjectCfg( + prim_path="/World/Objects/Capsule", + spawn=sim_utils.CapsuleCfg( + radius=0.25, + height=0.5, + axis="Z", + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + ), + collision_props=sim_utils.CollisionPropertiesCfg( + collision_enabled=True, + ), + activate_contact_sensors=True, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.4, 0.4)), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(1.0, 0.0, 1.5)), + contact_pose=torch.tensor([1.0, 0.0, 0.0, 1, 0, 0, 0]), + non_contact_pose=torch.tensor([1.0, 0.0, 1.5, 1, 0, 0, 0]), +) +"""Configuration of the capsule prim.""" + +CONE_CFG = ContactSensorRigidObjectCfg( + prim_path="/World/Objects/Cone", + spawn=sim_utils.ConeCfg( + radius=0.5, + height=0.5, + axis="Z", + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + ), + collision_props=sim_utils.CollisionPropertiesCfg( + collision_enabled=True, + ), + activate_contact_sensors=True, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.4, 0.2, 0.4)), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(-1.0, 0.0, 1.0)), + contact_pose=torch.tensor([-1.0, 0.0, 0.0, 1, 0, 0, 0]), + non_contact_pose=torch.tensor([-1.0, 0.0, 1.0, 1, 0, 0, 0]), +) +"""Configuration of the cone prim.""" + +FLAT_TERRAIN_CFG = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane") +"""Configuration of the flat ground plane.""" + +COBBLESTONE_TERRAIN_CFG = TerrainImporterCfg( + prim_path="/World/ground", + terrain_type="generator", + terrain_generator=TerrainGeneratorCfg( + seed=0, + size=(3.0, 3.0), + border_width=0.0, + num_rows=1, + num_cols=1, + sub_terrains={ + "random_rough": HfRandomUniformTerrainCfg( + proportion=1.0, noise_range=(0.0, 0.05), noise_step=0.01, border_width=0.25 + ), + }, + ), +) +"""Configuration of the generated mesh terrain.""" + +## +# Shared test constants. +## + +_SIM_DT = 0.0025 +"""Simulation time-step [s] used across all contact sensor tests.""" + +_DURATIONS = [_SIM_DT, _SIM_DT * 2, _SIM_DT * 32, _SIM_DT * 128] +"""Contact/air durations [s] exercised by the timing tests.""" + +_TERRAINS = [FLAT_TERRAIN_CFG, COBBLESTONE_TERRAIN_CFG] +"""Terrain configurations exercised by the timing tests.""" + +## +# Tests. +## + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@flaky(max_runs=5, min_passes=1) +@pytest.mark.isaacsim_ci +def test_cube_contact_time(device): + """Checks contact sensor values for contact time and air time for a cube collision primitive.""" + _run_contact_sensor_test(CUBE_CFG, _SIM_DT, device, _TERRAINS, _DURATIONS) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@flaky(max_runs=5, min_passes=1) +@pytest.mark.isaacsim_ci +def test_sphere_contact_time(device): + """Checks contact sensor values for contact time and air time for a sphere collision primitive.""" + _run_contact_sensor_test(SPHERE_CFG, _SIM_DT, device, _TERRAINS, _DURATIONS) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("num_envs", [1, 6, 24]) +@pytest.mark.isaacsim_ci +def test_cube_stack_contact_filtering(device, num_envs): + """Checks contact sensor reporting for filtering stacked cube prims.""" + with _ovphysx_sim_context(device=device, dt=_SIM_DT, add_lighting=True) as sim: + # Instance new scene for the current terrain and contact prim. + # OVPhysX uses fnmatch globs (not regex), so ``Env_*`` rather than ``Env_.*``. + scene_cfg = ContactSensorSceneCfg(num_envs=num_envs, env_spacing=1.0, lazy_sensor_update=False) + scene_cfg.terrain = FLAT_TERRAIN_CFG.replace(prim_path="/World/ground") + # -- cube 1 + scene_cfg.shape = CUBE_CFG.replace(prim_path="{ENV_REGEX_NS}/Cube_1") + scene_cfg.shape.init_state.pos = (0, -1.0, 1.0) + # -- cube 2 (on top of cube 1) + scene_cfg.shape_2 = CUBE_CFG.replace(prim_path="{ENV_REGEX_NS}/Cube_2") + scene_cfg.shape_2.init_state.pos = (0, -1.0, 1.525) + # -- contact sensor 1 + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Cube_1", + track_pose=True, + debug_vis=False, + update_period=0.0, + filter_prim_paths_expr=["{ENV_REGEX_NS}/Cube_2"], + ) + # -- contact sensor 2 + scene_cfg.contact_sensor_2 = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Cube_2", + track_pose=True, + debug_vis=False, + update_period=0.0, + filter_prim_paths_expr=["{ENV_REGEX_NS}/Cube_1"], + ) + scene = InteractiveScene(scene_cfg) + + # Play the simulation + sim.reset() + + contact_sensor: ContactSensor = scene["contact_sensor"] + contact_sensor_2: ContactSensor = scene["contact_sensor_2"] + + # Check that the filter binding was created for each sensor + assert contact_sensor.contact_view.filter_count == 1 + assert contact_sensor_2.contact_view.filter_count == 1 + + # Let the scene settle and accumulate contacts + scene.reset() + for _ in range(500): + _perform_sim_step(sim, scene, _SIM_DT) + + # Check values for cube 2 — cube 1 is the only collision for cube 2 + torch.testing.assert_close( + contact_sensor_2.data.force_matrix_w.torch[:, :, 0], + contact_sensor_2.data.net_forces_w.torch, + ) + # Check that forces are opposite and equal + torch.testing.assert_close( + contact_sensor_2.data.force_matrix_w.torch[:, :, 0], + -contact_sensor.data.force_matrix_w.torch[:, :, 0], + ) + # Check values are non-zero (contacts are happening and are getting reported) + assert contact_sensor_2.data.net_forces_w.torch.sum().item() > 0.0 + assert contact_sensor.data.net_forces_w.torch.sum().item() > 0.0 + + +@pytest.mark.isaacsim_ci +def test_no_contact_reporting(): + """Test that OVPhysX contact sensor returns zero forces when no filter is configured. + + Without ``filter_prim_paths_expr``, the ``force_matrix_w`` buffer is not + populated (no per-partner breakdown is available), and ``net_forces_w`` + should still reflect the aggregate contact force. This test verifies the + simpler "unfiltered, CPU-only" path by using CPU and letting the scene + settle: with no filter the ``force_matrix_w`` sum is expected to be zero + (the buffer is not allocated). + + Note: + The PhysX variant of this test forcibly disables contact processing via + a Carbonite setting (``/physics/disableContactProcessing``). That + setting is not available in the kitless OVPhysX flow; instead we test + that a sensor with no filter has a zero ``force_matrix_w``. + """ + with _ovphysx_sim_context(device="cpu", dt=_SIM_DT, add_lighting=True) as sim: + scene_cfg = ContactSensorSceneCfg(num_envs=2, env_spacing=1.0, lazy_sensor_update=False) + scene_cfg.terrain = FLAT_TERRAIN_CFG + # -- cube 1 + scene_cfg.shape = CUBE_CFG.replace(prim_path="{ENV_REGEX_NS}/Cube_1") + scene_cfg.shape.init_state.pos = (0, -1.0, 1.0) + # -- cube 2 (on top of cube 1) + scene_cfg.shape_2 = CUBE_CFG.replace(prim_path="{ENV_REGEX_NS}/Cube_2") + scene_cfg.shape_2.init_state.pos = (0, -1.0, 1.525) + # No filter paths — force_matrix_w will not be allocated. + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Cube_1", + track_pose=True, + debug_vis=False, + update_period=0.0, + filter_prim_paths_expr=[], + ) + scene_cfg.contact_sensor_2 = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Cube_2", + track_pose=True, + debug_vis=False, + update_period=0.0, + filter_prim_paths_expr=[], + ) + scene = InteractiveScene(scene_cfg) + + # Play the simulation + sim.reset() + + contact_sensor: ContactSensor = scene["contact_sensor"] + contact_sensor_2: ContactSensor = scene["contact_sensor_2"] + + # Let the scene settle + scene.reset() + for _ in range(500): + _perform_sim_step(sim, scene, _SIM_DT) + + # Without filter_prim_paths_expr the force_matrix_w buffer is not allocated; + # its sum should be zero (or the tensor is None). + fm1 = contact_sensor.data.force_matrix_w + fm2 = contact_sensor_2.data.force_matrix_w + if fm1 is not None: + assert fm1.torch.sum().item() == 0.0 + if fm2 is not None: + assert fm2.torch.sum().item() == 0.0 + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("num_envs", [1, 3]) +@pytest.mark.isaacsim_ci +def test_multi_body_per_sensor_indexing(device, num_envs): + """Ground-truth body-index check for a single sensor that resolves to two bodies. + + OVPhysX :class:`ContactBinding` returns sensors in **pattern-major** order + (``[env_0/body_0, env_1/body_0, …, env_0/body_1, env_1/body_1, …]``), + whereas the inherited PhysX kernel formula assumes env-major + (``[env_0/body_0, env_0/body_1, …, env_1/body_0, …]``). Single-body + sensors don't disambiguate the two layouts, so this test exercises the + multi-body discovery path with one cube on the ground and one floating + above it. After the scene settles, only the bottom cube should report a + non-zero net force. An env-major bug would attribute that force to the + wrong (env, body) slot — caught here. + """ + with _ovphysx_sim_context(device=device, dt=_SIM_DT, add_lighting=True) as sim: + scene_cfg = ContactSensorSceneCfg(num_envs=num_envs, env_spacing=2.0, lazy_sensor_update=False) + scene_cfg.terrain = FLAT_TERRAIN_CFG.replace(prim_path="/World/ground") + # -- Cube_low: on the ground, will report contact forces + scene_cfg.shape = CUBE_CFG.replace(prim_path="{ENV_REGEX_NS}/Cube_low") + scene_cfg.shape.init_state.pos = (0.0, 0.0, 0.25) + # -- Cube_high: floating well above the ground, should remain in air + scene_cfg.shape_2 = CUBE_CFG.replace(prim_path="{ENV_REGEX_NS}/Cube_high") + scene_cfg.shape_2.init_state.pos = (0.0, 1.5, 3.0) + # Single ContactSensor that matches BOTH cubes via a regex glob. + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Cube_.*", + track_pose=False, + debug_vis=False, + update_period=0.0, + filter_prim_paths_expr=[], + ) + scene = InteractiveScene(scene_cfg) + sim.reset() + contact_sensor: ContactSensor = scene["contact_sensor"] + + # Sanity: the sensor discovered exactly two bodies, one per cube. + assert contact_sensor.body_names is not None + assert sorted(contact_sensor.body_names) == ["Cube_high", "Cube_low"] + low_idx = contact_sensor.body_names.index("Cube_low") + high_idx = contact_sensor.body_names.index("Cube_high") + + # Let physics settle and accumulate stable contacts on Cube_low. + scene.reset() + for _ in range(200): + _perform_sim_step(sim, scene, _SIM_DT) + + # Net force readout: shape (num_envs, num_sensors=2, 3) after .torch. + net_forces = contact_sensor.data.net_forces_w.torch + assert net_forces.shape == (num_envs, 2, 3) + low_force_mag = net_forces[:, low_idx, :].abs().sum().item() + high_force_mag = net_forces[:, high_idx, :].abs().sum().item() + # Cube_low rests on the ground: non-zero contact force per env. + assert low_force_mag > 0.0, "Cube_low (on ground) should report contact force" + # Cube_high floats: net force is zero (no contact). + assert high_force_mag == 0.0, ( + f"Cube_high (in air) should report zero contact force, got sum-abs={high_force_mag:.6f}." + " A non-zero value here usually means body indices are scrambled —" + " e.g. a Cube_low contact was attributed to Cube_high because the kernel" + " assumed env-major instead of pattern-major flat-buffer layout." + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_sensor_print(device): + """Test sensor print is working correctly.""" + with _ovphysx_sim_context(device=device, dt=_SIM_DT, add_lighting=False) as sim: + scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) + scene_cfg.terrain = FLAT_TERRAIN_CFG.replace(prim_path="/World/ground") + scene_cfg.shape = CUBE_CFG + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path=scene_cfg.shape.prim_path, + track_pose=True, + debug_vis=False, + update_period=0.0, + track_air_time=True, + history_length=3, + ) + scene = InteractiveScene(scene_cfg) + # Play the simulator + sim.reset() + # print info + print(scene.sensors["contact_sensor"]) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_contact_sensor_threshold(device): + """Test that the contact sensor USD threshold attribute is set to 0.0.""" + with _ovphysx_sim_context(device=device, dt=_SIM_DT, add_lighting=False) as sim: + scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) + scene_cfg.terrain = FLAT_TERRAIN_CFG.replace(prim_path="/World/ground") + scene_cfg.shape = CUBE_CFG + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path=scene_cfg.shape.prim_path, + track_pose=True, + debug_vis=False, + update_period=0.0, + track_air_time=True, + history_length=3, + ) + scene = InteractiveScene(scene_cfg) + # Play the simulator + sim.reset() + + stage = get_current_stage() + prim_path = scene_cfg.shape.prim_path + prim = stage.GetPrimAtPath(prim_path) + + # Ensure the contact sensor was created properly + contact_sensor = scene["contact_sensor"] + assert contact_sensor is not None, "Contact sensor was not created" + + # Check if the prim has contact report API and verify threshold is close to 0.0 + if "PhysxContactReportAPI" in prim.GetAppliedSchemas(): + threshold_attr = prim.GetAttribute("physxContactReport:threshold") + if threshold_attr.IsValid(): + threshold_value = threshold_attr.Get() + assert pytest.approx(threshold_value, abs=1e-6) == 0.0, ( + f"Expected USD threshold to be close to 0.0, but got {threshold_value}" + ) + + +@pytest.mark.skip( + reason=( + "ovphysx ContactSensor v1 does not support track_friction_forces; " + "see issue #5325 and docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md" + ) +) +@pytest.mark.parametrize("grav_dir", [(-10.0, 0.0, -0.1), (0.0, -10.0, -0.1)]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_friction_reporting(device, grav_dir): + """Test friction force reporting for contact sensors. + + This test places a contact sensor enabled cube onto a ground plane under different gravity directions. + It then compares the normalized friction force dir with the direction of gravity to ensure they are aligned. + """ + sim_cfg = SimulationCfg(physics=OvPhysxCfg(), dt=_SIM_DT, device=device, gravity=grav_dir) + with build_simulation_context(device=device, sim_cfg=sim_cfg, add_lighting=False) as sim: + scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) + scene_cfg.terrain = FLAT_TERRAIN_CFG + scene_cfg.shape = CUBE_CFG + + filter_prim_paths_expr = [scene_cfg.terrain.prim_path + "/terrain/GroundPlane/CollisionPlane"] + + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path=scene_cfg.shape.prim_path, + track_pose=True, + debug_vis=False, + update_period=0.0, + track_air_time=True, + history_length=3, + track_friction_forces=True, + filter_prim_paths_expr=filter_prim_paths_expr, + ) + + scene = InteractiveScene(scene_cfg) + sim.reset() + + scene["contact_sensor"].reset() + shape: RigidObject = scene["shape"] + shape.write_root_pose_to_sim_index( + root_pose=torch.tensor([0, 0.0, CUBE_CFG.spawn.size[2] / 2.0, 1, 0, 0, 0], device=device).unsqueeze(0) + ) + + # step sim once to compute friction forces + _perform_sim_step(sim, scene, _SIM_DT) + + # check that forces are being reported match expected friction forces + expected_friction, _, _, _ = scene["contact_sensor"].contact_view.get_friction_data(dt=_SIM_DT) + expected_friction_torch = wp.to_torch(expected_friction) + reported_friction = scene["contact_sensor"].data.friction_forces_w.torch[0, 0, :] + + torch.testing.assert_close(expected_friction_torch.sum(dim=0), reported_friction[0], atol=1e-6, rtol=1e-5) + + # check that friction force direction opposes gravity direction + grav = torch.tensor(grav_dir, device=device) + norm_reported_friction = reported_friction / reported_friction.norm() + norm_gravity = grav / grav.norm() + dot = torch.dot(norm_reported_friction[0], norm_gravity) + + torch.testing.assert_close(torch.abs(dot), torch.tensor(1.0, device=device), atol=1e-4, rtol=1e-3) + + +@pytest.mark.skip( + reason=( + "ovphysx ContactSensor v1 does not support track_friction_forces; " + "see issue #5325 and docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md" + ) +) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_invalid_prim_paths_config(device): + """Test that a ValueError is raised when track_friction_forces=True and filter_prim_paths_expr is empty.""" + sim_cfg = SimulationCfg(physics=OvPhysxCfg(), dt=_SIM_DT, device=device) + with build_simulation_context(device=device, sim_cfg=sim_cfg, add_lighting=False) as sim: + scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) + scene_cfg.terrain = FLAT_TERRAIN_CFG + scene_cfg.shape = CUBE_CFG + + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path=scene_cfg.shape.prim_path, + track_pose=True, + debug_vis=False, + update_period=0.0, + track_air_time=True, + history_length=3, + track_friction_forces=True, + filter_prim_paths_expr=[], + ) + + try: + _ = InteractiveScene(scene_cfg) + sim.reset() + assert False, "Expected ValueError due to invalid contact sensor configuration." + except ValueError: + pass + + +@pytest.mark.skip( + reason=( + "ovphysx ContactSensor v1 does not support track_friction_forces; " + "see issue #5325 and docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md" + ) +) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.isaacsim_ci +def test_invalid_max_contact_points_config(device): + """Test that a ValueError is raised when track_friction_forces=True and max_contact_data_count_per_prim=0.""" + sim_cfg = SimulationCfg(physics=OvPhysxCfg(), dt=_SIM_DT, device=device) + with build_simulation_context(device=device, sim_cfg=sim_cfg, add_lighting=False) as sim: + scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) + scene_cfg.terrain = FLAT_TERRAIN_CFG + scene_cfg.shape = CUBE_CFG + filter_prim_paths_expr = [scene_cfg.terrain.prim_path + "/terrain/GroundPlane/CollisionPlane"] + + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path=scene_cfg.shape.prim_path, + track_pose=True, + debug_vis=False, + update_period=0.0, + track_air_time=True, + history_length=3, + track_friction_forces=True, + filter_prim_paths_expr=filter_prim_paths_expr, + max_contact_data_count_per_prim=0, + ) + + try: + _ = InteractiveScene(scene_cfg) + sim.reset() + assert False, "Expected ValueError due to invalid contact sensor configuration." + except ValueError: + pass + + +## +# Internal helpers. +## + + +def _run_contact_sensor_test( + shape_cfg: ContactSensorRigidObjectCfg, + sim_dt: float, + device: str, + terrains: list[TerrainImporterCfg], + durations: list[float], +): + """Run contact sensor timing tests for a single device across all terrain combinations. + + Args: + shape_cfg: Configuration of the rigid body used as contact primitive. + sim_dt: Simulation time-step [s]. + device: Compute device (e.g. ``"cuda:0"`` or ``"cpu"``). + terrains: List of terrain configurations to iterate over. + durations: Contact / air durations [s] to exercise. + + Note: + Unlike the PhysX variant, this helper never enables + ``track_contact_points`` or ``track_friction_forces`` because those + APIs are not yet available in the ovphysx v1 contact sensor (see + issue #5325). The ``test_contact_data`` path is therefore always + ``False``. The ``disable_contact_processing`` PhysX/Kit setting is + also not available in the kitless flow and is omitted. + """ + for terrain in terrains: + with _ovphysx_sim_context(device=device, dt=sim_dt, add_lighting=True) as sim: + scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) + scene_cfg.terrain = terrain + scene_cfg.shape = shape_cfg + + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path=shape_cfg.prim_path, + track_pose=True, + debug_vis=False, + update_period=0.0, + track_air_time=True, + history_length=3, + track_contact_points=False, + track_friction_forces=False, + filter_prim_paths_expr=[], + ) + scene = InteractiveScene(scene_cfg) + + # Play the simulation + sim.reset() + + # Run contact time and air time tests + _test_sensor_contact( + shape=scene["shape"], + sensor=scene["contact_sensor"], + mode=ContactTestMode.IN_CONTACT, + sim=sim, + scene=scene, + sim_dt=sim_dt, + durations=durations, + ) + _test_sensor_contact( + shape=scene["shape"], + sensor=scene["contact_sensor"], + mode=ContactTestMode.NON_CONTACT, + sim=sim, + scene=scene, + sim_dt=sim_dt, + durations=durations, + ) + + +def _test_sensor_contact( + shape: RigidObject, + sensor: ContactSensor, + mode: ContactTestMode, + sim: SimulationContext, + scene: InteractiveScene, + sim_dt: float, + durations: list[float], +): + """Test for the contact sensor. + + This test sets the contact prim to a pose either in contact or out of contact with the ground plane for + a known duration. Once the contact duration has elapsed, the data stored inside the contact sensor + associated with the contact prim is checked against the expected values. + + This process is repeated for all elements in ``durations``, where each successive contact timing test + is punctuated by setting the contact prim to the complement of the desired contact mode for 1 sim time-step. + + Args: + shape: The contact prim used for the contact sensor test. + sensor: The sensor reporting data to be verified by the contact sensor test. + mode: The contact test mode: either contact with ground plane or air time. + sim: The active simulation context. + scene: The interactive scene. + sim_dt: Simulation time-step [s]. + durations: Contact / air durations [s] to exercise. + """ + # reset the test state + sensor.reset() + expected_last_test_contact_time = 0 + expected_last_reset_contact_time = 0 + + # set poses for shape for a given contact sensor test mode. + # desired contact mode to set for a given duration. + test_pose = None + # complement of the desired contact mode used to reset the contact sensor. + reset_pose = None + if mode == ContactTestMode.IN_CONTACT: + test_pose = shape.cfg.contact_pose + reset_pose = shape.cfg.non_contact_pose + elif mode == ContactTestMode.NON_CONTACT: + test_pose = shape.cfg.non_contact_pose + reset_pose = shape.cfg.contact_pose + else: + raise ValueError("Received incompatible contact sensor test mode") + + for idx in range(len(durations)): + current_test_time = 0 + duration = durations[idx] + while current_test_time < duration: + # set object states to contact the ground plane + shape.write_root_pose_to_sim_index(root_pose=torch.tensor(test_pose, device=shape.device).unsqueeze(0)) + # perform simulation step + _perform_sim_step(sim, scene, sim_dt) + # increment contact time + current_test_time += sim_dt + # set last contact time to the previous desired contact duration plus the extra dt allowance. + expected_last_test_contact_time = durations[idx - 1] + sim_dt if idx > 0 else 0 + # Check the data inside the contact sensor + if mode == ContactTestMode.IN_CONTACT: + _check_prim_contact_state_times( + sensor=sensor, + expected_air_time=0.0, + expected_contact_time=durations[idx], + expected_last_contact_time=expected_last_test_contact_time, + expected_last_air_time=expected_last_reset_contact_time, + dt=duration + sim_dt, + ) + elif mode == ContactTestMode.NON_CONTACT: + _check_prim_contact_state_times( + sensor=sensor, + expected_air_time=durations[idx], + expected_contact_time=0.0, + expected_last_contact_time=expected_last_reset_contact_time, + expected_last_air_time=expected_last_test_contact_time, + dt=duration + sim_dt, + ) + + # switch the contact mode for 1 dt step before the next contact test begins. + shape.write_root_pose_to_sim_index(root_pose=torch.tensor(reset_pose, device=shape.device).unsqueeze(0)) + # perform simulation step + _perform_sim_step(sim, scene, sim_dt) + # set the last air time to 2 sim_dt steps, because last_air_time and last_contact_time + # adds an additional sim_dt to the total time spent in the previous contact mode for uncertainty in + # when the contact switch happened in between a dt step. + expected_last_reset_contact_time = 2 * sim_dt + + +def _test_friction_forces(shape: RigidObject, sensor: ContactSensor, mode: ContactTestMode) -> None: + """Verify friction force values reported by the contact sensor. + + This helper is only called from skipped tests (requires ``track_friction_forces`` + which is not supported in ovphysx v1). + + Args: + shape: The contact prim used for the contact sensor test. + sensor: The sensor reporting data to be verified. + mode: The contact test mode. + """ + if not sensor.cfg.track_friction_forces: + assert sensor._data.friction_forces_w is None + return + + # check shape of the friction_forces_w tensor (wp.to_torch expands vec3f -> float32 trailing dim) + num_bodies = sensor.num_bodies + friction_torch = sensor._data.friction_forces_w.torch + assert friction_torch.shape == (sensor.num_instances // num_bodies, num_bodies, 1, 3) + # compare friction forces + if mode == ContactTestMode.IN_CONTACT: + assert torch.any(torch.abs(friction_torch) > 1e-5).item() + friction_forces, _, buffer_count, buffer_start_indices = sensor.contact_view.get_friction_data( + dt=sensor._sim_physics_dt + ) + friction_forces_t = wp.to_torch(friction_forces) + buffer_count_t = wp.to_torch(buffer_count).to(torch.int32) + buffer_start_t = wp.to_torch(buffer_start_indices).to(torch.int32) + for i in range(sensor.num_instances * num_bodies): + for j in range(sensor.contact_view.filter_count): + start_index_ij = buffer_start_t[i, j] + count_ij = buffer_count_t[i, j] + force = torch.sum(friction_forces_t[start_index_ij : (start_index_ij + count_ij), :], dim=0) + env_idx = i // num_bodies + body_idx = i % num_bodies + assert torch.allclose(force, friction_torch[env_idx, body_idx, j, :], atol=1e-5) + elif mode == ContactTestMode.NON_CONTACT: + assert torch.all(friction_torch == 0.0).item() + + +def _test_contact_position(shape: RigidObject, sensor: ContactSensor, mode: ContactTestMode) -> None: + """Test for the contact positions (only implemented for sphere and flat terrain). + + Checks that the contact position is radius distance away from the root of the object. + + This helper is only called from skipped tests (requires ``track_contact_points`` + which is not supported in ovphysx v1). + + Args: + shape: The contact prim used for the contact sensor test. + sensor: The sensor reporting data to be verified. + mode: The contact test mode. + """ + if not sensor.cfg.track_contact_points: + assert sensor._data.contact_pos_w is None + return + + # check shape of the contact_pos_w tensor (wp.to_torch expands vec3f -> float32 trailing dim) + num_bodies = sensor.num_bodies + contact_pos_torch = sensor._data.contact_pos_w.torch + assert contact_pos_torch.shape == (sensor.num_instances // num_bodies, num_bodies, 1, 3) + # check contact positions + if mode == ContactTestMode.IN_CONTACT: + pos_w_torch = sensor._data.pos_w.torch + contact_position = pos_w_torch + torch.tensor([[0.0, 0.0, -shape.cfg.spawn.radius]], device=pos_w_torch.device) + assert torch.all( + torch.abs(torch.linalg.norm(contact_pos_torch - contact_position.unsqueeze(1), ord=2, dim=-1)) < 1e-2 + ).item() + elif mode == ContactTestMode.NON_CONTACT: + assert torch.all(torch.isnan(contact_pos_torch)).item() + + +def _check_prim_contact_state_times( + sensor: ContactSensor, + expected_air_time: float, + expected_contact_time: float, + expected_last_air_time: float, + expected_last_contact_time: float, + dt: float, +): + """Check contact sensor data matches expected values. + + Args: + sensor: Instance of ContactSensor containing data to be tested. + expected_air_time: Air time ground truth [s]. + expected_contact_time: Contact time ground truth [s]. + expected_last_air_time: Last air time ground truth [s]. + expected_last_contact_time: Last contact time ground truth [s]. + dt: Time since previous contact mode switch [s]. If the contact prim left contact 0.1 seconds ago, + dt should be 0.1 + simulation dt seconds. + """ + # store current state of the contact prim + in_air = expected_air_time > 0.0 + in_contact = expected_contact_time > 0.0 + measured_contact_time = sensor.data.current_contact_time.torch + measured_air_time = sensor.data.current_air_time.torch + measured_last_contact_time = sensor.data.last_contact_time.torch + measured_last_air_time = sensor.data.last_air_time.torch + # check current contact state + assert pytest.approx(measured_contact_time.item(), 0.01) == expected_contact_time + assert pytest.approx(measured_air_time.item(), 0.01) == expected_air_time + # check last contact state + assert pytest.approx(measured_last_contact_time.item(), 0.01) == expected_last_contact_time + assert pytest.approx(measured_last_air_time.item(), 0.01) == expected_last_air_time + # check current contact mode + assert sensor.compute_first_contact(dt=dt).torch.item() == in_contact + assert sensor.compute_first_air(dt=dt).torch.item() == in_air + + +def _perform_sim_step(sim: SimulationContext, scene: InteractiveScene, sim_dt: float) -> None: + """Update sensors and step the contact sensor test scene. + + Args: + sim: The active simulation context. + scene: The interactive scene. + sim_dt: Simulation time-step [s]. + """ + # write data to simulation + scene.write_data_to_sim() + # simulate + sim.step(render=False) + # update buffers at sim dt + scene.update(dt=sim_dt) diff --git a/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_contactsensor.rst b/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_contactsensor.rst new file mode 100644 index 000000000000..ab7247b1bea5 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_contactsensor.rst @@ -0,0 +1,10 @@ +Added +^^^^^ + +* Added ``ovphysx`` preset to ``isaaclab_tasks.manager_based.locomotion.velocity`` + for use under the OVPhysX backend. ``AnymalDFlatPhysicsCfg`` now exposes + an ``ovphysx`` member, and the shared ``LocomotionVelocityRoughEnvCfg`` + injects the OVPhysX :class:`~isaaclab_ovphysx.sensors.ContactSensorCfg` + alongside the existing PhysX and Newton entries so the velocity task + selects the right contact sensor backend when run with + ``presets=ovphysx``. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py index 5a97b75f7936..abf4ffae0f9f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_ovphysx.physics import OvPhysxCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -28,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + ovphysx = OvPhysxCfg() @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/velocity_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/velocity_env_cfg.py index 1b936e9fc0b9..b1b7e0043bed 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/velocity_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/velocity_env_cfg.py @@ -8,6 +8,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg, NewtonCollisionPipelineCfg, NewtonShapeCfg from isaaclab_newton.sensors import ContactSensorCfg as NewtonContactSensorCfg +from isaaclab_ovphysx.sensors import ContactSensorCfg as OvPhysXContactSensorCfg from isaaclab_physx.physics import PhysxCfg from isaaclab_physx.sensors import ContactSensorCfg as PhysXContactSensorCfg @@ -78,6 +79,7 @@ class VelocityEnvContactSensorCfg(PresetCfg): default = PhysXContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_air_time=True) newton_mjwarp = NewtonContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_air_time=True) physx = default + ovphysx = OvPhysXContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_air_time=True) @configclass From 37862a111cb3396ff6991d3b450936c23c4a5e28 Mon Sep 17 00:00:00 2001 From: rwiltz <165190220+rwiltz@users.noreply.github.com> Date: Tue, 19 May 2026 13:45:47 -0400 Subject: [PATCH 114/133] Migrate teleop replay from XCR to Isaac Teleop MCAP (#5608) # Description Replaces the XCR-based teleop replay path with a native Isaac Teleop MCAP record + replay workflow. The new `scripts/environments/teleoperation/teleop_replay_agent.py` drives an `IsaacTeleopDevice` in `SessionMode.REPLAY` against an MCAP capture produced by `record_demos.py --mcap_record_path`. It gates `env.step()` on `poll_control_events` (same shape as `teleop_se3_agent.py`), so the recorded START / STOP / RESET edges reproduce the original recording's pacing -- frame-aligned via the new `ReplayMessageChannelTrackerImpl` upstream in IsaacTeleop (companion PR: `IsaacTeleop#`). End-of-replay is detected by four signals (recorded STOP, env `success_term`, env `terminated`/`truncated`, or `--max_replay_duration_s` wall-clock cap). Each maps to a CI-friendly process exit code: `0` success, `1` failure / incomplete, `2` timeout. Also threads `mcap_record_path` / `mcap_replay_path` through `create_isaac_teleop_device` (`IsaacTeleopDevice` + `TeleopSessionLifecycle`) and adds `--mcap_record_path` to `record_demos.py` for paired capture/replay. 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 --- .../teleoperation/teleop_replay_agent.py | 1455 ++++++++++++++--- scripts/tools/record_demos.py | 15 + .../rwiltz-mcap-replay-agent.minor.rst | 12 + .../pick_place/pickplace_gr1t2_env_cfg.py | 43 - .../rwiltz-mcap-replay-agent.minor.rst | 36 + .../isaaclab_teleop/automation/__init__.py | 8 - .../isaaclab_teleop/automation/xcr_replay.py | 138 -- .../isaaclab_teleop/isaac_teleop_device.py | 45 +- .../isaaclab_teleop/session_lifecycle.py | 170 +- .../isaaclab_teleop/xr_anchor_manager.py | 48 +- source/isaaclab_teleop/setup.py | 2 +- 11 files changed, 1481 insertions(+), 491 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/rwiltz-mcap-replay-agent.minor.rst create mode 100644 source/isaaclab_teleop/changelog.d/rwiltz-mcap-replay-agent.minor.rst delete mode 100644 source/isaaclab_teleop/isaaclab_teleop/automation/__init__.py delete mode 100644 source/isaaclab_teleop/isaaclab_teleop/automation/xcr_replay.py diff --git a/scripts/environments/teleoperation/teleop_replay_agent.py b/scripts/environments/teleoperation/teleop_replay_agent.py index 7d5d2cbf62f2..2b8ffe51d63d 100644 --- a/scripts/environments/teleoperation/teleop_replay_agent.py +++ b/scripts/environments/teleoperation/teleop_replay_agent.py @@ -3,18 +3,213 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""CI/automation entry point for replaying captured teleop sessions. +"""CI/automation entry point for replaying captured Isaac Teleop sessions. This is the non-interactive counterpart to ``teleop_se3_agent.py``. It builds -a teleop environment, attaches a teleop device, schedules a replay driver, -and pumps the simulation loop until the replay completes and the application -exits. The user-journey teleop script remains ``teleop_se3_agent.py``. - -The current implementation drives playback through Kit's OpenXR XCR backend -and the legacy native XR ``handtracking`` device. The script is structured so -that the replay-driver call site and device selection are the only pieces -that need to change when migrating to a different replay backend in the -future (e.g. an Isaac Teleop ``TeleopSession`` running in replay mode). +a teleop environment, attaches an :class:`~isaaclab_teleop.IsaacTeleopDevice` +configured in :class:`isacteleop.teleop_session_manager.SessionMode.REPLAY`, +and pumps the simulation loop until the recorded operator presses STOP (or +``--max_replay_duration_s`` elapses, or Kit is closed). The user-journey +teleop script remains ``teleop_se3_agent.py``. + +Inputs: + ``--replay_file`` is an MCAP capture produced by Isaac Teleop's + ``McapRecordingConfig`` path (typically written by ``record_demos.py + --mcap_record_path``). The recorder lays down per-tracker flatbuffer + messages (head / hands / controllers) plus the ``_teleop_control`` + ``MessageChannelTracker`` that captured the operator's START / STOP / + RESET gestures. TeleopCore's + :class:`~isacteleop.deviceio_session.ReplaySession` re-emits all of + them on the same monotonic-time cadence they were recorded on, so + :func:`~isaaclab_teleop.poll_control_events` returns the same edges + here that ``record_demos.py``'s loop saw at recording time. + +Gating: + The env-step loop mirrors ``teleop_se3_agent.py``: each iteration + calls :meth:`IsaacTeleopDevice.advance` and + :func:`~isaaclab_teleop.poll_control_events`, gates ``env.step()`` on + ``ctrl.is_active`` (so pre-START operator setup frames are rendered + without stepping), and handles mid-demo ``ctrl.should_reset`` events + by running the full sim/env/teleop reset cycle. + +End-of-replay termination: + Four distinct signals end the current run by breaking out of the + inner loop. ``_run_single_replay`` returns its populated + :class:`_RunStats`, and the outer batch driver in :func:`main` + moves on to the next replay (or returns from ``main`` when the + batch is done; ``__main__`` then calls ``simulation_app.close()``). + The signals are: + + 1. **The recorded operator STOP**, replayed via the + ``_teleop_control`` ``MessageChannelTracker`` at the same + recording-frame index it was captured at -- ``ctrl.is_active`` + transitions True->False on that frame. + 2. **The env's success condition firing for + ``--num_success_steps`` consecutive steps**, the natural end of + a ``record_demos.py``-style capture. Post-success MCAP frames + are operator wind-down (releases, idle drift) that we have no + use for in replay, so we skip the ``_handle_reset`` cycle the + live agent would do. + 3. **A task-specific failure term** (``terminated`` or ``truncated`` + from ``env.step``) -- the recorded trajectory did not reproduce; + the operator has no agency to recover during replay. + 4. **Wall-clock ``--max_replay_duration_s`` safety cap**, for + recordings that produce neither a STOP, a success, nor a failure + within the configured window. + + With ``--num_replays N > 1`` each run is independently terminated by + one of the four signals above; the agent then rebuilds the + :class:`IsaacTeleopDevice` (reopening the MCAP at frame 0) and runs + again. The USD stage is loaded only once. + +Stats output: + Every iteration where ``env.step()`` actually ran contributes one + CPU frame-time sample (``time.perf_counter()`` delta in ms). Pre- + START render-only frames, warmup ticks, and post-quit render-only + spin-down are excluded so the resulting numbers reflect the + steady-state replay workload rather than agent bookkeeping + overhead. Per-run samples are summarised into mean / p50 / p90 / + p95 / p99 / min / max / stddev (under ``cpu_frame_time_ms``) plus + derived FPS metrics (under ``fps``). The two blocks measure the + same ``env.step`` event and stay self-consistent: ``fps.mean`` + equals ``1000 / cpu_frame_time_ms.mean`` (harmonic mean of FPS + = total frames / total step time). Kit's HUD displays the render + rate, which is this FPS multiplied by ``decimation / + render_interval`` (Kit pumps multiple frames per ``env.step``); + derive that from the env config if you need it. + + Each active iteration also emits one ``GpuStatsProvider.sample()`` + call. The default :class:`NvmlGpuStatsProvider` snapshots GPU + utilization (%) and used memory (MB) via ``pynvml``, summarised + under ``gpu_stats`` with the same percentile shape as + ``cpu_frame_time_ms``. It soft-fails when ``nvidia-ml-py`` is + missing or the driver is unreachable (``gpu_stats.available = + false`` + reason). Renderer-specific providers (Kit viewport + telemetry, Newton, ...) can be slotted in by implementing the + :class:`GpuStatsProvider` Protocol. + + A multi-run batch aggregates by taking the mean-of-means, + mean-of-p90s, etc. across runs. + + A one-line-per-run stdout summary is always printed at the end of + the batch. Pass ``--stats_output_file `` to additionally + persist the report as JSON. Schema (schema_version 1):: + + { + "schema_version": 1, + "task": "Isaac-PickPlace-GR1T2-Abs-v0", + "replay_file": "/tmp/pickplace_gr1t2.mcap", + "num_replays": 5, + "outcomes": {"success": 4, "failure": 1, "incomplete": 0, "timeout": 0}, + "success_rate": 0.8, + "runs": [ + { + "run_index": 0, + "outcome": "success", + "active_iterations": 322, + "active_duration_s": 21.503, + "success_step_count": 1, + "cpu_frame_time_ms": { + "mean": ..., "p50": ..., "p90": ..., "p95": ..., "p99": ..., + "min": ..., "max": ..., "stddev": ..., "n": ... + }, + "fps": {"mean": ..., "min_instantaneous": ..., "max_instantaneous": ...}, + "gpu_stats": { + "backend": "nvml", "available": True, + "device_index": 0, "device_name": ..., "memory_total_mb": ..., + "utilization_percent": {}, + "memory_used_mb": {} + } + } + ], + "aggregate": { + "cpu_frame_time_ms": {"mean_of_means": ..., "mean_of_p90s": ..., + "mean_of_p99s": ..., "min_overall": ..., + "max_overall": ...}, + "fps": {"mean_of_means": ...} + } + } + +Exit codes: + The process exits with a status code that CI can branch on. With + ``--num_replays N`` the worst-of-N outcome wins (precedence + ``timeout > failure > incomplete > success``): + + * ``0`` -- every run reproduced the recording (``success_term`` + fired on each run). + * ``1`` -- one or more runs terminated/truncated mid-trajectory, + or finished without any explicit terminator firing. + * ``2`` -- one or more runs hit ``--max_replay_duration_s``. + +Warmup: + Before stepping the env, the agent waits deterministically for Kit + to finish loading the USD stage by polling + ``omni.usd.UsdContext.get_stage_loading_status()`` until no assets + are pending (bounded by ``--max_stage_load_wait_s`` as a safety net). + It then pumps a fixed number of additional renderer-settle frames so + shaders / articulation views finish warming up before any action + lands. ``--replay_start_delay_s`` is available as an optional + wall-clock buffer on top of the deterministic wait for hardware + that needs more grace. During warmup the agent does not call + :meth:`IsaacTeleopDevice.advance`, so ``ReplaySession.update()`` + does not advance through the MCAP. + +XR-active replay: + Pass ``--cloudxr_env `` (and optionally + ``--no-auto_launch_cloudxr``) to auto-spawn the CloudXR runtime and + engage Kit's XR pipeline during replay. ``--cloudxr_env`` mirrors + the flag on ``record_demos.py`` and accepts the same ``cloudxrjs`` + / ``avp`` shorthands. This is required (not optional) for two + distinct reasons: + + A. **Performance parity with live teleop.** A pure-replay run (no + XR, no CloudXR) skips the entire Kit XR rendering pipeline, + so frame timings, render load, GPU/CPU contention, and any + XR-side bottlenecks do not appear -- a captured trajectory + that replayed at 90Hz under those conditions could easily run + at 30Hz once XR is actually active. For perf regression or + benchmarking the replay loop must reproduce the same Kit + configuration the original recording ran under. + + B. **Correct ``world_T_anchor`` for playback.** The recorded + tracker stream (head / hands / controllers) lives in + OpenXR-local space; the world-frame poses the env consumes + come from ``world_T_anchor @ oxr_pose``. With XR active, + :class:`~isaaclab_teleop.XrAnchorManager` resolves + ``world_T_anchor`` through ``XrAnchorSynchronizer`` (the same + path used at record time), so the live anchor semantics -- + including any dynamic-anchor following of a prim and runtime + recentering -- are reproduced. Without XR active, the manager + falls back to the static :class:`~isaaclab_teleop.XrCfg` + values, which only happen to match record-time semantics when + the anchor never moved. + + The full incantation also needs ``AppLauncher``'s ``--xr`` flag + plus a few Kit-side carb settings to flip the AR profile and load + the teleop XR bridge (the replay path skips both for the + headless-CI default; we have not yet promoted them to a single + ``--xr_active`` knob):: + + ./isaaclab.sh -p teleop_replay_agent.py \\ + --task --replay_file \\ + --xr --device cuda:0 \\ + --cloudxr_env cloudxrjs \\ + --kit_args="--/xr/profile/ar/enabled=true \\ + --enable isaacsim.kit.xr.teleop.bridge \\ + --/persistent/xr/openxr/disableInputBindings=true" + + The headset is purely a viewer / anchor source -- the recorded MCAP + remains the sole source of action; live controller input from the + spectator's headset does not displace the replayed trajectory. + + Multi-run note: ``--num_replays > 1`` IS supported in XR-active + mode. The CloudXR runtime is launched once at the agent (batch) + scope and shared across runs (``_maybe_launch_cloudxr``); each + per-run :class:`~isaaclab_teleop.IsaacTeleopDevice` is constructed + with ``auto_launch_cloudxr=False`` so the per-run lifecycle does + not stop the runtime on teardown. Only the per-run + ``TeleopSession`` is torn down between replays; Kit's OpenXR + instance/session stay alive. """ """Launch Isaac Sim Simulator first.""" @@ -25,7 +220,7 @@ parser = argparse.ArgumentParser( description=( - "Replay a captured teleop session against an Isaac Lab environment. " + "Replay a captured Isaac Teleop MCAP session against an Isaac Lab environment. " "CI/automation entry point; for interactive teleoperation see teleop_se3_agent.py." ) ) @@ -35,21 +230,93 @@ "--replay_file", type=str, required=True, - help="Absolute path to the recorded teleop session to replay.", + help="Absolute path to the Isaac Teleop MCAP capture to replay.", ) parser.add_argument( - "--replay_start_delay_s", + "--num_success_steps", + type=int, + default=1, + help=( + "Number of consecutive steps the task success term must hold before declaring success and" + " resetting the env. Mirrors the equivalent flag in record_demos.py." + ), +) +parser.add_argument( + "--max_replay_duration_s", type=float, - default=0.0, - help="Seconds to wait after the environment is up before starting replay (default: 120.0).", + default=600.0, + help=( + "Maximum wall-clock seconds to keep a single replay running before ending it with the" + " ``timeout`` outcome, measured from the end of the warmup window. Safety net for" + " malformed MCAPs that omit the operator's STOP gesture -- with a clean recording the" + " agent ends the run on the replayed STOP edge well before this cap. Applies per run when" + " ``--num_replays > 1``. Default is 600s (10 min)." + ), ) parser.add_argument( - "--num_success_steps", + "--num_replays", type=int, default=1, help=( - "Number of consecutive steps the task success term must hold before declaring success and" - " resetting the env. Mirrors the equivalent flag in record_demos.py. (default: 10)" + "Number of times to replay the MCAP back-to-back. Each replay rebuilds the IsaacTeleopDevice" + " (re-opens the MCAP at frame 0) and resets the env in place without reloading Kit; the" + " CloudXR runtime and Kit's OpenXR session stay alive across runs so subsequent replays" + " start ~instantly. Per-run and aggregated success/failure rates are reported in the stats" + " summary; the exit code reflects the worst outcome across runs. Default 1." + ), +) +parser.add_argument( + "--stats_output_file", + type=str, + default=None, + help=( + "Optional path to write a JSON stats report (CPU frame time, FPS, outcome) to after the" + " run(s) complete. When omitted only a stdout summary is printed. Schema is documented" + " in the 'Stats output' section of the script's module docstring." + ), +) +parser.add_argument( + "--replay_start_delay_s", + type=float, + default=0.0, + help=( + "Optional wall-clock buffer added on top of the deterministic stage-load wait." + " The agent always blocks until omni.usd reports no assets pending and then renders a" + " fixed number of settle frames before consuming MCAP frames; this flag inserts an" + " additional render-only window after that if the deterministic check is not enough" + " for a given hardware/asset combination. Default is 0s -- bump it if you still see" + " a race after the deterministic wait." + ), +) +parser.add_argument( + "--max_stage_load_wait_s", + type=float, + default=300.0, + help=( + "Safety cap on how long to wait for omni.usd to finish loading the stage before" + " proceeding anyway. Hit only when something is misconfigured (missing asset, slow" + " Nucleus, etc.); a warning is logged and replay continues. Default is 300s." + ), +) +parser.add_argument( + "--cloudxr_env", + type=str, + default=None, + help=( + "Path to a CloudXR ``.env`` file, or a shorthand: 'cloudxrjs' (Quest/Pico) or 'avp'" + " (Apple Vision Pro). Default is None -- CloudXR is not launched. Pair with" + " AppLauncher's ``--xr`` and Kit-side AR-profile settings for spectate-on-headset" + " replay; see the script docstring for the full command." + ), +) +parser.add_argument( + "--auto_launch_cloudxr", + action=argparse.BooleanOptionalAction, + default=True, + help=( + "Auto-launch the CloudXR runtime when ``--cloudxr_env`` is set. Use" + " ``--no-auto_launch_cloudxr`` to skip the launch (e.g. when running the" + " runtime externally). Ignored when ``--cloudxr_env`` is omitted." ), ) AppLauncher.add_app_launcher_args(parser) @@ -62,17 +329,21 @@ """Rest everything follows.""" -import asyncio +import json import logging +import os +import statistics +import sys import time -from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol, runtime_checkable import gymnasium as gym import torch +from isaaclab_teleop import IsaacTeleopDevice, create_isaac_teleop_device, poll_control_events -from isaaclab.devices import DeviceBase from isaaclab.devices.openxr import remove_camera_configs -from isaaclab.devices.teleop_device_factory import create_teleop_device from isaaclab.envs import ManagerBasedRLEnvCfg import isaaclab_tasks # noqa: F401 @@ -80,92 +351,484 @@ logger = logging.getLogger(__name__) -_LEGACY_DEVICE_NAME = "handtracking" +_CLOUDXR_ENV_SHORTHANDS: dict[str, str] = {} -# Module-level set of pending replay-driver tasks. The asyncio event loop only -# keeps weak references to tasks, so a task that is not referenced elsewhere -# may be garbage-collected before it completes. The completion callback below -# discards the task again once it finishes. -_PENDING_REPLAY_TASKS: set[asyncio.Future] = set() +# ---------------------------------------------------------------------- +# Perf stats: per-run collection + multi-run reporting +# ---------------------------------------------------------------------- -_RENDERER_SETTLE_FRAMES: int = 30 -"""Number of extra render frames pumped after the USD stage finishes loading. - -Kit's stage-load status flips to ``count_loading == 0`` as soon as every referenced -asset has been resolved, but the renderer pipeline (shader compilation, -articulation-view binding, material warm-up) typically needs a few more event-loop -ticks to converge. Thirty frames at the default Kit render cadence is ~0.5 s on -most machines and is deterministic per-machine -- unlike a wall-clock delay it -does not have to be tuned for hardware. -""" +_STATS_SCHEMA_VERSION = 1 +"""Bump when the JSON shape produced by :func:`_build_report` changes in a +non-additive way (renamed / removed keys). Additive changes (new optional +keys) do not require a bump.""" -_DEFAULT_MAX_STAGE_LOAD_WAIT_S: float = 300.0 -"""Safety cap on the deterministic stage-load wait. -Hit only when something is misconfigured (missing asset, slow Nucleus, etc.); a -warning is logged and the loop continues so CI does not hang silently on a -broken capture. -""" +@dataclass +class _RunStats: + """Per-replay performance + outcome record. + ``active_frame_times_ms`` is sampled only on iterations where + ``env.step()`` actually ran (post-START, pre-terminator). Pre-START + render-only frames, warmup ticks, and post-quit render-only spin-down + are intentionally excluded so the resulting stats reflect the steady- + state replay workload rather than the agent's bookkeeping overhead. + """ -def _wait_for_stage_load(max_wait_s: float = _DEFAULT_MAX_STAGE_LOAD_WAIT_S) -> None: - """Block until the USD stage finishes resolving every referenced asset. + outcome: str = "incomplete" # "success" | "failure" | "incomplete" | "timeout" + active_frame_times_ms: list[float] = field(default_factory=list) + active_duration_s: float = 0.0 + success_step_count: int = 0 # final consecutive-success counter at terminator + # Filled in at run end from ``GpuStatsProvider.summary()``. + # Defaults to ``{}`` so a run that never reached the active loop + # produces a missing-but-not-None ``"gpu_stats"`` slot. + gpu_stats: dict = field(default_factory=dict) + + def to_dict(self, run_index: int) -> dict: + cpu_stats = _compute_frame_stats(self.active_frame_times_ms) + fps_stats = _compute_fps_stats(self.active_frame_times_ms) + return { + "run_index": run_index, + "outcome": self.outcome, + "active_iterations": len(self.active_frame_times_ms), + "active_duration_s": round(self.active_duration_s, 6), + "success_step_count": self.success_step_count, + "cpu_frame_time_ms": cpu_stats, + "fps": fps_stats, + "gpu_stats": self.gpu_stats, + } + + +def _compute_frame_stats(samples_ms: list[float]) -> dict: + """Compute summary stats for a list of per-frame CPU times (in ms). + + Uses :func:`statistics.quantiles` with ``n=100, method="inclusive"`` so + the result is a stable in-process measurement with no numpy dependency. + The 99 quantile cut-points returned by ``quantiles`` are interpreted as + p1..p99; we sample p50 / p90 / p95 / p99 plus mean / min / max / + stddev / n. Handles empty (``n=0``, all numeric fields ``None``) and + single-sample (``n=1``, all numeric fields equal to the single sample, + ``stddev=0.0``) inputs without raising. + """ + n = len(samples_ms) + if n == 0: + return { + "mean": None, + "p50": None, + "p90": None, + "p95": None, + "p99": None, + "min": None, + "max": None, + "stddev": None, + "n": 0, + } + sample_min = min(samples_ms) + sample_max = max(samples_ms) + sample_mean = statistics.fmean(samples_ms) + if n == 1: + return { + "mean": sample_mean, + "p50": samples_ms[0], + "p90": samples_ms[0], + "p95": samples_ms[0], + "p99": samples_ms[0], + "min": sample_min, + "max": sample_max, + "stddev": 0.0, + "n": 1, + } + # quantiles(n=100) returns 99 cut points; cuts[i] is the (i+1)-th percentile. + cuts = statistics.quantiles(samples_ms, n=100, method="inclusive") + return { + "mean": sample_mean, + "p50": cuts[49], + "p90": cuts[89], + "p95": cuts[94], + "p99": cuts[98], + "min": sample_min, + "max": sample_max, + "stddev": statistics.stdev(samples_ms), + "n": n, + } + + +def _compute_fps_stats(samples_ms: list[float]) -> dict: + """Compute env.step-throughput FPS stats from per-step CPU times. + + All three fields are derived from the same ``samples_ms`` series + that feeds ``cpu_frame_time_ms`` and stay self-consistent with it: + ``mean == 1000 / cpu_frame_time_ms.mean`` (harmonic mean of FPS = + total frames / total step time). The harmonic mean is what + Devdeep's "use harmonic mean for FPS and it will agree with the + arithmetic mean of frame time" prescription expects -- it avoids + the upward bias of arithmetic-mean-of-instantaneous-FPS + (dominated by the fastest frames) and the downward bias of + ``n / active_duration_s`` (dragged down by inter-step + bookkeeping). + + Note that this is the ``env.step`` rate, not Kit's render rate: + Kit pumps ``cfg.decimation / cfg.sim.render_interval`` frames per + ``env.step`` call, so the HUD shows a higher number than what is + reported here. Compute the render rate as + ``fps.mean * decimation / render_interval`` from the env config + if needed. + + ``min_instantaneous`` and ``max_instantaneous`` are derived from + the slowest / fastest individual step respectively. + """ + n = len(samples_ms) + if n == 0: + return {"mean": None, "min_instantaneous": None, "max_instantaneous": None} + sample_mean_ms = statistics.fmean(samples_ms) + sample_max_ms = max(samples_ms) + sample_min_ms = min(samples_ms) + return { + "mean": 1000.0 / sample_mean_ms if sample_mean_ms > 0 else None, + "min_instantaneous": 1000.0 / sample_max_ms if sample_max_ms > 0 else None, + "max_instantaneous": 1000.0 / sample_min_ms if sample_min_ms > 0 else None, + } + + +# ============================================================================= +# GPU statistics +# ============================================================================= +# +# ``GpuStatsProvider`` is the modularity seam. The agent constructs a +# provider at the start of each run, calls :meth:`sample` once per +# active iteration, and consumes :meth:`summary` at run end to embed +# the resulting dict under ``"gpu_stats"`` in the per-run report. +# +# ``NvmlGpuStatsProvider`` (default) is renderer-agnostic: it queries +# NVML directly via ``pynvml`` and works wherever an NVIDIA driver is +# installed -- no Kit dependency, no CUDA context needed. If you swap +# the renderer out (e.g. move to a non-Kit visualization), this +# provider still works. +# +# To add a renderer-specific provider in the future (Kit viewport +# telemetry, Newton, etc.), define a class with the same +# ``sample`` / ``summary`` signature and instantiate it inside +# ``_run_single_replay`` in place of ``NvmlGpuStatsProvider``. + + +@runtime_checkable +class GpuStatsProvider(Protocol): + """Per-run GPU telemetry source. + + Renderer-agnostic interface for sampling GPU state during a + replay. Implementations are expected to be cheap on + :meth:`sample` (<<1 ms; the agent calls it once per active + iteration in the hot path) and to return a JSON-serializable + dict from :meth:`summary` matching the shape documented on the + concrete impl. + """ - Polls :meth:`omni.usd.UsdContext.get_stage_loading_status`. The third element of - the returned tuple is the count of assets Kit still has pending; when it - reaches zero the stage is fully streamed in and the renderer pipeline is ready - to draw against it. After the count reaches zero this function pumps an - additional :data:`_RENDERER_SETTLE_FRAMES` ``simulation_app.update()`` calls so - shaders, materials, and articulation views finish warming up before the caller - begins consuming replay data or stepping the env. + def sample(self) -> None: + """Snapshot current GPU state. Called once per active iteration.""" + ... - Unlike :attr:`args_cli.replay_start_delay_s`, which is wall-clock and has to be - tuned per-host, this wait is deterministic and self-adapting: it returns - immediately on a warm asset cache and waits exactly long enough on a cold one. + def summary(self) -> dict: + """Return the aggregated stats fragment for the run, embedded + as the ``"gpu_stats"`` value in the run's report dict.""" + ... + + +class NvmlGpuStatsProvider: + """NVML-backed :class:`GpuStatsProvider`. + + Snapshots GPU utilization (%) and used memory (MB) for one + device per :meth:`sample` call via ``pynvml``. Per-call cost is + <100 us so per-frame sampling is fine at any realistic frame + rate. Soft-fails when ``pynvml`` is missing or initialization + fails (no NVIDIA driver, etc.); :meth:`sample` is then a no-op + and :meth:`summary` reports the failure reason. Args: - max_wait_s: Upper bound on how long to spin on a non-zero loading count - before warning and returning. Acts as a safety net for misconfigured - scenes (missing assets, slow Nucleus); a successful run typically - completes well within this bound. + device_index: NVML device index (typically 0 for the + workstation's primary GPU). Defaults to 0. + + Summary shape on success:: + + { + "backend": "nvml", + "available": True, + "device_index": 0, + "device_name": "NVIDIA GeForce RTX 4090", + "memory_total_mb": 24564.0, + "utilization_percent": {}, + "memory_used_mb": {} + } + + On failure the ``"backend"`` / ``"available"`` fields are still + present plus a ``"reason"`` string. """ - try: - import omni.usd - except (ImportError, ModuleNotFoundError): - logger.warning("omni.usd not available; skipping deterministic stage-load wait") - return - - print("Waiting for USD stage to finish loading...") - start_s = time.monotonic() - last_progress_log_s = start_s - while simulation_app.is_running(): - context = omni.usd.get_context() - if context is None: - break - # get_stage_loading_status -> (message, count_loaded, count_loading) - _, _, count_loading = context.get_stage_loading_status() - if count_loading == 0: - break - elapsed_s = time.monotonic() - start_s - if elapsed_s >= max_wait_s: - logger.warning( - "Stage still reports %d assets pending after %.1fs; proceeding anyway. Replay may race the renderer.", - count_loading, - max_wait_s, - ) - break - if time.monotonic() - last_progress_log_s >= 5.0: - print(f" stage loading: {count_loading} assets pending (elapsed {elapsed_s:.1f}s)") - last_progress_log_s = time.monotonic() - simulation_app.update() - elapsed_s = time.monotonic() - start_s - print(f"Stage load complete after {elapsed_s:.1f}s; settling renderer for {_RENDERER_SETTLE_FRAMES} frames...") - for _ in range(_RENDERER_SETTLE_FRAMES): - if not simulation_app.is_running(): + def __init__(self, device_index: int = 0): + self._device_index = device_index + self._available = False + self._reason: str | None = None + self._device_name: str | None = None + self._memory_total_mb: float | None = None + self._util_samples: list[float] = [] + self._mem_used_samples_mb: list[float] = [] + try: + import pynvml + + self._pynvml = pynvml + pynvml.nvmlInit() + self._handle = pynvml.nvmlDeviceGetHandleByIndex(device_index) + self._memory_total_mb = pynvml.nvmlDeviceGetMemoryInfo(self._handle).total / (1024 * 1024) + # nvmlDeviceGetName returns bytes on older bindings and str on newer ones; coerce both. + name = pynvml.nvmlDeviceGetName(self._handle) + self._device_name = name.decode("utf-8") if isinstance(name, bytes) else str(name) + self._available = True + except ImportError: + self._reason = "pynvml not installed (`pip install nvidia-ml-py` to enable)" + except Exception as exc: + self._reason = f"NVML init failed: {exc}" + + def sample(self) -> None: + if not self._available: return - simulation_app.update() + try: + util = self._pynvml.nvmlDeviceGetUtilizationRates(self._handle) + mem = self._pynvml.nvmlDeviceGetMemoryInfo(self._handle) + self._util_samples.append(float(util.gpu)) + self._mem_used_samples_mb.append(mem.used / (1024 * 1024)) + except Exception: + # Transient NVML query failures shouldn't kill the replay loop; + # missing samples are reflected in the final ``n`` count. + pass + + def summary(self) -> dict: + if not self._available: + return {"backend": "nvml", "available": False, "reason": self._reason} + return { + "backend": "nvml", + "available": True, + "device_index": self._device_index, + "device_name": self._device_name, + "memory_total_mb": round(self._memory_total_mb, 1) if self._memory_total_mb is not None else None, + "utilization_percent": _compute_frame_stats(self._util_samples), + "memory_used_mb": _compute_frame_stats(self._mem_used_samples_mb), + } + + +def _build_report(args, all_runs: list[_RunStats]) -> dict: + """Build the structured JSON report dict from a list of completed runs.""" + outcomes_count = {"success": 0, "failure": 0, "incomplete": 0, "timeout": 0} + for r in all_runs: + outcomes_count[r.outcome] = outcomes_count.get(r.outcome, 0) + 1 + + total = max(len(all_runs), 1) + success_rate = outcomes_count.get("success", 0) / total + + run_dicts = [r.to_dict(i) for i, r in enumerate(all_runs)] + + return { + "schema_version": _STATS_SCHEMA_VERSION, + "task": args.task, + "replay_file": args.replay_file, + "num_replays": len(all_runs), + "outcomes": outcomes_count, + "success_rate": success_rate, + "runs": run_dicts, + "aggregate": _aggregate_runs(run_dicts), + } + + +def _aggregate_runs(run_dicts: list[dict]) -> dict: + """Aggregate per-run CPU / FPS stats across a multi-run batch. + + Returns ``mean_of_means``, ``p90_of_p90s``, etc. so reviewers can scan + a one-line summary without recomputing from individual runs. Runs that + produced no active frames (e.g. the recording never reached START) + contribute no samples and are skipped for that field; if no run had + samples the aggregate value is ``None``. + """ + + def _gather(key_path: list[str]) -> list[float]: + out: list[float] = [] + for run in run_dicts: + value = run + for key in key_path: + if value is None: + break + value = value.get(key) if isinstance(value, dict) else None + if isinstance(value, (int, float)): + out.append(float(value)) + return out + + def _mean_or_none(values: list[float]) -> float | None: + return statistics.fmean(values) if values else None + + def _min_or_none(values: list[float]) -> float | None: + return min(values) if values else None + + def _max_or_none(values: list[float]) -> float | None: + return max(values) if values else None + + return { + "cpu_frame_time_ms": { + "mean_of_means": _mean_or_none(_gather(["cpu_frame_time_ms", "mean"])), + "mean_of_p90s": _mean_or_none(_gather(["cpu_frame_time_ms", "p90"])), + "mean_of_p99s": _mean_or_none(_gather(["cpu_frame_time_ms", "p99"])), + "min_overall": _min_or_none(_gather(["cpu_frame_time_ms", "min"])), + "max_overall": _max_or_none(_gather(["cpu_frame_time_ms", "max"])), + }, + "fps": { + "mean_of_means": _mean_or_none(_gather(["fps", "mean"])), + }, + "gpu_stats": { + "utilization_percent": { + "mean_of_means": _mean_or_none(_gather(["gpu_stats", "utilization_percent", "mean"])), + "mean_of_p90s": _mean_or_none(_gather(["gpu_stats", "utilization_percent", "p90"])), + "max_overall": _max_or_none(_gather(["gpu_stats", "utilization_percent", "max"])), + }, + "memory_used_mb": { + "mean_of_means": _mean_or_none(_gather(["gpu_stats", "memory_used_mb", "mean"])), + "mean_of_p90s": _mean_or_none(_gather(["gpu_stats", "memory_used_mb", "p90"])), + "max_overall": _max_or_none(_gather(["gpu_stats", "memory_used_mb", "max"])), + }, + }, + } + + +def _print_stdout_summary(report: dict) -> None: + """Print a one-line-per-run summary plus an aggregate line to stdout.""" + runs = report.get("runs", []) + total = report.get("num_replays", len(runs)) + + def _fmt(value: float | None, suffix: str = "") -> str: + return f"{value:.2f}{suffix}" if isinstance(value, (int, float)) else "n/a" + + def _gpu_segment(gpu_stats: dict) -> str: + """Render a compact GPU summary suffix, or empty when unavailable.""" + if not isinstance(gpu_stats, dict) or not gpu_stats.get("available"): + return "" + util = gpu_stats.get("utilization_percent") or {} + mem = gpu_stats.get("memory_used_mb") or {} + return f" | gpu={_fmt(util.get('mean'), '%')} mem={_fmt(mem.get('max'), 'MB')}" + + print("--- Replay stats ---") + for run in runs: + idx = run["run_index"] + 1 + cpu = run["cpu_frame_time_ms"] + fps = run["fps"] + print( + f"Replay {idx}/{total}: outcome={run['outcome']}" + f" | frames={run['active_iterations']}" + f" | active={run['active_duration_s']:.2f}s" + f" | mean={_fmt(cpu['mean'], 'ms')}" + f" p90={_fmt(cpu['p90'], 'ms')}" + f" p99={_fmt(cpu['p99'], 'ms')}" + f" | mean_fps={_fmt(fps['mean'])}" + f"{_gpu_segment(run.get('gpu_stats', {}))}" + ) + + succ = report["outcomes"].get("success", 0) + agg = report["aggregate"] + agg_gpu = agg.get("gpu_stats", {}) + agg_util = agg_gpu.get("utilization_percent", {}) if isinstance(agg_gpu, dict) else {} + agg_mem = agg_gpu.get("memory_used_mb", {}) if isinstance(agg_gpu, dict) else {} + print( + f"Aggregate: success_rate={succ}/{total} ({report['success_rate']:.2f})" + f" | mean_fps={_fmt(agg['fps']['mean_of_means'])}" + f" | mean_p90={_fmt(agg['cpu_frame_time_ms']['mean_of_p90s'], 'ms')}" + f" | mean_gpu={_fmt(agg_util.get('mean_of_means'), '%')}" + f" | max_mem={_fmt(agg_mem.get('max_overall'), 'MB')}" + ) + + +def _write_json_report(path: str, report: dict) -> None: + """Persist the report to ``path`` as a UTF-8 JSON file (pretty-printed).""" + with open(path, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, sort_keys=False) + fh.write("\n") + print(f"Stats report written to {path}") + + +def _exit_code_for_outcomes(all_runs: list[_RunStats]) -> int: + """Map the worst-of-N replay outcome to a CI-friendly exit code. + + Precedence: ``timeout`` > ``failure`` > ``incomplete`` > ``success``. + Any single bad run fails the whole batch; timeouts get their own code + so CI can distinguish a perf cliff from a broken trajectory. + """ + if not all_runs: + return 1 + outcomes = {r.outcome for r in all_runs} + if outcomes <= {"success"}: + return 0 + if "timeout" in outcomes: + return 2 + return 1 # any "failure" or "incomplete" + + +def _resolve_cloudxr_env(value: str | None) -> str | None: + """Resolve ``--cloudxr_env`` shorthands to absolute ``.env`` file paths. + + Mirrors :func:`scripts.tools.record_demos._resolve_cloudxr_env` so the same + short names (``"cloudxrjs"``, ``"avp"``) behave identically on the + recording and replay sides. Accepts ``"none"`` / empty / ``None`` to mean + "no CloudXR" and otherwise returns the value unchanged. + """ + if value is None or value.strip() == "" or value.lower() == "none": + return None + if not _CLOUDXR_ENV_SHORTHANDS: + from isaaclab_teleop import CLOUDXR_AVP_ENV, CLOUDXR_JS_ENV + + _CLOUDXR_ENV_SHORTHANDS["cloudxrjs"] = CLOUDXR_JS_ENV + _CLOUDXR_ENV_SHORTHANDS["avp"] = CLOUDXR_AVP_ENV + return _CLOUDXR_ENV_SHORTHANDS.get(value.lower(), value) + + +def _maybe_launch_cloudxr(cloudxr_env_path: str | None, auto_launch: bool): + """Launch a CloudXR runtime owned at the agent (batch) scope. + + The CloudXR runtime is process-scoped, not session-scoped: tearing it + down between teleop sessions in the same Kit process severs Kit's + OpenXR runtime IPC, so the next session's ``xrCreateInstance`` fails + with ``XR_ERROR_RUNTIME_UNAVAILABLE`` and the XR pipeline hangs. To + support multi-run XR replay we hoist CloudXR ownership out of the + per-run :class:`~isaaclab_teleop.session_lifecycle.TeleopSessionLifecycle` + (which would otherwise stop it on every device teardown) into the + agent. Each replay's ``IsaacTeleopDevice`` is constructed with + ``auto_launch_cloudxr=False`` so the lifecycle leaves the runtime + alone; the agent terminates the launcher in its ``finally`` block. + + Mirrors the gating in :meth:`TeleopSessionLifecycle._ensure_cloudxr_runtime` + (``--cloudxr_env`` set, ``--auto_launch_cloudxr`` enabled, + ``ISAACLAB_CXR_SKIP_AUTOLAUNCH=1`` env var not set) so behavior parity + is preserved. + + Args: + cloudxr_env_path: Resolved CloudXR ``.env`` file path, or ``None`` + to skip launching. + auto_launch: Whether to honor the request (mirrors + ``--auto_launch_cloudxr``). + + Returns: + The launched ``CloudXRLauncher`` instance, or ``None`` when + nothing should be launched. Caller is responsible for calling + ``.stop()`` at the end of the batch. + """ + if cloudxr_env_path is None or not auto_launch: + return None + + if os.environ.get("ISAACLAB_CXR_SKIP_AUTOLAUNCH", "").strip() == "1": + logger.info("CloudXR auto-launch skipped (ISAACLAB_CXR_SKIP_AUTOLAUNCH=1)") + return None + + from isaacteleop.cloudxr import CloudXRLauncher + + launcher = CloudXRLauncher( + install_dir=str(Path.home() / ".cloudxr"), + env_config=cloudxr_env_path, + accept_eula=False, + ) + logger.info("CloudXR runtime launched (process-scoped, shared across replays)") + return launcher def _prepare_env_cfg(task: str, num_envs: int, device: str) -> tuple[ManagerBasedRLEnvCfg, object | None]: @@ -179,13 +842,23 @@ def _prepare_env_cfg(task: str, num_envs: int, device: str) -> tuple[ManagerBase explicitly via :func:`_process_success_condition`, gated by ``--num_success_steps``. This matches record_demos.py's pattern of manually counting consecutive success steps before resetting. - * Every other termination term -- including ``time_out`` and any - task-specific failure terms (e.g. ``object_dropping``, - ``object_too_far``) -- is left active. ``env.step`` then auto-invokes - ``_reset_idx`` for any env whose termination fires; the main loop - detects this via the returned ``terminated``/``truncated`` tensors - and completes the reset cycle (sim reinit + teleop device reset) - so Pink IK starts the next attempt with fresh articulation views. + * The ``time_out`` term is cleared for the same reason it is cleared in + :file:`scripts/tools/record_demos.py` and + :file:`scripts/imitation_learning/robomimic/play.py`: a recorded + trajectory often exceeds ``episode_length_s`` (pick-place is 20s by + default; a successful operator demo can easily run 25-30s). With the + term active, the env auto-truncates partway through the MCAP, resets + to the default pose, and the remainder of the recorded actions get + retargeted against the freshly-reset robot -- which manifests as + "robot moves correctly for a bit, then snaps back / acts wrong." + The recorder itself did not run with ``time_out`` enabled, so + reproducing record-time semantics requires clearing it here too. + * Other failure terms (e.g. ``object_dropping``, ``object_too_far``) + are left active. ``env.step`` then auto-invokes ``_reset_idx`` for any + env whose termination fires; the main loop detects this via the + returned ``terminated``/``truncated`` tensors and completes the reset + cycle (sim reinit + teleop device reset) so Pink IK starts the next + attempt with fresh articulation views. Returns: Tuple ``(env_cfg, success_term)``. ``success_term`` is ``None`` when @@ -207,73 +880,14 @@ def _prepare_env_cfg(task: str, num_envs: int, device: str) -> tuple[ManagerBase "No success termination term was found in the environment;" " success-driven resets will not fire during replay." ) + if hasattr(env_cfg.terminations, "time_out"): + env_cfg.terminations.time_out = None env_cfg = remove_camera_configs(env_cfg) env_cfg.sim.render.antialiasing_mode = "DLSS" return env_cfg, success_term -def _create_replay_teleop_device( - env_cfg: ManagerBasedRLEnvCfg, task: str, callbacks: dict[str, Callable[[], None]] -) -> DeviceBase: - """Instantiate the teleop device used during replay. - - Today this returns the legacy native XR ``handtracking`` device because the - XCR backend replays through Kit's OpenXR runtime, which is the surface - that device consumes. When migrating to a ``TeleopSession``-driven replay - backend, swap this for an ``IsaacTeleopDevice`` configured in replay mode. - - Args: - env_cfg: The environment configuration. - task: Task identifier, used for diagnostic messages. - callbacks: Teleop-command callbacks (typically just ``"START"`` for - replay; see :func:`main`) registered on the device. The XCR - replay dispatches the recorded user's start gesture through - Kit's OpenXR message bus, which the legacy - :class:`~isaaclab.devices.openxr.OpenXRDevice` translates into - calls into this dictionary. - """ - if not hasattr(env_cfg, "teleop_devices") or _LEGACY_DEVICE_NAME not in env_cfg.teleop_devices.devices: - raise ValueError( - f"Task '{task}' does not expose a teleop device named '{_LEGACY_DEVICE_NAME}'. " - "Use a task whose env config defines that legacy device, " - "or update _create_replay_teleop_device to use a different backend." - ) - teleop_interface = create_teleop_device(_LEGACY_DEVICE_NAME, env_cfg.teleop_devices.devices, callbacks) - if teleop_interface is None: - raise RuntimeError(f"Failed to create '{_LEGACY_DEVICE_NAME}' teleop device for task '{task}'.") - return teleop_interface - - -def _on_replay_driver_done(future: asyncio.Future) -> None: - """Surface replay-driver failures so the CI process does not hang. - - When :func:`start_xcr_replay` raises before reaching ``post_quit`` (e.g. - :class:`FileNotFoundError`, an ``omni.kit`` import failure, or a Kit - runtime error) the exception sits silently on the discarded future and - Python only emits a ``Future exception was never retrieved`` warning on - GC. The main loop would then keep spinning forever because nothing ever - flips ``simulation_app.is_running()`` to ``False``. - - This callback retrieves the exception, logs it with traceback, and asks - Kit to quit so the host process exits cleanly. It also drops the task - from :data:`_PENDING_REPLAY_TASKS` now that it is done. - """ - _PENDING_REPLAY_TASKS.discard(future) - if future.cancelled(): - return - exc = future.exception() - if exc is None: - return - logger.error("XCR replay driver failed", exc_info=exc) - try: - import omni.kit.app - - omni.kit.app.get_app().post_quit() - except Exception: - logger.exception("Failed to post_quit after replay driver failure") - - -def _handle_reset(env: gym.Env, teleop_interface: DeviceBase) -> None: +def _handle_reset(env: gym.Env, teleop_interface: IsaacTeleopDevice) -> None: """Run the full env+teleop reset cycle used by ``record_demos.py``. Mirrors :func:`scripts.tools.record_demos.handle_reset` (sans the @@ -320,77 +934,143 @@ def _process_success_condition( return success_step_count, False -def _schedule_replay_driver(replay_file: str, start_delay_s: float) -> None: - """Schedule the replay driver coroutine on the running asyncio loop. +_RENDERER_SETTLE_FRAMES: int = 30 +"""Number of additional render frames pumped after the USD stage finishes loading. - Today this drives Kit's OpenXR XCR backend. To migrate to a different - replay backend (e.g. ``TeleopSession`` running in replay mode), replace - this call with the equivalent driver hook -- this is the only XCR-specific - site outside the device-creation helper above. - """ - from isaaclab_teleop.automation import XcrReplayConfig, start_xcr_replay +Kit's stage-load status flips to ``count_loading == 0`` as soon as every referenced asset +has been resolved, but the renderer pipeline (shader compilation, articulation-view +binding, material warm-up) typically needs a few more event-loop ticks to converge. Thirty +frames at the default Kit render cadence is ~0.5 s on most machines and is deterministic +per-machine -- unlike a wall-clock delay it does not have to be tuned for hardware. +""" - future = asyncio.ensure_future( - start_xcr_replay(XcrReplayConfig(replay_file=replay_file, start_delay_s=start_delay_s)) - ) - _PENDING_REPLAY_TASKS.add(future) - future.add_done_callback(_on_replay_driver_done) - - -def main() -> None: - """Replay a captured teleop session against an Isaac Lab environment. - - Builds the env, attaches a replay teleop device, schedules the replay - driver as a background task, and runs the standard teleop step loop - until the application is closed (driver-issued ``post_quit``, Kit - shutdown, or operator interrupt). - - The loop deliberately does not call ``env.step()`` until the legacy - :class:`OpenXRDevice` dispatches a ``"START"`` callback. The XCR replay - restores the recorded user's start gesture through Kit's OpenXR message - bus, and the device routes that into the callback registered here -- - exactly the path ``record_demos.py`` uses to know when to start - recording. Until that ``"START"`` arrives, the OpenXR runtime is silent - and the device's :meth:`advance` would otherwise return a default zero - pose for both wrists, which stepping the env with would drive Pink IK - toward the world origin. - - Unlike :file:`record_demos.py`, the replay agent does **not** subscribe - to the ``"STOP"`` callback: Kit's ``teleop_command`` bus drains queued - events as a batch when the AR profile is enabled, so a recorded STOP - gesture fires within milliseconds of START and would gate the env-step - loop off again before Pink IK had time to converge. - Resource cleanup is wrapped in a ``try/finally`` so that ``env.close()`` - always runs, even when device construction or any subsequent setup - raises -- otherwise the USD stage would leak across CI runs. +def _wait_for_stage_load(simulation_app, max_wait_s: float) -> None: + """Block until the USD stage finishes resolving every referenced asset. + + Polls :meth:`omni.usd.UsdContext.get_stage_loading_status`. The third element of + the returned tuple is the count of assets Kit still has pending; when it reaches + zero the stage is fully streamed in and the renderer pipeline is ready to draw + against it. After the count reaches zero this function pumps an additional + :data:`_RENDERER_SETTLE_FRAMES` ``simulation_app.update()`` calls so shaders, + materials, and articulation views finish warming up before the caller begins + consuming MCAP frames or stepping the env. + + Args: + simulation_app: The :class:`isaaclab.app.SimulationApp` instance whose + event loop to pump while waiting. + max_wait_s: Upper bound on how long to spin on a non-zero loading count + before warning and returning. Acts as a safety net for misconfigured + scenes (missing assets, slow Nucleus); a successful run typically + completes well within this bound. + + The function is best-effort: when ``omni.usd`` is unavailable (e.g. when + running outside a Kit context) it returns immediately so callers do not + need a separate code path. """ - env: gym.Env | None = None try: - env_cfg, success_term = _prepare_env_cfg(args_cli.task, args_cli.num_envs, args_cli.device) - env = gym.make(args_cli.task, cfg=env_cfg).unwrapped + import omni.usd + except (ImportError, ModuleNotFoundError): + logger.warning("omni.usd not available; skipping deterministic stage-load wait") + return - # Single-element list so the closure can mutate it without ``nonlocal``. - teleop_active = [False] - - def _on_start() -> None: - if not teleop_active[0]: - teleop_active[0] = True - print("Teleop START received from XCR replay; forwarding actions to env.step().") - - # Intentionally only subscribe to START, not STOP. The XCR replay - # restores both the recorded user's start and stop gestures from the - # capture file, and Kit's ``teleop_command`` message bus appears to - # drain queued events as a batch when the AR profile is enabled -- - # so a STOP fires within milliseconds of START and would shut the env - # step loop off before Pink IK has had a chance to converge. For the - # replay agent's one-shot CI use case the only valid termination is - # the driver's ``post_quit`` (or a real exception in the loop). - callbacks: dict[str, Callable[[], None]] = {"START": _on_start} - - teleop_interface = _create_replay_teleop_device(env_cfg, args_cli.task, callbacks) + print("Waiting for USD stage to finish loading...") + start_s = time.monotonic() + last_progress_log_s = start_s + while simulation_app.is_running(): + context = omni.usd.get_context() + if context is None: + break + # get_stage_loading_status -> (message, count_loaded, count_loading) + _, _, count_loading = context.get_stage_loading_status() + if count_loading == 0: + break + elapsed_s = time.monotonic() - start_s + if elapsed_s >= max_wait_s: + logger.warning( + "Stage still reports %d assets pending after %.1fs; proceeding anyway. Replay may race the renderer.", + count_loading, + max_wait_s, + ) + break + if time.monotonic() - last_progress_log_s >= 5.0: + print(f" stage loading: {count_loading} assets pending (elapsed {elapsed_s:.1f}s)") + last_progress_log_s = time.monotonic() + simulation_app.update() + + elapsed_s = time.monotonic() - start_s + print(f"Stage load complete after {elapsed_s:.1f}s; settling renderer for {_RENDERER_SETTLE_FRAMES} frames...") + for _ in range(_RENDERER_SETTLE_FRAMES): + if not simulation_app.is_running(): + return + simulation_app.update() + + +def _run_single_replay( + env: gym.Env, + isaac_teleop_cfg, + success_term: object | None, + run_index: int, + total_runs: int, +) -> _RunStats: + """Run a single replay pass against ``env`` and return the per-run stats. + + Builds a fresh :class:`IsaacTeleopDevice` so each call re-opens the MCAP + reader at frame 0 -- exiting and re-entering the device's context manager + tears down the previous ``TeleopSession`` and constructs a new one. The + USD stage is left untouched; the caller (``main``) is responsible for + building / closing ``env`` once across the full multi-run batch. + + Per-frame sampling is restricted to iterations where ``env.step()`` + actually ran (post-START, pre-terminator). Pre-START render-only + frames, warmup ticks, and reset cycles do not contribute to the + returned stats so the numbers reflect the steady-state replay + workload rather than agent bookkeeping overhead. + + Args: + env: The (already built) Isaac Lab environment, shared across runs. + isaac_teleop_cfg: The :class:`IsaacTeleopCfg` extracted from + ``env_cfg.isaac_teleop``. + success_term: The original ``success`` termination term (or ``None``); + forwarded to :func:`_process_success_condition` each frame. + run_index: Zero-indexed run number within the multi-run batch. + Used to gate one-time work (the deterministic stage-load wait + only runs on ``run_index == 0``). + total_runs: Total number of runs in the batch; used only for log + framing ("Replay 1/5: ..."). + """ + stats = _RunStats() + + # Default NVML-backed provider samples GPU utilization + used + # memory once per active iteration. ``NvmlGpuStatsProvider`` + # soft-fails (the summary dict carries ``available: False`` and a + # reason) when ``nvidia-ml-py`` is missing or the driver is + # unreachable, so a missing GPU never blocks the replay. To swap + # in a renderer-specific provider (Kit viewport telemetry, Newton, + # ...) implement the :class:`GpuStatsProvider` Protocol and + # construct it here in place of :class:`NvmlGpuStatsProvider`. + gpu_stats_provider: GpuStatsProvider = NvmlGpuStatsProvider() + if run_index == 0 and not getattr(gpu_stats_provider, "_available", False): + print(f"[GPU stats] disabled: {getattr(gpu_stats_provider, '_reason', 'unknown reason')}") + + # CloudXR is owned by the agent (see ``_maybe_launch_cloudxr`` in + # ``main``), so the per-run lifecycle must not try to launch -- or + # stop -- it. ``cloudxr_env_file=None`` + ``auto_launch_cloudxr=False`` + # short-circuits ``TeleopSessionLifecycle._ensure_cloudxr_runtime`` so + # the runtime survives across replays and Kit's OpenXR session keeps + # its IPC connection. + teleop_interface = create_isaac_teleop_device( + isaac_teleop_cfg, + sim_device=args_cli.device, + callbacks={}, + cloudxr_env_file=None, + auto_launch_cloudxr=False, + mcap_replay_path=args_cli.replay_file, + ) + if run_index == 0: print(f"Using teleop device: {teleop_interface}") + with teleop_interface: # Mirror the reset sequence used by ``record_demos.py``: ``sim.reset()`` # does a hard physics reinit (re-binds articulation views, plays the # timeline) that ``env.reset()`` alone does not perform. Pink IK reads @@ -402,63 +1082,320 @@ def _on_start() -> None: env.reset() teleop_interface.reset() - # Deterministic warmup: block until omni.usd reports zero pending - # assets, then pump a fixed number of renderer-settle frames. This - # is independent of ``--replay_start_delay_s``; the wall-clock delay - # below covers the XCR-side OpenXR profile warm-up, while this wait - # ensures the stage is fully streamed in before the XCR replay - # injects its first recorded pose. - _wait_for_stage_load() - - print(f"Replay agent started; replay will begin in {args_cli.replay_start_delay_s:.1f} seconds.") - _schedule_replay_driver(args_cli.replay_file, args_cli.replay_start_delay_s) - + # Deterministic warmup is only required on the first run -- once the + # stage has been streamed in and the renderer settled, subsequent + # runs share the same stage and only need the per-run ``env.sim.reset`` + # above. Skipping it on later runs saves the renderer-settle frames + # at the cost of doing nothing measurable (the wait returns + # immediately on a fully-loaded stage anyway). + if run_index == 0: + _wait_for_stage_load(simulation_app, args_cli.max_stage_load_wait_s) + + # Optional extra wall-clock buffer on top of the deterministic + # wait. Useful as an escape hatch when the deterministic check + # is not enough (e.g. very slow shader compilation paths). + if args_cli.replay_start_delay_s > 0: + print( + f"Additional warmup buffer: rendering for {args_cli.replay_start_delay_s:.1f}s" + " before consuming MCAP frames." + ) + buffer_start_s = time.monotonic() + while simulation_app.is_running() and time.monotonic() - buffer_start_s < args_cli.replay_start_delay_s: + env.sim.render() + + print( + f"Replay {run_index + 1}/{total_runs} started; replaying MCAP from {args_cli.replay_file}" + f" (max_replay_duration_s={args_cli.max_replay_duration_s:.1f})." + ) + teleop_active = False + teleop_was_active = False # only terminate on STOP after a real START success_step_count = 0 + replay_start_s = time.monotonic() + # First time we run env.step on this replay; used to bound the + # active duration that drives mean-FPS. Stays None until a sample + # is recorded so render-only / pre-START frames don't widen the + # window. + active_start_s: float | None = None + # End-of-active-window timestamp. Updated each sampled iteration + # so the active duration is always "first active iter -> last + # active iter" even when a terminator fires mid-loop and the + # subsequent renders are excluded. + last_active_end_s: float | None = None + while simulation_app.is_running(): try: with torch.inference_mode(): + # Wall-clock safety cap. Only hit when the recording + # never reaches a natural terminator -- e.g. it omits + # an operator STOP AND the env's success/failure + # terms never fire within the configured window. A + # clean ``record_demos.py``-style capture exits on + # the success edge well before this triggers. + elapsed_s = time.monotonic() - replay_start_s + if elapsed_s >= args_cli.max_replay_duration_s: + print(f"Replay reached max_replay_duration_s={args_cli.max_replay_duration_s:.1f}; ending run.") + stats.outcome = "timeout" + break + action = teleop_interface.advance() - if action is None or not teleop_active[0]: + ctrl = poll_control_events(teleop_interface) + + # Track active state from the replayed _teleop_control + # channel. ``ctrl.is_active`` follows the same shape + # that ``record_demos.py`` and ``teleop_se3_agent.py`` + # consume; None means "no transition this frame." + prev_active = teleop_active + if ctrl.is_active is not None: + teleop_active = ctrl.is_active + if teleop_active: + teleop_was_active = True + + # End-of-run on the first STOP edge after a real + # START -- the operator pressed Stop during + # recording, and ``ReplayMessageChannelTrackerImpl`` + # surfaces that payload at the same recording-frame + # index it was captured at. Per-frame tracker EOF on + # its own does NOT trigger this branch: + # :class:`TeleopMessageProcessor` keeps emitting + # valid False booleans for KILL / RUN_TOGGLE / RESET + # after the message-channel MCAP exhausts, so the + # state manager stays in its last state and + # ``ctrl.is_active`` does not flip. Recordings + # without an operator STOP are terminated instead by + # the success / failure / wall-clock terminators + # below. + if prev_active and not teleop_active and teleop_was_active: + print("Replay end observed (STOP edge); ending run.") + break + + if ctrl.should_reset: + _handle_reset(env, teleop_interface) + success_step_count = 0 + continue + + # Gate stepping on the active state (mirrors + # teleop_se3_agent.py:309-328). Pre-START operator + # setup frames render only; the recorded START flips + # us into the stepping branch. + if action is None or not teleop_active: env.sim.render() continue + + # Sample CPU frame time across the env.step call only + # (the active-frame window the stats report covers). + iter_start_s = time.perf_counter() + if active_start_s is None: + active_start_s = iter_start_s actions = action.repeat(env.num_envs, 1) _, _, terminated, truncated, _ = env.step(actions) - - # Failure path: ``env.step`` already invoked ``_reset_idx`` - # for any env whose ``time_out`` or task-specific failure - # term fired (success was extracted up front so it does - # not show up here). We still need to refresh sim physics - # state and the teleop device so Pink IK starts the next - # attempt with fresh articulation views. + iter_end_s = time.perf_counter() + stats.active_frame_times_ms.append((iter_end_s - iter_start_s) * 1000.0) + last_active_end_s = iter_end_s + + # Snapshot GPU state right after env.step so the + # sample reflects the active workload (post-render + # for that frame). Provider is cheap (~50 us for + # NVML, no-op when disabled). + gpu_stats_provider.sample() + + # Failure path: ``env.step`` already invoked + # ``_reset_idx`` for any env whose task-specific + # failure term fired (``time_out`` was cleared by + # ``_prepare_env_cfg``; ``success`` is handled + # below). + # + # Replay-specific behavior: a failure mid-trajectory + # means the recorded demo did not reproduce -- the + # operator has no agency to recover here, so the + # rest of the MCAP would just feed retargeted + # actions to a freshly-reset env, which is not + # meaningful replay. End the run with a failure + # outcome so the batch's exit code reflects it. if bool(terminated.any().item()) or bool(truncated.any().item()): - print("Failure condition met (terminated/timed-out); resetting env.") - _handle_reset(env, teleop_interface) - success_step_count = 0 - continue - - # Success path: success_term was cleared from the env cfg - # so ``env.step`` does not auto-reset on it. Mirror - # record_demos.py and trigger a reset only after the - # success condition has held for ``num_success_steps`` - # consecutive steps. + print("Replay failure: env terminated/truncated mid-trajectory; ending run.") + stats.outcome = "failure" + break + + # Success path: ``success_term`` was cleared from the + # env cfg so ``env.step`` does not auto-reset on it. + # ``_process_success_condition`` consults the original + # success term and reports when it has held for + # ``--num_success_steps`` consecutive steps. + # + # Replay-specific behavior: success is the natural + # end-of-replay for ``record_demos.py``-style single + # episode captures, so end the run here instead of + # invoking ``_handle_reset`` like the live agent + # does. The alternative -- resetting and continuing + # into the post-success MCAP tail -- would just + # replay operator wind-down frames (controller + # releases, idle motion before they hit Stop on + # recording), which is not meaningful demo data and + # quickly exhausts the per-frame tracker streams + # anyway. success_step_count, reset_on_success = _process_success_condition( env, success_term, success_step_count, args_cli.num_success_steps ) if reset_on_success: - _handle_reset(env, teleop_interface) - success_step_count = 0 + print("Recorded demo succeeded; ending run.") + stats.outcome = "success" + break except Exception: # ``logger.exception`` preserves the full traceback; bare - # ``logger.error`` would only log the message. + # ``logger.error`` would only log the message. Classify as + # ``failure`` so the per-run outcome and the batch exit + # code reflect that the recorded trajectory did not + # complete -- staying at the default ``incomplete`` would + # silently mask a crash mid-replay in CI reports. logger.exception("Error during simulation step") + stats.outcome = "failure" + break + + # Stamp the active window duration. Falls back to 0.0 when no env + # step ever ran (recording never reached START, or terminated before + # the first active frame). + if active_start_s is not None and last_active_end_s is not None: + stats.active_duration_s = last_active_end_s - active_start_s + stats.success_step_count = success_step_count + stats.gpu_stats = gpu_stats_provider.summary() + return stats + + +def main() -> int: + """Replay a captured Isaac Teleop session against an Isaac Lab environment. + + Builds the env once, then loops :func:`_run_single_replay` for + ``--num_replays`` iterations. Each iteration builds a fresh + :class:`IsaacTeleopDevice` (so the MCAP reader reopens at frame 0) and + resets the env in place; the USD stage stays loaded between runs so + multi-run batches start essentially instantly. + + Per-replay control flow (see :func:`_run_single_replay` for details): + * Pre-loop warmup: ``_wait_for_stage_load`` polls + ``omni.usd.UsdContext.get_stage_loading_status`` until Kit + reports zero pending assets, then renders a fixed number of + settle frames (only on ``run_index == 0``). An optional + ``--replay_start_delay_s`` buffer can be appended for hardware + that needs more grace. ``advance()`` is not called during + warmup so ``ReplaySession.update`` does not consume MCAP + frames yet. + * Main loop: :meth:`IsaacTeleopDevice.advance` returns an action + tensor derived from the MCAP-replayed tracker stream and + :func:`poll_control_events` returns the START / STOP / RESET + edges replayed from the ``_teleop_control`` channel. The env + steps only when ``ctrl.is_active`` is True, mirroring + ``teleop_se3_agent.py`` and ``record_demos.py`` exactly -- + pre-START operator-setup frames render only. + * End-of-replay terminators (any of these breaks the inner + loop and sets ``_RunStats.outcome`` accordingly; the function + returns and the outer batch driver moves to the next replay): + 1. Replayed STOP edge from ``_teleop_control`` -- the + operator pressed Stop during recording. Does not + overwrite ``outcome``: if success fired earlier in this + run, the outcome stays ``"success"``; otherwise it stays + ``"incomplete"``, since stopping without reaching + success is not a successful reproduction. + 2. Success condition met for ``--num_success_steps`` + consecutive steps -- the natural end of a + ``record_demos.py`` single-episode capture. Sets + outcome ``"success"``. ``_handle_reset`` is intentionally + skipped here because the post-success MCAP tail is + operator wind-down, not demo data. + 3. ``env.step`` ``terminated`` / ``truncated`` -- a task- + specific failure term fired during the recorded + trajectory. Sets outcome ``"failure"``. + 4. Wall-clock ``--max_replay_duration_s`` safety cap. Sets + outcome ``"timeout"``. + + Kit itself is left running between replays so a fresh + :class:`IsaacTeleopDevice` can be constructed without reloading + the USD stage; ``__main__`` calls ``simulation_app.close()`` + after the whole batch finishes. + + Stats output: + Each iteration where ``env.step()`` ran contributes one CPU + frame-time sample (``perf_counter`` delta in ms). At the end of + the run the samples are summarised into mean / p50 / p90 / p95 / + p99 / min / max / stddev + a mean / instantaneous-min / max FPS + triple, then serialised into the ``runs[]`` array of the report + dict. ``--stats_output_file`` controls whether the dict is + persisted to disk; a one-line-per-run stdout summary is always + printed. See the module docstring for the full JSON schema. + + Resource cleanup is wrapped in a ``try/finally`` so that ``env.close()`` + always runs, even when device construction or any subsequent setup + raises -- otherwise the USD stage would leak across CI runs. + + Returns: + The host process exit code, mapped from the worst-of-N outcome + across the multi-run batch: ``0`` if every run's + ``success_term`` fired, ``2`` if any run hit + ``--max_replay_duration_s``, otherwise ``1`` (any failure or + incomplete run). + """ + env: gym.Env | None = None + cloudxr_launcher = None + all_runs: list[_RunStats] = [] + + if args_cli.num_replays < 1: + raise ValueError(f"--num_replays must be >= 1; got {args_cli.num_replays}") + + try: + # CloudXR launch is hoisted to the agent (batch scope) so it + # survives across per-run device teardown; per-run lifecycles + # are explicitly told not to launch / stop it (see the + # ``cloudxr_env_file=None, auto_launch_cloudxr=False`` call in + # ``_run_single_replay``). This is what lets ``--num_replays > 1`` + # work in XR-active mode without losing Kit's OpenXR runtime + # IPC between runs. + cloudxr_launcher = _maybe_launch_cloudxr( + _resolve_cloudxr_env(args_cli.cloudxr_env), args_cli.auto_launch_cloudxr + ) + + env_cfg, success_term = _prepare_env_cfg(args_cli.task, args_cli.num_envs, args_cli.device) + + if not hasattr(env_cfg, "isaac_teleop") or env_cfg.isaac_teleop is None: + raise ValueError( + f"Task '{args_cli.task}' does not configure an IsaacTeleop pipeline. " + "MCAP replay requires env_cfg.isaac_teleop to be set." + ) + + env = gym.make(args_cli.task, cfg=env_cfg).unwrapped + + for run_idx in range(args_cli.num_replays): + run_stats = _run_single_replay( + env=env, + isaac_teleop_cfg=env_cfg.isaac_teleop, + success_term=success_term, + run_index=run_idx, + total_runs=args_cli.num_replays, + ) + all_runs.append(run_stats) + print(f"Replay {run_idx + 1}/{args_cli.num_replays} outcome: {run_stats.outcome}") + if not simulation_app.is_running(): + # Kit was closed externally mid-batch; stop the outer loop + # rather than spawning a fresh device against a dead app. break finally: if env is not None: env.close() print("Environment closed") + if cloudxr_launcher is not None: + try: + cloudxr_launcher.stop() + logger.info("CloudXR runtime stopped (end of batch)") + except Exception: + logger.exception("Failed to stop CloudXR launcher cleanly") + + report = _build_report(args_cli, all_runs) + _print_stdout_summary(report) + if args_cli.stats_output_file is not None: + _write_json_report(args_cli.stats_output_file, report) + return _exit_code_for_outcomes(all_runs) if __name__ == "__main__": - main() + exit_code = main() simulation_app.update() simulation_app.close() + sys.exit(exit_code) diff --git a/scripts/tools/record_demos.py b/scripts/tools/record_demos.py index 0cac0b59284b..abe574ab3a81 100644 --- a/scripts/tools/record_demos.py +++ b/scripts/tools/record_demos.py @@ -79,6 +79,18 @@ default=True, help="Auto-launch the CloudXR runtime when --cloudxr_env is set. Use --no-auto_launch_cloudxr to disable.", ) +parser.add_argument( + "--mcap_record_path", + type=str, + default=None, + help=( + "Debug-only: write the live IsaacTeleop session to this MCAP file (one continuous file for the whole run)." + " Intended for pairing with teleop_replay_agent.py in CI -- NOT a data-generation format. MCAPs produced" + " here lack per-episode segmentation, world-frame anchor state, env reset state, and have no public Python" + " decoder. For data-gen workflows use the HDF5 dataset path (default). Ignored when the IsaacTeleop stack" + " is not in use." + ), +) # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) @@ -326,7 +338,10 @@ def setup_teleop_device(callbacks: dict[str, Callable], use_isaac_teleop: bool = callbacks=callbacks, cloudxr_env_file=_resolve_cloudxr_env(args_cli.cloudxr_env), auto_launch_cloudxr=args_cli.auto_launch_cloudxr, + mcap_record_path=args_cli.mcap_record_path, ) + if args_cli.mcap_record_path is not None: + logger.info("Recording live IsaacTeleop session to MCAP (debug-only): %s", args_cli.mcap_record_path) elif teleop_device_explicitly_set: device_name = args_cli.teleop_device diff --git a/source/isaaclab_tasks/changelog.d/rwiltz-mcap-replay-agent.minor.rst b/source/isaaclab_tasks/changelog.d/rwiltz-mcap-replay-agent.minor.rst new file mode 100644 index 000000000000..2760c4bfeab6 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/rwiltz-mcap-replay-agent.minor.rst @@ -0,0 +1,12 @@ +Changed +^^^^^^^ + +* **Breaking:** Removed the lazy legacy ``teleop_devices`` (``handtracking`` / ``manusvive``) + accessor on + :class:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.PickPlaceGR1T2EnvCfg`. + The env still exposes ``isaac_teleop`` (an :class:`~isaaclab_teleop.IsaacTeleopCfg`), which is + what the in-tree teleoperation, recording, and replay scripts use by default. Consumers that + read ``env_cfg.teleop_devices`` directly to build a legacy + :class:`~isaaclab.devices.openxr.OpenXRDevice` should construct it themselves or migrate to + :class:`~isaaclab_teleop.IsaacTeleopDevice` (see ``scripts/environments/teleoperation/teleop_se3_agent.py`` + for the migrated pattern). diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py index 5789ecda2031..c736375ab96e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py @@ -607,46 +607,3 @@ def __post_init__(self): sim_device=self.sim.device, xr_cfg=self.xr, ) - - # Legacy teleop devices are built lazily via __getattr__ to avoid - # importing runtime-only modules (carb, pxr) at config-load time. - del self.teleop_devices - - def __getattr__(self, name: str): - if name == "teleop_devices": - from isaaclab.devices.device_base import DevicesCfg # noqa: PLC0415 - from isaaclab.devices.openxr import ManusViveCfg, OpenXRDeviceCfg # noqa: PLC0415 - from isaaclab.devices.openxr.retargeters.humanoid.fourier.gr1t2_retargeter import ( # noqa: PLC0415 - GR1T2RetargeterCfg, - ) - - self.teleop_devices = DevicesCfg( - devices={ - "handtracking": OpenXRDeviceCfg( - retargeters=[ - GR1T2RetargeterCfg( - enable_visualization=True, - num_open_xr_hand_joints=2 * 26, - sim_device=self.sim.device, - hand_joint_names=self.actions.upper_body_ik.hand_joint_names, - ), - ], - sim_device=self.sim.device, - xr_cfg=self.xr, - ), - "manusvive": ManusViveCfg( - retargeters=[ - GR1T2RetargeterCfg( - enable_visualization=True, - num_open_xr_hand_joints=2 * 26, - sim_device=self.sim.device, - hand_joint_names=self.actions.upper_body_ik.hand_joint_names, - ), - ], - sim_device=self.sim.device, - xr_cfg=self.xr, - ), - } - ) - return self.teleop_devices - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") diff --git a/source/isaaclab_teleop/changelog.d/rwiltz-mcap-replay-agent.minor.rst b/source/isaaclab_teleop/changelog.d/rwiltz-mcap-replay-agent.minor.rst new file mode 100644 index 000000000000..4fa6c2501f49 --- /dev/null +++ b/source/isaaclab_teleop/changelog.d/rwiltz-mcap-replay-agent.minor.rst @@ -0,0 +1,36 @@ +Added +^^^^^ + +* Added MCAP record/replay support to :class:`~isaaclab_teleop.IsaacTeleopDevice` via new + ``mcap_record_path`` and ``mcap_replay_path`` parameters on + :func:`~isaaclab_teleop.create_isaac_teleop_device` (mutually exclusive). ``mcap_replay_path`` + switches the underlying :class:`isacteleop.teleop_session_manager.TeleopSession` into + :class:`SessionMode.REPLAY` and feeds the recorded tracker stream through the configured + retargeting pipeline; ``mcap_record_path`` is a debug-grade knob that writes the live session + to a single continuous MCAP file for pairing with the replay agent in CI. It is **not** a + data-generation format -- the produced MCAP has no per-episode segmentation, no world-frame + anchor state, no env reset state, and no public Python decoder. +* Added a ``--mcap_record_path`` (debug-only) flag to ``scripts/tools/record_demos.py`` that + forwards into :func:`~isaaclab_teleop.create_isaac_teleop_device` when the IsaacTeleop stack + is in use. +* Added ``scripts/environments/teleoperation/teleop_replay_agent.py``, a non-interactive entry + point used by CI to replay captured Isaac Teleop sessions against an Isaac Lab environment. + The agent gates env stepping on :func:`~isaaclab_teleop.poll_control_events` so the recorded + START / STOP / RESET boundaries reproduce the original recording's pacing, and asks Kit to + ``post_quit`` on the first STOP-edge after teleop has been active so the host process exits + deterministically. + +Changed +^^^^^^^ + +* **Breaking:** Removed the ``isaaclab_teleop.automation`` subpackage, including + ``XcrReplayConfig`` and ``start_xcr_replay``. The XCR backend was a transitional Kit-level + OpenXR capture/replay path that pre-dated Isaac Teleop's native MCAP record/replay. Replays + now go through ``teleop_replay_agent.py`` against an MCAP capture produced by Isaac Teleop. +* **Breaking:** Removed the lazy legacy ``teleop_devices`` (``handtracking`` / ``manusvive``) + accessor on + :class:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.PickPlaceGR1T2EnvCfg`. + All in-tree scripts (``teleop_se3_agent.py``, ``record_demos.py``, ``teleop_replay_agent.py``) + prefer ``env_cfg.isaac_teleop``; consumers that built the legacy + :class:`~isaaclab.devices.openxr.OpenXRDevice` directly from the env config should construct + it themselves or migrate to :class:`~isaaclab_teleop.IsaacTeleopDevice`. diff --git a/source/isaaclab_teleop/isaaclab_teleop/automation/__init__.py b/source/isaaclab_teleop/isaaclab_teleop/automation/__init__.py deleted file mode 100644 index 8619b26fb42c..000000000000 --- a/source/isaaclab_teleop/isaaclab_teleop/automation/__init__.py +++ /dev/null @@ -1,8 +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 - -from .xcr_replay import XcrReplayConfig, start_xcr_replay - -__all__ = ["XcrReplayConfig", "start_xcr_replay"] diff --git a/source/isaaclab_teleop/isaaclab_teleop/automation/xcr_replay.py b/source/isaaclab_teleop/isaaclab_teleop/automation/xcr_replay.py deleted file mode 100644 index e68842521057..000000000000 --- a/source/isaaclab_teleop/isaaclab_teleop/automation/xcr_replay.py +++ /dev/null @@ -1,138 +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 - -"""Internal XCR replay driver used by ``teleop_replay_agent.py``. - -Schedules a Kit ``omni.kit.xr.core`` XR Capture Replay against an already -running Kit application. This is a transitional implementation; the intended -long-term replacement drives playback through an Isaac Teleop -``TeleopSession`` rather than through Kit's OpenXR XCR backend. - -All Kit imports are deferred to :func:`start_xcr_replay` so importing this -module outside of a running Kit application is safe. -""" - -from __future__ import annotations - -import asyncio -import logging -import os -from dataclasses import dataclass - -logger = logging.getLogger(__name__) - - -@dataclass -class XcrReplayConfig: - """Configuration for an XCR replay automation run. - - Args: - replay_file: Absolute path to the ``.bin`` XCR capture to replay. - profile_name: Name of the Kit XR profile to enable for replay. The - CI pipelines use ``"ar"``. - start_delay_s: Seconds to wait after the environment is up before - starting replay. Gives the simulation time to settle so initial - warm-up frames do not skew metrics. - quit_on_complete: When ``True``, call - :meth:`omni.kit.app.IApp.post_quit` once replay finishes so the - host CI process exits cleanly. - max_replay_duration_s: Upper bound on how long the coroutine will - wait for ``xcr_player`` to clear its playback subscription. If - replay never finishes (e.g. Kit-side bug, captured session - never emits a stop event), the coroutine returns after this - many seconds so CI does not hang indefinitely. - """ - - replay_file: str - profile_name: str = "ar" - start_delay_s: float = 120.0 - quit_on_complete: bool = True - max_replay_duration_s: float = 3600.0 - - -async def start_xcr_replay(cfg: XcrReplayConfig) -> None: - """Drive an XCR replay against the currently running Kit application. - - This coroutine is intended to be scheduled (e.g. via - :func:`asyncio.ensure_future`) from a host CI script after the teleop - environment has been created. It mirrors the original - ``xcr_perf_automation.run_xcr_replay`` flow used by the ``teleop-cicd`` - pipeline so captured CI metrics remain comparable across the patch - migration. - - Args: - cfg: Replay configuration. The replay file must exist on disk. - - Raises: - FileNotFoundError: If :attr:`XcrReplayConfig.replay_file` does not - exist when the coroutine starts. - """ - if not os.path.exists(cfg.replay_file): - raise FileNotFoundError(f"XCR replay file not found: {cfg.replay_file}") - - import carb.settings - import omni.kit.app - import omni.kit.xr.core.test_utils as test_utils - from omni.kit.xr.core import XRCore - from omni.kit.xr.core.recorder._xr_xcr import XCRReplayAPI - from omni.kit.xr.core.recorder.scripts import xcr_player - from omni.kit.xr.core.recorder.scripts.xcr_player import start_replay_if_enabled - - settings = carb.settings.get_settings() - - await omni.kit.app.get_app().next_update_async() - - settings.set("/xr/system/openxr/xcr/capture/enabled", False) - settings.set("/xr/system/openxr/xcr/replay/enabled", True) - settings.set("/xr/system/openxr/xcr/replay/replayFile", cfg.replay_file) - settings.set(f"/xr/profile/{cfg.profile_name}/system/display", "OpenXR") - - XRCore.get_singleton().get_profile(cfg.profile_name) - - # Construct the replay API so the runtime registers the replay backend - # before start_replay_if_enabled() is called. Bind to a local so the - # object stays alive for the lifetime of the coroutine in case any - # internal subscription is tied to the instance lifetime. - _replay_api = XCRReplayAPI() # noqa: F841 - - logger.info("XCR replay: waiting %.1f seconds before starting replay", cfg.start_delay_s) - await asyncio.sleep(cfg.start_delay_s) - logger.info("XCR replay: starting replay from %s", cfg.replay_file) - - start_replay_if_enabled() - - # Pump a couple of frames so the replay service is fully initialized - # before the AR profile is enabled. - await omni.kit.app.get_app().next_update_async() - await omni.kit.app.get_app().next_update_async() - - logger.info("XCR replay: enabling XR profile %s", cfg.profile_name) - async with test_utils.EnabledXRProfile(cfg.profile_name, 0): - logger.info("XCR replay: XR profile enabled, replay should be playing") - - # The xcr_player module clears its playback subscription when replay - # finishes; that is the public-ish signal we have for completion. - # Polling a private attribute is fragile (it may be renamed or - # removed in future Kit versions); the bounded wait below keeps a - # stuck poll from hanging the CI job if that ever happens. - poll_interval_s = 5.0 - elapsed_s = 0.0 - while xcr_player._xcr_playback_subscription is not None: - if elapsed_s >= cfg.max_replay_duration_s: - logger.warning( - "XCR replay: timed out after %.1fs waiting for playback to complete; aborting wait.", - cfg.max_replay_duration_s, - ) - break - logger.debug("XCR replay: waiting for playback subscription to clear") - await asyncio.sleep(poll_interval_s) - elapsed_s += poll_interval_s - - await omni.kit.app.get_app().next_update_async() - - if cfg.quit_on_complete: - omni.kit.app.get_app().post_quit() - - logger.info("XCR replay: finished") diff --git a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py index 3f8c565a7e21..ba939718c521 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py +++ b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py @@ -105,6 +105,8 @@ def __init__( cfg: IsaacTeleopCfg, cloudxr_env_file: str | None = None, auto_launch_cloudxr: bool = True, + mcap_record_path: str | None = None, + mcap_replay_path: str | None = None, ): """Initialize the IsaacTeleop device. @@ -117,6 +119,16 @@ def __init__( auto_launch_cloudxr: Whether to auto-launch the CloudXR runtime when *cloudxr_env_file* is set. Ignored when *cloudxr_env_file* is ``None``. + mcap_record_path: Optional MCAP file path to record the live + teleop session into. Mutually exclusive with + *mcap_replay_path*. Debug-grade only -- the produced file + has no per-episode segmentation, no world-frame anchor, and + no public Python decoder. + mcap_replay_path: Optional MCAP file path to replay. When set, + the device runs in :class:`SessionMode.REPLAY` with no live + XR connection and feeds the recorded tracker stream + through the pipeline. Mutually exclusive with + *mcap_record_path*. """ self._cfg = cfg @@ -126,6 +138,8 @@ def __init__( cfg, cloudxr_env_file=cloudxr_env_file, auto_launch_cloudxr=auto_launch_cloudxr, + mcap_record_path=mcap_record_path, + mcap_replay_path=mcap_replay_path, ) self._prev_right_a_pressed = False @@ -411,6 +425,8 @@ def create_isaac_teleop_device( callbacks: dict[str, Callable] | None = None, cloudxr_env_file: str | None = None, auto_launch_cloudxr: bool = True, + mcap_record_path: str | None = None, + mcap_replay_path: str | None = None, ) -> IsaacTeleopDevice: """Create an :class:`IsaacTeleopDevice` with required Omniverse extension setup. @@ -418,7 +434,9 @@ def create_isaac_teleop_device( before constructing an :class:`IsaacTeleopDevice`: 1. Disable default OpenXR input bindings (prevents conflicts). - 2. Enable the ``isaacsim.kit.xr.teleop.bridge`` extension. + 2. Enable the ``isaacsim.kit.xr.teleop.bridge`` extension (live mode + only -- replay mode skips this since it never touches the XR + runtime). 3. Optionally override :attr:`IsaacTeleopCfg.sim_device` so action tensors land on the same device the caller uses for the simulation. @@ -440,21 +458,42 @@ def create_isaac_teleop_device( when *cloudxr_env_file* is set. Set to ``False`` to skip the launch (e.g. when running the runtime externally). Ignored when *cloudxr_env_file* is ``None``. + mcap_record_path: Optional MCAP file path to record the live teleop + session into. Debug-grade only. Mutually exclusive with + *mcap_replay_path*. + mcap_replay_path: Optional MCAP file path to replay. When set, the + returned device runs in :class:`SessionMode.REPLAY` and the XR + teleop bridge is left untouched. Mutually exclusive with + *mcap_record_path*. Returns: A fully configured :class:`IsaacTeleopDevice` ready for use in a ``with`` block. """ - _enable_teleop_bridge() + if mcap_record_path is not None and mcap_replay_path is not None: + raise ValueError( + "mcap_record_path and mcap_replay_path are mutually exclusive; " + "set at most one to switch between LIVE recording and REPLAY playback." + ) + + # Replay sessions never talk to Kit's XR bridge, so loading/enabling the + # bridge extension would only add startup latency and noisy log lines. + if mcap_replay_path is None: + _enable_teleop_bridge() if sim_device is not None: cfg.sim_device = sim_device - logger.info("Using IsaacTeleop stack for teleoperation") + if mcap_replay_path is not None: + logger.info("Using IsaacTeleop stack for teleoperation (REPLAY mode)") + else: + logger.info("Using IsaacTeleop stack for teleoperation") device = IsaacTeleopDevice( cfg, cloudxr_env_file=cloudxr_env_file, auto_launch_cloudxr=auto_launch_cloudxr, + mcap_record_path=mcap_record_path, + mcap_replay_path=mcap_replay_path, ) if callbacks is not None: diff --git a/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py b/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py index 9c3a7813cd81..b0189797fd6f 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py +++ b/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py @@ -115,6 +115,8 @@ def __init__( cfg: IsaacTeleopCfg, cloudxr_env_file: str | None = None, auto_launch_cloudxr: bool = True, + mcap_record_path: str | None = None, + mcap_replay_path: str | None = None, ): """Initialize the session lifecycle manager. @@ -127,11 +129,35 @@ def __init__( auto_launch_cloudxr: Whether to auto-launch the CloudXR runtime when *cloudxr_env_file* is set. Ignored when *cloudxr_env_file* is ``None``. + mcap_record_path: Optional path to an MCAP file the live teleop + session should be recorded into. Mutually exclusive with + *mcap_replay_path*. Debug-grade only -- see the Isaac Lab + teleop migration doc for the limitations of the produced + file (no per-episode segmentation, no world-frame anchor, + etc.). + mcap_replay_path: Optional path to an MCAP file to replay. When + set, the session runs in :class:`SessionMode.REPLAY` with no + OpenXR connection and feeds the recorded tracker stream + through the pipeline. Mutually exclusive with + *mcap_record_path*. + + Raises: + ValueError: If both *mcap_record_path* and *mcap_replay_path* + are provided. """ + if mcap_record_path is not None and mcap_replay_path is not None: + raise ValueError( + "mcap_record_path and mcap_replay_path are mutually exclusive; " + "set at most one to switch the session between LIVE recording and REPLAY playback." + ) + self._cfg = cfg self._device = torch.device(cfg.sim_device) self._cloudxr_env_file = cloudxr_env_file self._auto_launch_cloudxr = auto_launch_cloudxr + self._mcap_record_path = mcap_record_path + self._mcap_replay_path = mcap_replay_path + self._is_replay = mcap_replay_path is not None # Session state (populated during start) self._session: TeleopSession | None = None @@ -150,34 +176,42 @@ def __init__( self._retargeting_ui_ctx: MultiRetargeterTuningUIImGui | None = None self._retargeting_ui = None - try: - # Importing bridge also performs polyfill of missing omni.kit.xr.system.openxr functions. - import isaacsim.kit.xr.teleop.bridge as bridge + # Replay sessions never talk to Kit's XR system, so skip all XR + # extension subscriptions; they would only generate noise and could + # mis-fire if a parallel live session ever toggled /xr/enabled. + if not self._is_replay: + try: + # Importing bridge also performs polyfill of missing omni.kit.xr.system.openxr functions. + import isaacsim.kit.xr.teleop.bridge as bridge - subscribe_required_extensions = getattr(bridge, "subscribe_required_extensions", None) - if callable(subscribe_required_extensions): - self._required_extensions_subscription = subscribe_required_extensions( - self._on_request_required_extensions - ) - else: + subscribe_required_extensions = getattr(bridge, "subscribe_required_extensions", None) + if callable(subscribe_required_extensions): + self._required_extensions_subscription = subscribe_required_extensions( + self._on_request_required_extensions + ) + else: + logger.info( + "isaacsim.kit.xr.teleop.bridge.subscribe_required_extensions not available; " + "skipping required extensions subscription" + ) + except (ImportError, ModuleNotFoundError): logger.info( - "isaacsim.kit.xr.teleop.bridge.subscribe_required_extensions not available; " - "skipping required extensions subscription" + "isaacsim.kit.xr.teleop.bridge not available; IsaacTeleop will create its own OpenXR session" ) - except (ImportError, ModuleNotFoundError): - logger.info("isaacsim.kit.xr.teleop.bridge not available; IsaacTeleop will create its own OpenXR session") - try: - import carb.settings + try: + import carb.settings - # Subscribe to the setting (may not fire when Kit closes; see pre-shutdown below) - self._xr_enabled_subscription = carb.settings.get_settings().subscribe_to_node_change_events( - "/xr/enabled", - self._on_xr_enabled_changed, - ) - except (ImportError, ModuleNotFoundError): - logger.info("carb.settings not available; IsaacTeleop will not be able to detect XR enabled state") + # Subscribe to the setting (may not fire when Kit closes; see pre-shutdown below) + self._xr_enabled_subscription = carb.settings.get_settings().subscribe_to_node_change_events( + "/xr/enabled", + self._on_xr_enabled_changed, + ) + except (ImportError, ModuleNotFoundError): + logger.info("carb.settings not available; IsaacTeleop will not be able to detect XR enabled state") + # Pre-shutdown is still wanted in replay mode so the MCAP writer/reader + # gets a chance to flush before Kit tears down its event loop. try: import omni.kit.app from carb.eventdispatcher import get_eventdispatcher @@ -276,6 +310,12 @@ def start(self) -> None: "Start AR"), session creation is deferred and will be retried on each :meth:`step` call. """ + # CloudXR is per-run, not per-mode: when the caller passes a profile + # we spawn the runtime so a real client has something to attach to. + # This is true for live recording (operator wears the headset) and + # for spectate-on-replay (operator wears the headset to view a + # captured trajectory). Pure CI replay leaves cloudxr_env_file at + # None and gets the previous no-launcher behavior. if self._cloudxr_env_file is not None: self._ensure_cloudxr_runtime() @@ -293,7 +333,12 @@ def start(self) -> None: } self._pipeline = OutputCombiner(pipeline_outputs) - # Build optional teleop_control_pipeline for message-channel control + # Build the optional teleop_control_pipeline for message-channel control. + # Live and replay both build it: in replay mode the underlying + # MessageChannelTracker is fed by TeleopCore's + # ReplayMessageChannelTrackerImpl from the recorded + # ``_teleop_control_source`` channel, so START / STOP / RESET edges + # surface through ``poll_control_events`` the same way they do live. self._teleop_control_pipeline = None self._message_processor = None if self._cfg.control_channel_uuid is not None: @@ -460,12 +505,16 @@ def try_start_session(self) -> bool: def _try_start_session(self) -> bool: """Attempt to create and start the IsaacTeleop session. - Tries to acquire OpenXR handles from Kit's XR bridge. If the - handles are available, creates and enters the ``TeleopSession``. - If the handles are not yet complete — either because the XR session - has not started or because the bridge component has not finished - registering — session creation is deferred and will be retried on - the next :meth:`step` call. + In live mode, tries to acquire OpenXR handles from Kit's XR bridge. + If the handles are available, creates and enters the + :class:`TeleopSession`. If the handles are not yet complete — either + because the XR session has not started or because the bridge + component has not finished registering — session creation is deferred + and will be retried on the next :meth:`step` call. + + In replay mode, starts a :class:`SessionMode.REPLAY` session backed + by the MCAP file passed via ``mcap_replay_path``; no Kit XR handles + are needed. Returns: ``True`` if the session was successfully started (or was already @@ -474,6 +523,9 @@ def _try_start_session(self) -> bool: if self._session is not None: return True + if self._is_replay: + return self._start_replay_session() + self._ensure_xr_ar_profile_enabled() from isaacteleop.oxr import OpenXRSessionHandles @@ -495,6 +547,15 @@ def _try_start_session(self) -> bool: self._session_start_deferred_logged = True return False + mcap_config = None + if self._mcap_record_path is not None: + from isaacteleop.deviceio_session import McapRecordingConfig + + mcap_config = McapRecordingConfig(self._mcap_record_path) + + # Pipeline is built by start() before any _try_start_session call. + assert self._pipeline is not None, "pipeline must be built before starting the session" + session_config = TeleopSessionConfig( app_name=self._cfg.app_name, trackers=[], @@ -503,13 +564,62 @@ def _try_start_session(self) -> bool: plugins=self._cfg.plugins, oxr_handles=oxr_handles, retargeting_execution=self._cfg.retargeting_execution, + mcap_config=mcap_config, ) # Create and enter the TeleopSession self._session = TeleopSession(session_config) self._session.__enter__() - logger.info(f"IsaacTeleop session started: {self._cfg.app_name}") + if self._mcap_record_path is not None: + logger.info(f"IsaacTeleop session started: {self._cfg.app_name} (recording to {self._mcap_record_path})") + else: + logger.info(f"IsaacTeleop session started: {self._cfg.app_name}") + return True + + def _start_replay_session(self) -> bool: + """Start an MCAP-backed :class:`SessionMode.REPLAY` session. + + Unlike the live path, replay never waits for Kit XR handles or + pumps the OpenXR runtime: ``TeleopSession`` builds a + :class:`isacteleop.deviceio_session.ReplaySession` that feeds the + pipeline directly from the captured tracker stream. + + Returns: + Always ``True`` -- replay sessions start synchronously. + """ + from isaacteleop.deviceio_session import McapReplayConfig + from isaacteleop.teleop_session_manager import SessionMode, TeleopSession, TeleopSessionConfig + + # Narrow Optional types for the type checker; both fields are + # guaranteed non-None by start() / __init__ when this branch runs. + assert self._mcap_replay_path is not None, "replay path missing in replay mode" + assert self._pipeline is not None, "pipeline must be built before starting the session" + + # Fail fast on a missing MCAP file + if not os.path.exists(self._mcap_replay_path): + raise FileNotFoundError( + f"MCAP replay file not found: '{self._mcap_replay_path}'. " + "Check the ``mcap_replay_path`` passed to ``create_isaac_teleop_device`` " + "(or the ``--replay_file`` CLI arg on the replay agent)." + ) + + mcap_config = McapReplayConfig(self._mcap_replay_path) + session_config = TeleopSessionConfig( + app_name=self._cfg.app_name, + trackers=[], + pipeline=self._pipeline, + teleop_control_pipeline=self._teleop_control_pipeline, + plugins=self._cfg.plugins, + retargeting_execution=self._cfg.retargeting_execution, + mode=SessionMode.REPLAY, + mcap_config=mcap_config, + ) + + self._session = TeleopSession(session_config) + self._session.__enter__() + + logger.info(f"IsaacTeleop replay session started: {self._cfg.app_name} (replaying {self._mcap_replay_path})") return True # ------------------------------------------------------------------ diff --git a/source/isaaclab_teleop/isaaclab_teleop/xr_anchor_manager.py b/source/isaaclab_teleop/isaaclab_teleop/xr_anchor_manager.py index d39cdcbe84ad..e40cb9932a57 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/xr_anchor_manager.py +++ b/source/isaaclab_teleop/isaaclab_teleop/xr_anchor_manager.py @@ -29,6 +29,31 @@ logger = logging.getLogger(__name__) +def _xr_anchor_prim_exists(prim_path: str) -> bool: + """Return True when ``prim_path`` is already a valid prim on the active stage. + + Used to avoid re-creating the anchor prim on every + :class:`XrAnchorManager` construction in multi-replay batches (the + replay agent rebuilds the device per run, but the prim is + stage-scoped). Best-effort: returns ``False`` if the stage cannot + be inspected (e.g. unit tests without omni.usd) so the caller falls + through to the create-and-warn path used historically. + """ + try: + import omni.usd + + context = omni.usd.get_context() + if context is None: + return False + stage = context.get_stage() + if stage is None: + return False + return stage.GetPrimAtPath(prim_path).IsValid() + except Exception as exc: + logger.debug("_xr_anchor_prim_exists(%r) failed: %s", prim_path, exc) + return False + + class XrAnchorManager: """Manages XR anchor prim creation, synchronization, and world transform computation. @@ -75,15 +100,20 @@ def __init__(self, xr_cfg: XrCfg): else: self._xr_anchor_headset_path = "/World/XRAnchor" - # Create the XR anchor prim in USD. - # XrCfg.anchor_rot is xyzw; create_prim orientation expects xyzw. - x, y, z, w = self._xr_cfg.anchor_rot - try: - pos = np.asarray(self._xr_cfg.anchor_pos, dtype=np.float64) - quat_xyzw = np.asarray([x, y, z, w], dtype=np.float64) - _create_prim(self._xr_anchor_headset_path, prim_type="Xform", position=pos, orientation=quat_xyzw) - except Exception as e: - logger.warning(f"Failed to create XR anchor prim: {e}") + # Create the XR anchor prim in USD if it does not already exist. + # The check matters for multi-replay batches (the replay agent + # rebuilds ``IsaacTeleopDevice`` -- and therefore this manager -- + # for each run, but the prim is stage-scoped and survives + # per-run device teardown). XrCfg.anchor_rot is xyzw; create_prim + # orientation expects xyzw. + if not _xr_anchor_prim_exists(self._xr_anchor_headset_path): + x, y, z, w = self._xr_cfg.anchor_rot + try: + pos = np.asarray(self._xr_cfg.anchor_pos, dtype=np.float64) + quat_xyzw = np.asarray([x, y, z, w], dtype=np.float64) + _create_prim(self._xr_anchor_headset_path, prim_type="Xform", position=pos, orientation=quat_xyzw) + except Exception as e: + logger.warning(f"Failed to create XR anchor prim: {e}") # Configure carb settings for XR rendering if hasattr(carb, "settings"): diff --git a/source/isaaclab_teleop/setup.py b/source/isaaclab_teleop/setup.py index 7ecec8340abb..358a9ca3af7f 100644 --- a/source/isaaclab_teleop/setup.py +++ b/source/isaaclab_teleop/setup.py @@ -20,7 +20,7 @@ # Minimum dependencies required prior to installation INSTALL_REQUIRES = [ # IsaacTeleop is only available on Linux x86_64 - f"isaacteleop[retargeters,ui,cloudxr]~=1.2.0 ; platform_system == 'Linux' and ({SUPPORTED_ARCHS})", + f"isaacteleop[retargeters,ui,cloudxr]~=1.3.0 ; platform_system == 'Linux' and ({SUPPORTED_ARCHS})", # required by isaaclab.devices.openxr.retargeters.humanoid.fourier.gr1_t2_dex_retargeting_utils f"dex-retargeting==0.5.0 ; platform_system == 'Linux' and ({SUPPORTED_ARCHS})", ] From 7df01a994daedadc063c7e4bc5f0cff1f70b62be Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Tue, 19 May 2026 15:59:25 -0700 Subject: [PATCH 115/133] Import torch after app launcher (#5694) # Description Import torch numpy after AppLauncher 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 --- .../changelog.d/pbarejko-fix-torch-imports.skip | 0 source/isaaclab/test/sensors/test_ray_caster.py | 10 +++++----- .../isaaclab/test/sensors/test_ray_caster_patterns.py | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 source/isaaclab/changelog.d/pbarejko-fix-torch-imports.skip diff --git a/source/isaaclab/changelog.d/pbarejko-fix-torch-imports.skip b/source/isaaclab/changelog.d/pbarejko-fix-torch-imports.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/test/sensors/test_ray_caster.py b/source/isaaclab/test/sensors/test_ray_caster.py index 6ac63cf0d041..1749f537ab22 100644 --- a/source/isaaclab/test/sensors/test_ray_caster.py +++ b/source/isaaclab/test/sensors/test_ray_caster.py @@ -5,16 +5,16 @@ from __future__ import annotations -import numpy as np -import pytest -import torch -import trimesh - from isaaclab.app import AppLauncher # launch omniverse app simulation_app = AppLauncher(headless=True, enable_cameras=True).app +import numpy as np +import pytest +import torch +import trimesh + # Import after app launch import warp as wp diff --git a/source/isaaclab/test/sensors/test_ray_caster_patterns.py b/source/isaaclab/test/sensors/test_ray_caster_patterns.py index 5edcbe55d7c6..8e1b484968d3 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_patterns.py +++ b/source/isaaclab/test/sensors/test_ray_caster_patterns.py @@ -5,17 +5,17 @@ from __future__ import annotations -import math - -import pytest -import torch - from isaaclab.app import AppLauncher # launch omniverse app simulation_app = AppLauncher(headless=True, enable_cameras=False).app # Import after app launch +import math + +import pytest +import torch + from isaaclab.sensors.ray_caster.patterns import patterns, patterns_cfg From 16d63156ecabe10e676d122e0c3edb861b0b3baa Mon Sep 17 00:00:00 2001 From: nvsekkin <72572910+nvsekkin@users.noreply.github.com> Date: Tue, 19 May 2026 16:03:46 -0700 Subject: [PATCH 116/133] Remove stale xfail decorator from Newton environment tests (#5692) Removed the stale file-level `@pytest.mark.xfail` decorator on `test_environments_newton` (the cited Hydra deep nesting issue was already resolved by PR #5029 and follow-ups #5130 / #5177). ## 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 - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../changelog.d/esekkin-pr-a-newton-xfail.rst | 6 ++++++ source/isaaclab_tasks/test/test_environments_newton.py | 8 -------- 2 files changed, 6 insertions(+), 8 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/esekkin-pr-a-newton-xfail.rst diff --git a/source/isaaclab_tasks/changelog.d/esekkin-pr-a-newton-xfail.rst b/source/isaaclab_tasks/changelog.d/esekkin-pr-a-newton-xfail.rst new file mode 100644 index 000000000000..42736dca455b --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/esekkin-pr-a-newton-xfail.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Removed the stale file-level ``@pytest.mark.xfail`` decorator on + ``test_environments_newton`` (the cited Hydra deep-nesting issue was already + resolved by PR #5029 and follow-ups #5130 / #5177). diff --git a/source/isaaclab_tasks/test/test_environments_newton.py b/source/isaaclab_tasks/test/test_environments_newton.py index 23456946a689..1915d8a649b3 100644 --- a/source/isaaclab_tasks/test/test_environments_newton.py +++ b/source/isaaclab_tasks/test/test_environments_newton.py @@ -36,14 +36,6 @@ ), ) @pytest.mark.newton_ci -@pytest.mark.xfail( - reason=( - "TODO: Nested PresetCfg resolution for named presets (e.g. 'newton_mjwarp') is not yet supported. " - "The logic in parse_cfg.apply_named_preset should be unified with the deep-nesting " - "fixes in https://github.com/isaac-sim/IsaacLab/pull/5029 (isaaclab_tasks.utils.hydra)." - ), - strict=False, -) def test_environments_newton(task_name, num_envs, device): # run environments with MJWarp physics preset _run_environments(task_name, device, num_envs, physics_preset_name="newton_mjwarp", create_stage_in_memory=False) From cc095984a0ebf4aeaa1d3c144cdd9dd22dad530c Mon Sep 17 00:00:00 2001 From: John Date: Tue, 19 May 2026 16:05:14 -0700 Subject: [PATCH 117/133] Adds instructions for using pre-made locomanipulation SDG dataset/model (#5357) # Description Adds two tip callouts to the Demo 3 (G1 locomanipulation) section of the humanoids imitation learning documentation, giving users shortcuts to skip expensive pipeline steps: 1. Pre-made dataset (nvidia/g1_locomanip_dataset on Hugging Face): placed at the start of the SDG generation section, allowing users to skip manipulation dataset generation, SDG generation, and LeRobot conversion, and proceed directly to finetuning. Includes huggingface-cli download + unzip commands with the exact extracted path (g1_simple_high_var_lerobot/) and a note that policies trained on this dataset require --policy_quat_format wxyz at rollout. 2. Pre-trained model (nvidia/g1_locomanip_finetune on Hugging Face): placed immediately before the rollout section, allowing users to skip finetuning entirely. Includes download + unzip commands with the exact checkpoint path (g1_locomanip_finetune_20260129_231610/checkpoint-20000) and the required --policy_quat_format wxyz flag. Fixes # (issue) ## Type of change - Documentation update ## Screenshots ## 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 --- .../humanoids_imitation.rst | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/source/overview/imitation-learning/humanoids_imitation.rst b/docs/source/overview/imitation-learning/humanoids_imitation.rst index 2ea9c6542b7e..6cf321f71fb6 100644 --- a/docs/source/overview/imitation-learning/humanoids_imitation.rst +++ b/docs/source/overview/imitation-learning/humanoids_imitation.rst @@ -538,6 +538,24 @@ Generate the dataset with manipulation and point-to-point navigation To create a comprehensive locomanipulation dataset that combines both manipulation and navigation capabilities, you can generate a navigation dataset using the manipulation dataset from the previous step as input. +.. tip:: + + **Skip data generation:** A pre-made locomanipulation dataset in LeRobot format is available on + Hugging Face at `nvidia/g1_locomanip_dataset `__. + Downloading it lets you skip this section and the dataset conversion step, proceeding directly to + **Finetune the policy** below. + + Download and unzip the dataset: + + .. code:: bash + + huggingface-cli download nvidia/g1_locomanip_dataset --repo-type dataset --local-dir ./datasets/g1_locomanip_hf + unzip ./datasets/g1_locomanip_hf/*.zip -d ./datasets/ + + The archive extracts to ``./datasets/g1_simple_high_var_lerobot/``. + Use this path as the ``--dataset-path`` in the finetuning step. + Policies trained on this dataset require ``--policy_quat_format wxyz`` at rollout time. + .. list-table:: :widths: 50 50 :header-rows: 0 @@ -690,6 +708,23 @@ Run finetuning from the **Isaac-GR00T** repository root. Use the LeRobot-format See the GR00T N1.5 repository documentation for additional training options. +.. tip:: + + **Skip finetuning:** A pre-trained GR00T N1.5 checkpoint for this task is available on + Hugging Face at `nvidia/g1_locomanip_finetune `__. + Downloading it lets you skip the finetuning step and proceed directly to rollout. + + Download and unzip the checkpoint: + + .. code:: bash + + huggingface-cli download nvidia/g1_locomanip_finetune --local-dir ./checkpoints/g1_locomanip_finetune_hf + unzip ./checkpoints/g1_locomanip_finetune_hf/*.zip -d ./checkpoints/ + + The archive extracts to ``./checkpoints/g1_locomanip_finetune_20260129_231610/``. + Use ``./checkpoints/g1_locomanip_finetune_20260129_231610/checkpoint-20000`` as the ``--model_path`` + in the rollout command below. This checkpoint requires ``--policy_quat_format wxyz``. + Rollout the policy in Isaac Lab """"""""""""""""""""""""""""""" From a9b62101ca65034b1d8bfa9bd6087aeb3a308c13 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Wed, 20 May 2026 01:52:54 +0200 Subject: [PATCH 118/133] Skips apply_external_force_torque when both ranges are zero (#5688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Fast-path early-return in :func:`~isaaclab.envs.mdp.events.apply_external_force_torque` when both `force_range` and `torque_range` are exactly zero — a common configuration for tasks that declare the event term but apply no disturbance. Before this change, zero-wrench configurations were still sampled, written into the dual-buffer `WrenchComposer` introduced in #5265, and pushed through the per-step compose-and-apply path in `Articulation.write_data_to_sim`, paying the full per-step cost for what is semantically a no-op. Applying a zero wrench is equivalent to not applying one at all, so the function now returns immediately when both ranges are zero. This restores the H1, G1, and Anymal-C `Velocity-Rough` throughput observed prior to #5265, as recorded in the OmniPerf DB regression flagged in `isaac-sim/IsaacLab-Internal#906`. **Scope limitation.** This only addresses the zero-force case. Tasks that apply non-zero external forces (curriculum disturbances, push events, domain-randomized wrenches) still pay the per-step body-frame recompose cost under the new dual-buffer architecture. That broader optimization (compose caching / kernel fusion) is tracked separately in `isaac-sim/IsaacLab-Internal#911` and is out of scope here. **Correctness.** The dual-buffer `WrenchComposer` architecture from #5265 is untouched; this fix sits one layer above it in the event term. For any non-zero `force_range` or `torque_range`, the early-return predicate is false and behavior is unchanged. Fixes `isaac-sim/IsaacLab-Internal#906` Follow-up: `isaac-sim/IsaacLab-Internal#911` ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots N/A — performance fix, no user-visible behavior 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` - [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 - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Kelly Guo --- .../changelog.d/fix-zero-wrench-fast-path.rst | 12 ++++++++++++ source/isaaclab/isaaclab/envs/mdp/events.py | 4 ++++ 2 files changed, 16 insertions(+) create mode 100644 source/isaaclab/changelog.d/fix-zero-wrench-fast-path.rst diff --git a/source/isaaclab/changelog.d/fix-zero-wrench-fast-path.rst b/source/isaaclab/changelog.d/fix-zero-wrench-fast-path.rst new file mode 100644 index 000000000000..1521e8f9db31 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-zero-wrench-fast-path.rst @@ -0,0 +1,12 @@ +Fixed +^^^^^ + +* Fixed a per-step performance regression in :func:`~isaaclab.envs.mdp.events.apply_external_force_torque` + when the event was configured with all-zero ``force_range`` and ``torque_range`` (a common default + for tasks that declare the event term but apply no disturbance). The event was unconditionally + sampling zero wrenches and routing them through the dual-buffer ``WrenchComposer`` introduced in + PR #5265, paying the full per-step compose-and-apply cost in + :meth:`~isaaclab.assets.Articulation.write_data_to_sim` for what is semantically a no-op. The + function now returns early when both ranges are exactly zero. This restores the H1, G1, and + Anymal-C ``Velocity-Rough`` throughput observed prior to PR #5265. Behaviour for non-zero ranges + is unchanged. diff --git a/source/isaaclab/isaaclab/envs/mdp/events.py b/source/isaaclab/isaaclab/envs/mdp/events.py index 9c1c885520a9..f6cfddc2a64e 100644 --- a/source/isaaclab/isaaclab/envs/mdp/events.py +++ b/source/isaaclab/isaaclab/envs/mdp/events.py @@ -1709,6 +1709,10 @@ def apply_external_force_torque( # resolve number of bodies num_bodies = len(asset_cfg.body_ids) if isinstance(asset_cfg.body_ids, list) else asset.num_bodies + # Skip force application if the wrench ranges are zero + if force_range[0] == 0.0 and force_range[1] == 0.0 and torque_range[0] == 0.0 and torque_range[1] == 0.0: + return + # sample random forces and torques size = (len(env_ids), num_bodies, 3) forces = math_utils.sample_uniform(*force_range, size, asset.device) From cd1ba9134df3a349a0fc9ef30db8da57689e7ac6 Mon Sep 17 00:00:00 2001 From: matthewtrepte Date: Tue, 19 May 2026 18:44:07 -0700 Subject: [PATCH 119/133] Minor Updates & Patches to Visualizers (#5610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Contains a few minor updates and patches to visualizers. - Disable default browser launch to Rerun & Viser visualizers - Add explicit logging urls to Rerun & Viser which appear before simulation loop - Reduce visualizer num sim steps in unit tests - Unskip all visualizer cartpole integration tests - Add focal length to visualizer cfg, for flexibility, align default visualizer cams, and add more consistency to tests - Expand pausing checks for Kit & Rerun tests - Fix frozen robot bodies in newton visualizers with physx - Add a save visualizer test captures mode to visualizer cartpole unit test (disabled by default) - Re-enable visualization markers when no visualizer is launched - Fix Newton Visualizer visualization marker regression - Expose remote desktop config for Viser Visualizer - Align Viser Visualizer docs with other visualizers - Update docs to reflect the changes ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - 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 | 78 ++- .../overview/core-concepts/renderers.rst | 5 + .../changelog.d/mtrepte-update-debug-viz.rst | 9 + .../isaaclab/isaaclab/cli/commands/install.py | 2 + .../isaaclab/markers/visualization_markers.py | 5 +- .../isaaclab/sim/simulation_context.py | 3 + .../isaaclab/visualizers/base_visualizer.py | 50 +- .../isaaclab/visualizers/visualizer_cfg.py | 7 + .../markers/test_visualization_markers.py | 563 +++++++++++++++--- ...test_newton_manager_visualization_state.py | 16 +- .../test_simulation_context_visualizers.py | 362 ++--------- .../changelog.d/mtrepte-update-debug-viz.rst | 4 + .../isaaclab_newton/physics/newton_manager.py | 21 +- .../changelog.d/mtrepte-update-debug-viz.skip | 0 .../isaaclab_physx/physics/physx_manager.py | 25 +- .../changelog.d/mtrepte-update-debug-viz.skip | 0 .../changelog.d/mtrepte-update-debug-viz.skip | 0 .../changelog.d/mtrepte-update-debug-viz.rst | 5 + .../kit/kit_visualizer.py | 7 - .../newton/newton_visualizer.py | 46 +- .../rerun/rerun_visualizer.py | 34 +- .../rerun/rerun_visualizer_cfg.py | 9 +- .../viser/viser_visualizer.py | 102 ++-- .../viser/viser_visualizer_cfg.py | 19 +- .../test_visualizer_cartpole_integration.py | 1 + 25 files changed, 846 insertions(+), 527 deletions(-) create mode 100644 source/isaaclab/changelog.d/mtrepte-update-debug-viz.rst create mode 100644 source/isaaclab_newton/changelog.d/mtrepte-update-debug-viz.rst create mode 100644 source/isaaclab_physx/changelog.d/mtrepte-update-debug-viz.skip create mode 100644 source/isaaclab_rl/changelog.d/mtrepte-update-debug-viz.skip create mode 100644 source/isaaclab_tasks/changelog.d/mtrepte-update-debug-viz.skip create mode 100644 source/isaaclab_visualizers/changelog.d/mtrepte-update-debug-viz.rst diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index c37fb6d9be96..c23d9d4b5fd2 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -121,6 +121,8 @@ You can also configure custom visualizers in the code by defining ``VisualizerCf ), ViserVisualizerCfg( port=8080, + bind_address="0.0.0.0", + display_address="localhost", share=False, ), ] @@ -390,6 +392,22 @@ Rerun Visualizer - Timeline scrubbing and playback controls of recordings - Visualization debug markers +.. important:: + + A highlighted Rerun browser URL is printed in the logs before the main simulation or training loop begins. + Ctrl-click the printed URL in supported terminals/IDEs to open it. Set ``open_browser=True`` to automatically + open the browser tab instead. + + Example: + + .. code-block:: text + + ╭─────────────────────────── rerun (listening *:9090) ───────────────────────────╮ + │ ╷ │ + │ URL │ http://127.0.0.1:9090/?url=rerun%2Bhttp://127.0.0.1:9876/proxy │ + │ ╵ │ + ╰────────────────────────────────────────────────────────────────────────────────╯ + **Core Configuration:** .. code-block:: python @@ -400,8 +418,9 @@ Rerun Visualizer # Server settings app_id="isaaclab-simulation", # Application identifier for viewer grpc_port=9876, # gRPC endpoint for logging SDK connection - web_port=9090, # Port for local web viewer (launched in browser) + web_port=9090, # Port for local web viewer URL printed in logs bind_address="0.0.0.0", # Endpoint host formatting/reuse checks + open_browser=False, # Set True to auto-launch the browser # Camera settings eye=(8.0, 8.0, 3.0), # Initial camera position (x, y, z) @@ -427,7 +446,7 @@ The `Viser `_ visualizer provides a **web-based** 3D view simulations powered by the Newton Warp renderer. It streams the simulation state to a local web server, allowing you to view and interact with the scene from any browser. -**Key features:** +**Main Features:** - Browser-based visualization accessible at ``http://localhost:8080`` by default - Optional public share URL for remote viewing @@ -435,34 +454,55 @@ server, allowing you to view and interact with the scene from any browser. - Environment filtering to control which environments are rendered - Visualization debug markers -**Launch with Viser:** +.. important:: -.. code-block:: bash + A highlighted Viser browser URL is printed in the logs before the main simulation or training loop begins. + Ctrl-click the printed URL in supported terminals/IDEs to open it. Set ``open_browser=True`` to automatically + open the browser tab instead. For remote access, keep ``bind_address="0.0.0.0"`` and set + ``display_address`` to the hostname or IP address reachable from your browser. + + Example: - ./isaaclab.sh -p source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env.py --viz viser + .. code-block:: text -**Configuration example:** + ╭────── viser (listening *:8080) ───────╮ + │ ╷ │ + │ URL │ http://localhost:8080 │ + │ ╵ │ + ╰───────────────────────────────────────╯ + +**Core Configuration:** .. code-block:: python from isaaclab_visualizers.viser import ViserVisualizerCfg visualizer_cfg = ViserVisualizerCfg( - port=8080, - open_browser=True, - label="Isaac Lab Simulation", - share=False, - max_visible_envs=16, - ) + # Server settings + port=8080, # Port for local Viser web server + bind_address="0.0.0.0", # Interface to listen on; use 0.0.0.0 for remote access + display_address="localhost", # Host/IP shown in the printed browser URL + open_browser=False, # Set True to auto-launch the browser + label="Isaac Lab Simulation", # Page title shown in the viewer + share=False, # Request a public share URL for remote viewing + verbose=True, # Print viewer server startup information -**Configuration options:** + # Camera settings + eye=(8.0, 8.0, 3.0), # Initial camera position (x, y, z) + lookat=(0.0, 0.0, 0.0), # Camera look-at target + + # Environment filtering + max_visible_envs=16, # Maximum number of environments to visualize + + # Recording + record_to_viser="recording.viser", # Path to save .viser file (None = no recording) + ) -- ``port`` (int, default ``8080``): Port of the local Viser web server. -- ``open_browser`` (bool, default ``True``): Automatically open the viewer URL in a browser. -- ``label`` (str or None, default ``"Isaac Lab Simulation"``): Page title shown in the viewer. -- ``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. +Viser uses an in-process ``viser.ViserServer`` through ``newton.viewer.ViewerViser``. ``bind_address`` +controls the network interface that the server listens on, while ``display_address`` controls only the +URL printed by Isaac Lab. On a remote machine, set ``display_address`` to the machine hostname/IP and +ensure the configured ``port`` is reachable from your browser. Set ``share=True`` to request Viser's +public share/tunnel URL when that service is available. .. note:: diff --git a/docs/source/overview/core-concepts/renderers.rst b/docs/source/overview/core-concepts/renderers.rst index f04b476330b5..b4e7c2a6454e 100644 --- a/docs/source/overview/core-concepts/renderers.rst +++ b/docs/source/overview/core-concepts/renderers.rst @@ -32,6 +32,11 @@ Choosing a renderer backend .. note:: + Visualization markers are not yet supported by Newton-based renderer backends, + including the Newton Warp renderer. Use an RTX-based renderer, such as the + Isaac RTX renderer or OVRTX renderer, when marker visualization is needed. + +.. note:: **Temporal information for camera-based RL.** Unlike RTX modes with temporal anti-aliasing (DLSS, DLAA, TAA), the Newton Warp renderer does not inject prior-frame information into the current image. Camera-control tasks that depend diff --git a/source/isaaclab/changelog.d/mtrepte-update-debug-viz.rst b/source/isaaclab/changelog.d/mtrepte-update-debug-viz.rst new file mode 100644 index 000000000000..b7aa84db0dfc --- /dev/null +++ b/source/isaaclab/changelog.d/mtrepte-update-debug-viz.rst @@ -0,0 +1,9 @@ +Fixed +^^^^^ + +* Fixed visualization marker backend initialization so USD markers remain available during rendering even when standalone visualizers are not launched. + +Changed +^^^^^^^ + +* Removed temporary startup and runtime debug breadcrumbs from core simulation and environment setup logs. diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index 90fa929b69e5..2953919c4427 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -649,6 +649,8 @@ def _install_extra_feature(feature_name: str, selector: str = "") -> None: "websockets", "viser", "imgui_bundle", + "attr", + "attrs", ] """Package directory names in Isaac Sim prebundle directories to repoint. diff --git a/source/isaaclab/isaaclab/markers/visualization_markers.py b/source/isaaclab/isaaclab/markers/visualization_markers.py index 2e418bbecade..667f9b4ddf7d 100644 --- a/source/isaaclab/isaaclab/markers/visualization_markers.py +++ b/source/isaaclab/isaaclab/markers/visualization_markers.py @@ -250,7 +250,10 @@ def _ensure_backends_initialized(self) -> None: self._ensure_kit_backend() return - if any(viz.supports_markers() and viz.pumps_app_update() and viz.cfg.enable_markers for viz in sim.visualizers): + needs_kit_backend = sim.is_rendering or any( + viz.supports_markers() and viz.pumps_app_update() and viz.cfg.enable_markers for viz in sim.visualizers + ) + if needs_kit_backend: self._ensure_kit_backend() if any( viz.supports_markers() and not viz.pumps_app_update() and viz.cfg.enable_markers for viz in sim.visualizers diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 175961fcd383..9975cf13100d 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -761,6 +761,9 @@ def update_visualizers(self, dt: float, skip_app_pumping: bool = False) -> None: if not self._visualizers: return + for viz in self._visualizers: + viz.flush_startup_messages() + if self._should_forward_before_visualizer_update(): self.physics_manager.forward() diff --git a/source/isaaclab/isaaclab/visualizers/base_visualizer.py b/source/isaaclab/isaaclab/visualizers/base_visualizer.py index 92f8c37ce947..9e41ddce3402 100644 --- a/source/isaaclab/isaaclab/visualizers/base_visualizer.py +++ b/source/isaaclab/isaaclab/visualizers/base_visualizer.py @@ -8,10 +8,12 @@ from __future__ import annotations import logging +import math import random import re from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse if TYPE_CHECKING: from isaaclab.scene.scene_data_provider import SceneDataProvider @@ -21,6 +23,8 @@ logger = logging.getLogger(__name__) +_USD_DEFAULT_VERTICAL_APERTURE_MM = 15.2908 + class BaseVisualizer(ABC): """Base class for all visualizer backends. @@ -38,6 +42,7 @@ def __init__(self, cfg: VisualizerCfg): self._scene_data_provider = None self._is_initialized = False self._is_closed = False + self._deferred_startup_messages: list[str] = [] @abstractmethod def initialize(self, scene_data_provider: SceneDataProvider) -> None: @@ -188,6 +193,13 @@ def _resolve_cfg_camera_pose( lookat = tuple(float(v) for v in self.cfg.lookat) return eye, lookat + def _focal_length_to_vertical_fov_degrees(self) -> float: + """Convert cfg focal length to vertical FOV using USD's default aperture.""" + focal_length = float(self.cfg.focal_length) + if focal_length <= 0.0: + raise ValueError("VisualizerCfg.focal_length must be positive.") + return math.degrees(2.0 * math.atan(_USD_DEFAULT_VERTICAL_APERTURE_MM / (2.0 * focal_length))) + def _resolve_camera_pose_from_usd_path( self, usd_path: str ) -> tuple[tuple[float, float, float], tuple[float, float, float]] | None: @@ -308,7 +320,43 @@ def _log_initialization_table(self, logger: logging.Logger, title: str, rows: li table.align["Value"] = "l" for key, value in rows: table.add_row([key, value]) - logger.info("Visualizer initialization:\n%s", table.get_string()) + logger.debug("Visualizer initialization:\n%s", table.get_string()) + + def _log_viewer_url( + self, + visualizer_name: str, + viewer_url: str, + ) -> None: + """Queue a visible browser URL block for web-based visualizers. + + Args: + visualizer_name: Name of the visualizer exposing the URL. + viewer_url: Browser URL for the visualizer. + """ + parsed_url = urlparse(viewer_url) + visualizer_label = visualizer_name.removesuffix("Visualizer").lower() + title = f" {visualizer_label} (listening *:{parsed_url.port}) " if parsed_url.port else f" {visualizer_label} " + label = "URL" + label_width = len(label) + value_width = max(len(viewer_url), len(title) + 2, 21) + inner_width = label_width + value_width + 9 + left_rule_width = max((inner_width - len(title)) // 2, 1) + right_rule_width = max(inner_width - len(title) - left_rule_width, 1) + + lines = [ + f"╭{'─' * left_rule_width}{title}{'─' * right_rule_width}╮", + f"│{' ' * (label_width + 4)}╷{' ' * (value_width + 4)}│", + f"│ {label:<{label_width}} │ {viewer_url:<{value_width}} │", + f"│{' ' * (label_width + 4)}╵{' ' * (value_width + 4)}│", + f"╰{'─' * inner_width}╯", + ] + self._deferred_startup_messages.append("\n" + "\n".join(lines) + "\n") + + def flush_startup_messages(self) -> None: + """Print deferred startup messages immediately before the workflow update loop starts.""" + for message in self._deferred_startup_messages: + print(message, flush=True) + self._deferred_startup_messages.clear() def play(self) -> None: """Handle simulation play/start. No-op by default.""" diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 8a8c7ba7a21b..ef64d0909e4f 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -40,6 +40,13 @@ class VisualizerCfg: lookat: tuple[float, float, float] = (0.0, 0.0, 0.0) """Initial camera look-at point (x, y, z) in world coordinates.""" + focal_length: float = 15.0 + """Camera focal length in millimeters for visualizer camera views. + + Kit applies this directly to USD cameras. Newton-style backends convert it + to a vertical field-of-view using the USD default vertical aperture. + """ + cam_source: Literal["cfg", "prim_path"] = "cfg" """Camera source mode: 'cfg' uses eye/lookat, 'prim_path' follows a camera prim.""" diff --git a/source/isaaclab/test/markers/test_visualization_markers.py b/source/isaaclab/test/markers/test_visualization_markers.py index 906c14ccb7d5..b9ae8387cf0f 100644 --- a/source/isaaclab/test/markers/test_visualization_markers.py +++ b/source/isaaclab/test/markers/test_visualization_markers.py @@ -12,15 +12,24 @@ """Rest everything follows.""" +import isaaclab_visualizers.newton.newton_visualization_markers as newton_markers +import isaaclab_visualizers.newton.newton_visualizer as newton_visualizer +import isaaclab_visualizers.rerun.rerun_visualizer as rerun_visualizer +import isaaclab_visualizers.viser.viser_visualizer as viser_visualizer +import numpy as np import pytest import torch +from isaaclab_visualizers.kit.kit_visualizer import KitVisualizer +from isaaclab_visualizers.kit.kit_visualizer_cfg import KitVisualizerCfg +from isaaclab_visualizers.newton.newton_visualizer_cfg import NewtonVisualizerCfg +from isaaclab_visualizers.rerun.rerun_visualizer_cfg import RerunVisualizerCfg +from isaaclab_visualizers.viser.viser_visualizer_cfg import ViserVisualizerCfg import isaaclab.sim as sim_utils from isaaclab.markers import VisualizationMarkers, VisualizationMarkersCfg from isaaclab.markers.config import FRAME_MARKER_CFG, POSITION_GOAL_MARKER_CFG from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.math import random_orientation -from isaaclab.utils.timer import Timer @pytest.fixture @@ -40,6 +49,24 @@ def sim(): sim_utils.close_stage() +class _FakeMarkerVisualizer: + def __init__(self, *, enable_markers: bool = True, pumps_app_update: bool = False): + self.cfg = type("Cfg", (), {"enable_markers": enable_markers})() + self._pumps_app_update = pumps_app_update + + def supports_markers(self): + return True + + def pumps_app_update(self): + return self._pumps_app_update + + def stop(self): + pass + + def close(self): + pass + + def test_instantiation(sim): """Test that the class can be initialized properly.""" config = VisualizationMarkersCfg( @@ -54,6 +81,69 @@ def test_instantiation(sim): assert test_marker.num_prototypes == 1 +@pytest.mark.parametrize( + ("is_rendering", "visualizers", "expected_backends"), + [ + (True, [], ["kit"]), + (False, [], []), + (False, [KitVisualizer(KitVisualizerCfg())], ["kit"]), + (False, [newton_visualizer.NewtonVisualizer(NewtonVisualizerCfg())], ["newton"]), + (False, [rerun_visualizer.RerunVisualizer(RerunVisualizerCfg())], ["newton"]), + (False, [viser_visualizer.ViserVisualizer(ViserVisualizerCfg())], ["newton"]), + ], +) +def test_marker_backend_selection(monkeypatch, is_rendering: bool, visualizers: list, expected_backends: list[str]): + """Marker backend selection follows rendering state and active visualizer type.""" + marker = object.__new__(VisualizationMarkers) + marker._backends = [] + fake_sim = type("FakeSim", (), {"is_rendering": is_rendering, "visualizers": visualizers})() + + monkeypatch.setattr(sim_utils.SimulationContext, "instance", staticmethod(lambda: fake_sim)) + monkeypatch.setattr(VisualizationMarkers, "_ensure_kit_backend", lambda self: self._backends.append("kit")) + monkeypatch.setattr(VisualizationMarkers, "_ensure_newton_backend", lambda self: self._backends.append("newton")) + + marker._ensure_backends_initialized() + + assert marker._backends == expected_backends + + +def test_rendering_context_authors_visible_usd_point_instancer(sim): + """Rendering-active contexts should create visible USD marker prims.""" + from pxr import UsdGeom + + sim._has_offscreen_render = True + config = VisualizationMarkersCfg( + prim_path="/World/Visuals/rendered_marker", + markers={ + "failure": sim_utils.CuboidCfg( + size=(0.1, 0.1, 0.1), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.25, 0.15, 0.15)), + visible=True, + ), + "success": sim_utils.CuboidCfg( + size=(0.1, 0.1, 0.1), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.15, 0.25, 0.15)), + visible=True, + ), + }, + ) + test_marker = VisualizationMarkers(config) + test_marker.visualize( + translations=torch.tensor([[0.0, 0.0, 0.0], [0.2, 0.0, 0.0]], device=sim.device), + marker_indices=torch.tensor([0, 1], device=sim.device), + ) + + stage = sim_utils.get_current_stage() + instancer_prim = stage.GetPrimAtPath(test_marker.prim_path) + instancer = UsdGeom.PointInstancer(instancer_prim) + + assert instancer_prim.IsValid() + assert instancer + assert UsdGeom.Imageable(instancer_prim).GetVisibilityAttr().Get() != UsdGeom.Tokens.invisible + assert len(instancer.GetPositionsAttr().Get()) == 2 + assert list(instancer.GetProtoIndicesAttr().Get()) == [0, 1] + + def test_usd_marker(sim): """Test with marker from a USD.""" # create a marker @@ -80,29 +170,6 @@ def test_usd_marker(sim): assert test_marker.count == num_frames -def test_usd_marker_color(sim): - """Test with marker from a USD with its color modified.""" - # create a marker - config = FRAME_MARKER_CFG.copy() - config.prim_path = "/World/Visuals/test_frames" - config.markers["frame"].visual_material = sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0)) - test_marker = VisualizationMarkers(config) - - # play the simulation - sim.reset() - # run with randomization of poses - for count in range(1000): - # sample random poses - if count % 50 == 0: - num_frames = torch.randint(10, 1000, (1,)).item() - frame_translations = torch.randn(num_frames, 3, device=sim.device) - frame_rotations = random_orientation(num_frames, device=sim.device) - # set the marker - test_marker.visualize(translations=frame_translations, orientations=frame_rotations) - # update the kit - sim.step() - - def test_multiple_prototypes_marker(sim): """Test with multiple prototypes of spheres.""" # create a marker @@ -126,9 +193,8 @@ def test_multiple_prototypes_marker(sim): sim.step() -@pytest.mark.flaky(max_runs=3, min_passes=1) -def test_visualization_time_based_on_prototypes(sim): - """Test with time taken when number of prototypes is increased.""" +def test_visualization_skips_updates_when_invisible(sim): + """When invisible, visualize should not update marker state.""" # create a marker config = POSITION_GOAL_MARKER_CFG.copy() config.prim_path = "/World/Visuals/test_protos" @@ -136,69 +202,408 @@ def test_visualization_time_based_on_prototypes(sim): # play the simulation sim.reset() - # number of frames - num_frames = 4096 # check that visibility is true assert test_marker.is_visible() - # run with randomization of poses and indices - frame_translations = torch.randn(num_frames, 3, device=sim.device) - marker_indices = torch.randint(0, test_marker.num_prototypes, (num_frames,), device=sim.device) - # set the marker - with Timer("Marker visualization with explicit indices") as timer: - test_marker.visualize(translations=frame_translations, marker_indices=marker_indices) - # save the time - time_with_marker_indices = timer.time_elapsed - - with Timer("Marker visualization with no indices") as timer: - test_marker.visualize(translations=frame_translations) - # save the time - time_with_no_marker_indices = timer.time_elapsed + frame_translations = torch.randn(4, 3, device=sim.device) + marker_indices = torch.zeros(4, dtype=torch.int32, device=sim.device) + test_marker.visualize(translations=frame_translations, marker_indices=marker_indices) + assert test_marker.count == 4 # update the kit sim.step() - # check that the time is less - assert time_with_no_marker_indices < time_with_marker_indices + # make invisible + test_marker.set_visibility(False) + # check that visibility is false + assert not test_marker.is_visible() + test_marker.visualize( + translations=torch.randn(8, 3, device=sim.device), + marker_indices=torch.zeros(8, dtype=torch.int32, device=sim.device), + ) -def test_visualization_time_based_on_visibility(sim): - """Test with visibility of markers. When invisible, the visualize call should return.""" - # create a marker + assert test_marker.count == 4 + + +def test_newton_marker_backend_registers_and_updates_state_without_frame_capture(sim): + """Newton marker backend state should be registered and ready for Newton-family viewers.""" + sim._visualizers.append(_FakeMarkerVisualizer(pumps_app_update=False)) config = POSITION_GOAL_MARKER_CFG.copy() - config.prim_path = "/World/Visuals/test_protos" + config.prim_path = "/World/Visuals/newton_marker_state" test_marker = VisualizationMarkers(config) + translations = torch.arange(6, dtype=torch.float32, device=sim.device).reshape(2, 3) + marker_indices = torch.tensor([0, 0], device=sim.device) - # play the simulation - sim.reset() - # number of frames - num_frames = 4096 + test_marker.visualize(translations=translations, marker_indices=marker_indices) - # check that visibility is true - assert test_marker.is_visible() - # run with randomization of poses and indices - frame_translations = torch.randn(num_frames, 3, device=sim.device) - marker_indices = torch.randint(0, test_marker.num_prototypes, (num_frames,), device=sim.device) - # set the marker - with Timer("Marker visualization") as timer: - test_marker.visualize(translations=frame_translations, marker_indices=marker_indices) - # save the time - time_with_visualization = timer.time_elapsed + newton_backend = test_marker._backends[0] + assert isinstance(newton_backend, newton_markers.NewtonVisualizationMarkers) + assert sim.vis_marker_registry.get_groups()[newton_backend.group_id] is newton_backend + assert torch.equal(newton_backend.translations, translations) + assert torch.equal(newton_backend.marker_indices, marker_indices.to(dtype=torch.int32)) + assert newton_backend.count == 2 - # update the kit - sim.step() - # make invisible - test_marker.set_visibility(False) - # check that visibility is false - assert not test_marker.is_visible() - # run with randomization of poses and indices - frame_translations = torch.randn(num_frames, 3, device=sim.device) - marker_indices = torch.randint(0, test_marker.num_prototypes, (num_frames,), device=sim.device) - # set the marker - with Timer("Marker no visualization") as timer: - test_marker.visualize(translations=frame_translations, marker_indices=marker_indices) - # save the time - time_with_no_visualization = timer.time_elapsed - - # check that the time is less - assert time_with_no_visualization < time_with_visualization +def test_newton_visualizer_step_renders_markers(monkeypatch: pytest.MonkeyPatch): + """NewtonVisualizer.step should ask active Newton marker groups to render.""" + marker_calls = [] + + class _FakeViewer: + _update_frequency = 1 + + def __init__(self): + self.calls = [] + + def is_paused(self): + return False + + def begin_frame(self, sim_time): + self.calls.append(("begin_frame", sim_time)) + + def log_state(self, state): + self.calls.append(("log_state", state)) + + def end_frame(self): + self.calls.append(("end_frame",)) + + class _FakeNewtonManager: + @staticmethod + def get_state(scene_data_provider=None): + assert scene_data_provider == "provider" + return {"state": "ok"} + + @staticmethod + def get_num_envs() -> int: + return 4 + + def _fake_render_markers(viewer, visible_env_ids, num_envs): + marker_calls.append((viewer, visible_env_ids, num_envs)) + + import isaaclab_newton.physics as newton_physics + + monkeypatch.setattr(newton_physics, "NewtonManager", _FakeNewtonManager) + monkeypatch.setattr(newton_visualizer, "render_newton_visualization_markers", _fake_render_markers) + + viewer = _FakeViewer() + visualizer = newton_visualizer.NewtonVisualizer(NewtonVisualizerCfg(enable_markers=True)) + visualizer._is_initialized = True + visualizer._is_closed = False + visualizer._viewer = viewer + visualizer._scene_data_provider = "provider" + visualizer._resolved_visible_env_ids = [1, 3] + + visualizer.step(0.25) + + assert viewer.calls == [("begin_frame", pytest.approx(0.25)), ("log_state", {"state": "ok"}), ("end_frame",)] + assert marker_calls == [(viewer, [1, 3], 4)] + + +def test_viser_visualizer_marker_render_failure_does_not_interrupt_state_updates( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + """Viser marker failures should be logged without dropping body state frames.""" + marker_calls = [] + + class _FakeViewer: + def __init__(self): + self.calls = [] + + def begin_frame(self, sim_time: float) -> None: + self.calls.append(("begin_frame", sim_time)) + + def log_state(self, state) -> None: + self.calls.append(("log_state", state)) + + def end_frame(self) -> None: + self.calls.append(("end_frame",)) + + class _FakeProvider: + num_envs = 4 + usd_stage = None + + def get_camera_transforms(self): + return {} + + class _FakeNewtonManager: + @staticmethod + def get_model(): + return "dummy-model" + + @staticmethod + def get_state(scene_data_provider=None): + assert scene_data_provider is provider + return {"state": "ok"} + + @staticmethod + def get_num_envs() -> int: + return provider.num_envs + + def _fake_create_viewer(self, record_to_viser: str | None, metadata: dict | None = None): + self._viewer = viewer + + def _raise_marker_render(*args, **kwargs): + marker_calls.append((args, kwargs)) + raise RuntimeError("marker overlay failed") + + import isaaclab_newton.physics as newton_physics + + provider = _FakeProvider() + viewer = _FakeViewer() + monkeypatch.setattr(newton_physics, "NewtonManager", _FakeNewtonManager) + monkeypatch.setattr(viser_visualizer.ViserVisualizer, "_create_viewer", _fake_create_viewer) + monkeypatch.setattr(viser_visualizer, "render_newton_visualization_markers", _raise_marker_render) + + visualizer = viser_visualizer.ViserVisualizer(ViserVisualizerCfg()) + visualizer.initialize(provider) + + with caplog.at_level("WARNING"): + visualizer.step(0.25) + + assert marker_calls + assert viewer.calls == [("begin_frame", pytest.approx(0.25)), ("log_state", {"state": "ok"}), ("end_frame",)] + assert "Marker rendering failed; continuing body updates" in caplog.text + + +def test_rerun_visualizer_marker_failure_still_ends_frame(monkeypatch: pytest.MonkeyPatch): + """Rerun should close the frame even if marker rendering raises.""" + captured = {} + + class _FakeViewer: + def __init__(self): + self.calls = [] + + def is_paused(self): + return False + + def begin_frame(self, sim_time): + self.calls.append(("begin_frame", sim_time)) + + def log_state(self, state): + self.calls.append(("log_state", state)) + + def end_frame(self): + self.calls.append(("end_frame",)) + + class _FakeProvider: + def get_metadata(self) -> dict: + return {"num_envs": 4} + + def get_newton_state(self): + return {"ok": True} + + def get_camera_transforms(self): + return {} + + class _FakeNewtonManager: + @staticmethod + def get_model(): + return "dummy-model" + + @staticmethod + def get_state(scene_data_provider=None): + captured["state_provider"] = scene_data_provider + return {"ok": True} + + @staticmethod + def get_num_envs() -> int: + return 4 + + def _raise_marker_render(*args, **kwargs): + raise RuntimeError("marker render failed") + + import isaaclab_newton.physics as newton_physics + + monkeypatch.setattr(newton_physics, "NewtonManager", _FakeNewtonManager) + monkeypatch.setattr(rerun_visualizer, "render_newton_visualization_markers", _raise_marker_render) + + visualizer = rerun_visualizer.RerunVisualizer(RerunVisualizerCfg()) + viewer = _FakeViewer() + visualizer._is_initialized = True + visualizer._is_closed = False + visualizer._viewer = viewer + visualizer._scene_data_provider = _FakeProvider() + visualizer._resolved_visible_env_ids = None + + with pytest.raises(RuntimeError, match="marker render failed"): + visualizer.step(0.25) + + assert captured["state_provider"] is visualizer._scene_data_provider + assert [call[0] for call in viewer.calls] == ["begin_frame", "log_state", "end_frame"] + + +def test_newton_marker_mesh_registration_is_per_viewer(monkeypatch: pytest.MonkeyPatch): + marker = object.__new__(newton_markers.NewtonVisualizationMarkers) + marker._registered_meshes = set() + + class _FakeMesh: + vertices = np.zeros((1, 3), dtype=np.float32) + indices = np.zeros((3,), dtype=np.int32) + normals = np.zeros((0, 3), dtype=np.float32) + uvs = np.zeros((0, 2), dtype=np.float32) + + class _FakeViewer: + def __init__(self): + self.meshes = [] + + def log_mesh(self, name, vertices, indices, **kwargs): + self.meshes.append((name, vertices, indices, kwargs)) + + monkeypatch.setattr(newton_markers, "_create_mesh", lambda cfg: _FakeMesh()) + monkeypatch.setattr(newton_markers.wp, "array", lambda value, dtype=None: value) + + spec = newton_markers._NewtonMarkerSpec(renderer="mesh", mesh_type="box", mesh_params={"size": (1.0, 1.0, 1.0)}) + viewer_a = _FakeViewer() + viewer_b = _FakeViewer() + + marker._ensure_mesh_registered(viewer_a, "/Visuals/marker/meshes/arrow", spec) + marker._ensure_mesh_registered(viewer_a, "/Visuals/marker/meshes/arrow", spec) + marker._ensure_mesh_registered(viewer_b, "/Visuals/marker/meshes/arrow", spec) + + assert len(viewer_a.meshes) == 1 + assert len(viewer_b.meshes) == 1 + + +class _FakeNewtonMarkerMesh: + vertices = np.zeros((1, 3), dtype=np.float32) + indices = np.zeros((3,), dtype=np.int32) + normals = np.zeros((0, 3), dtype=np.float32) + uvs = np.zeros((0, 2), dtype=np.float32) + + +class _FakeNewtonMarkerViewer: + def __init__(self): + self.meshes = [] + self.instances = [] + self.lines = [] + + def log_mesh(self, name, vertices, indices, **kwargs): + self.meshes.append((name, vertices, indices, kwargs)) + + def log_instances(self, batch_name, mesh_name, xforms, scales, colors, materials, hidden=False): + self.instances.append( + { + "batch_name": batch_name, + "mesh_name": mesh_name, + "xforms": xforms, + "scales": scales, + "colors": colors, + "materials": materials, + "hidden": hidden, + } + ) + + def log_lines(self, batch_name, starts, ends, colors, width=None, hidden=False): + self.lines.append( + { + "batch_name": batch_name, + "starts": starts, + "ends": ends, + "colors": colors, + "width": width, + "hidden": hidden, + } + ) + + +def _make_newton_marker_for_render( + *, + marker_names: list[str], + translations: torch.Tensor, + marker_indices: torch.Tensor | None = None, + visible: bool = True, +): + marker = object.__new__(newton_markers.NewtonVisualizationMarkers) + marker_cfg_type = type("MarkerCfg", (), {"visual_material": None}) + marker.cfg = type("Cfg", (), {"markers": {name: marker_cfg_type() for name in marker_names}})() + marker.group_id = "/Visuals/marker::test" + marker.visible = visible + marker.translations = translations + marker.orientations = torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32).repeat(translations.shape[0], 1) + marker.scales = torch.ones((translations.shape[0], 3), dtype=torch.float32) + marker.marker_indices = marker_indices + marker.count = translations.shape[0] + marker._registered_meshes = set() + marker._warned_unsupported = set() + return marker + + +def _patch_newton_marker_render_deps(monkeypatch: pytest.MonkeyPatch) -> None: + specs = { + "arrow": newton_markers._NewtonMarkerSpec( + renderer="mesh", + mesh_type="box", + mesh_params={"size": (1.0, 1.0, 1.0)}, + color=(1.0, 1.0, 1.0), + texture=np.zeros((2, 2, 3), dtype=np.uint8), + ), + "sphere": newton_markers._NewtonMarkerSpec(renderer="mesh", mesh_type="sphere", mesh_params={"radius": 1.0}), + "frame": newton_markers._NewtonMarkerSpec(renderer="frame"), + } + + monkeypatch.setattr(newton_markers, "_create_mesh", lambda cfg: _FakeNewtonMarkerMesh()) + monkeypatch.setattr(newton_markers.wp, "array", lambda value, dtype=None: value) + monkeypatch.setattr(newton_markers, "_resolve_newton_marker_cfg", lambda name, marker_cfg, cfg: specs[name]) + + +def test_newton_marker_render_filters_visible_envs(monkeypatch: pytest.MonkeyPatch): + _patch_newton_marker_render_deps(monkeypatch) + translations = torch.arange(8, dtype=torch.float32).unsqueeze(1).repeat(1, 3) + marker = _make_newton_marker_for_render( + marker_names=["arrow"], + translations=translations, + marker_indices=torch.zeros(8, dtype=torch.int32), + ) + viewer = _FakeNewtonMarkerViewer() + + marker.render(viewer, visible_env_ids=[1, 3], num_envs=4) + + assert len(viewer.instances) == 1 + assert viewer.instances[0]["hidden"] is False + assert viewer.instances[0]["xforms"][:, 0].tolist() == [1.0, 3.0, 5.0, 7.0] + + +def test_newton_marker_render_routes_instances_by_prototype(monkeypatch: pytest.MonkeyPatch): + _patch_newton_marker_render_deps(monkeypatch) + translations = torch.arange(4, dtype=torch.float32).unsqueeze(1).repeat(1, 3) + marker = _make_newton_marker_for_render( + marker_names=["arrow", "sphere"], + translations=translations, + marker_indices=torch.tensor([0, 1, 0, 1], dtype=torch.int32), + ) + viewer = _FakeNewtonMarkerViewer() + + marker.render(viewer, visible_env_ids=None, num_envs=4) + + visible_instances = [call for call in viewer.instances if not call["hidden"]] + assert [call["batch_name"] for call in visible_instances] == [ + "/Visuals/marker::test/arrow", + "/Visuals/marker::test/sphere", + ] + assert [call["xforms"].shape[0] for call in visible_instances] == [2, 2] + assert visible_instances[0]["materials"][:, 3].tolist() == [1.0, 1.0] + assert visible_instances[1]["materials"][:, 3].tolist() == [0.0, 0.0] + + +def test_newton_marker_render_hides_unselected_prototypes(monkeypatch: pytest.MonkeyPatch): + _patch_newton_marker_render_deps(monkeypatch) + marker = _make_newton_marker_for_render( + marker_names=["arrow", "sphere", "frame"], + translations=torch.zeros((3, 3), dtype=torch.float32), + marker_indices=torch.zeros(3, dtype=torch.int32), + ) + viewer = _FakeNewtonMarkerViewer() + + marker.render(viewer, visible_env_ids=None, num_envs=3) + + hidden_instances = [call for call in viewer.instances if call["hidden"]] + assert [call["batch_name"] for call in hidden_instances] == ["/Visuals/marker::test/sphere"] + assert viewer.lines == [ + { + "batch_name": "/Visuals/marker::test/frame", + "starts": None, + "ends": None, + "colors": None, + "width": None, + "hidden": True, + } + ] diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index de0515c57e07..7c3abf9f300a 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -26,8 +26,8 @@ def _reset_newton_manager_state(): NewtonManager._model = None NewtonManager._state_0 = None NewtonManager._num_envs = None - NewtonManager._physx_visualization_scene_data = None - NewtonManager._physx_visualization_mapping = None + NewtonManager._visualization_scene_data = None + NewtonManager._visualization_mapping = None def test_ensure_visualization_model_noop_when_backend_is_newton(monkeypatch): @@ -35,7 +35,7 @@ def test_ensure_visualization_model_noop_when_backend_is_newton(monkeypatch): from isaaclab_newton.physics import NewtonManager _reset_newton_manager_state() - monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: True)) + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, scene_data_provider=None: True)) NewtonManager._ensure_visualization_model() assert NewtonManager._model is None assert NewtonManager._state_0 is None @@ -47,7 +47,7 @@ def test_ensure_visualization_model_builds_from_stage_when_backend_is_physx(monk from isaaclab_newton.physics import newton_manager as nm _reset_newton_manager_state() - monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: False)) + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, scene_data_provider=None: False)) monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: SimpleNamespace()) monkeypatch.setattr(nm, "replace_newton_shape_colors", lambda model, *a, **kw: 0) @@ -80,7 +80,7 @@ def test_ensure_visualization_model_empty_builder_logs_and_skips(monkeypatch, ca from isaaclab_newton.physics import newton_manager as nm _reset_newton_manager_state() - monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: False)) + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, scene_data_provider=None: False)) monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: SimpleNamespace()) class _EmptyBuilder: @@ -106,7 +106,7 @@ def test_ensure_visualization_model_populates_num_envs_when_backend_is_physx(mon from isaaclab_newton.physics import newton_manager as nm _reset_newton_manager_state() - monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: False)) + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, scene_data_provider=None: False)) monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: SimpleNamespace()) monkeypatch.setattr(nm, "replace_newton_shape_colors", lambda model, *a, **kw: 0) @@ -136,7 +136,7 @@ def test_ensure_visualization_model_missing_stage_leaves_state_unset(monkeypatch from isaaclab_newton.physics import newton_manager as nm _reset_newton_manager_state() - monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: False)) + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, scene_data_provider=None: False)) monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: None) with caplog.at_level("ERROR"): @@ -152,7 +152,7 @@ def test_update_visualization_state_noop_when_backend_is_newton(monkeypatch): from isaaclab_newton.physics import NewtonManager _reset_newton_manager_state() - monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls: True)) + monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, scene_data_provider=None: True)) # Pre-set sentinel values to ensure update doesn't touch them. NewtonManager._model = "live-model" diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index cf136c37053a..9e590356932d 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -11,20 +11,21 @@ from typing import Any, cast import isaaclab_visualizers.kit.kit_visualizer as kit_visualizer -import isaaclab_visualizers.newton.newton_visualization_markers as newton_markers import isaaclab_visualizers.rerun.rerun_visualizer as rerun_visualizer import isaaclab_visualizers.viser.viser_visualizer as viser_visualizer -import numpy as np import pytest -import torch from isaaclab_visualizers.kit.kit_visualizer_cfg import KitVisualizerCfg from isaaclab_visualizers.rerun.rerun_visualizer_cfg import RerunVisualizerCfg from isaaclab_visualizers.viser.viser_visualizer_cfg import ViserVisualizerCfg -from isaaclab.markers.vis_marker_registry import VisMarkerRegistry from isaaclab.sim.simulation_context import SimulationContext +def test_web_visualizer_cfgs_do_not_open_browser_by_default(): + assert RerunVisualizerCfg().open_browser is False + assert ViserVisualizerCfg().open_browser is False + + class _FakePhysicsManager: def __init__(self): self.forward_calls = 0 @@ -114,6 +115,9 @@ def pumps_app_update(self): def supports_markers(self): return False + def flush_startup_messages(self): + pass + def _make_context(visualizers, provider=None): ctx = object.__new__(SimulationContext) @@ -188,26 +192,6 @@ def test_update_visualizers_handles_training_pause_loop(): assert viz.step_calls == [0.0, 0.2] -def test_vis_marker_registry_dispatch_allows_callback_mutation(): - registry = VisMarkerRegistry() - calls = [] - - def _remove_other_callback(event): - calls.append(("remove_other", event)) - registry.remove_callback("other") - - def _other_callback(event): - calls.append(("other", event)) - - registry.add_callback("remove_other", _remove_other_callback) - registry.add_callback("other", _other_callback) - - registry.dispatch_callbacks("tick") - - assert calls == [("remove_other", "tick"), ("other", "tick")] - assert "other" not in registry._callbacks - - class _DummyViserSceneDataProvider: @property def num_envs(self) -> int: @@ -249,7 +233,7 @@ def _fake_create_viewer(self, record_to_viser: str | None, metadata: dict | None monkeypatch.setattr(viser_visualizer.ViserVisualizer, "_create_viewer", _fake_create_viewer) - state_calls: list[None] = [] + state_calls: list[object] = [] class _FakeNewtonManager: @staticmethod @@ -257,8 +241,8 @@ def get_model(): return "dummy-model" @staticmethod - def get_state(): - state_calls.append(None) + def get_state(scene_data_provider=None): + state_calls.append(scene_data_provider) return {"state_call": len(state_calls)} @staticmethod @@ -274,241 +258,13 @@ def get_num_envs() -> int: visualizer.step(0.25) assert visualizer.is_initialized - assert state_calls == [None, None] + assert state_calls == [provider, provider] assert visualizer._sim_time == pytest.approx(0.25) assert viewer.calls[0][0] == "begin_frame" assert viewer.calls[0][1] == pytest.approx(0.25) - # log_state passes NewtonManager.get_state() through as-is; no env_ids merged in. - assert viewer.calls[1] == ("log_state", {"state_call": 2}) - assert viewer.calls[2] == ("end_frame",) - - -def test_viser_visualizer_marker_render_failure_does_not_interrupt_state_updates( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -): - provider = _DummyViserSceneDataProvider() - viewer = _DummyViserViewer() - marker_calls = [] - - def _fake_create_viewer(self, record_to_viser: str | None, metadata: dict | None = None): - self._viewer = viewer - - def _raise_marker_render(*args, **kwargs): - marker_calls.append((args, kwargs)) - raise RuntimeError("marker overlay failed") - - monkeypatch.setattr(viser_visualizer.ViserVisualizer, "_create_viewer", _fake_create_viewer) - monkeypatch.setattr(viser_visualizer, "render_newton_visualization_markers", _raise_marker_render) - - state_calls: list[None] = [] - - class _FakeNewtonManager: - @staticmethod - def get_model(): - return "dummy-model" - - @staticmethod - def get_state(): - state_calls.append(None) - return {"state_call": len(state_calls)} - - @staticmethod - def get_num_envs() -> int: - return 1 - - import isaaclab_newton.physics as _np_mod - - monkeypatch.setattr(_np_mod, "NewtonManager", _FakeNewtonManager) - - visualizer = viser_visualizer.ViserVisualizer(ViserVisualizerCfg()) - visualizer.initialize(cast(Any, provider)) - - with caplog.at_level("WARNING"): - visualizer.step(0.25) - - assert marker_calls - assert viewer.calls[0][0] == "begin_frame" + # log_state passes NewtonManager.get_state(provider) through as-is; no env_ids merged in. assert viewer.calls[1] == ("log_state", {"state_call": 2}) assert viewer.calls[2] == ("end_frame",) - assert "Marker rendering failed; continuing body updates" in caplog.text - - -def test_newton_marker_mesh_registration_is_per_viewer(monkeypatch: pytest.MonkeyPatch): - marker = object.__new__(newton_markers.NewtonVisualizationMarkers) - marker._registered_meshes = set() - - class _FakeMesh: - vertices = np.zeros((1, 3), dtype=np.float32) - indices = np.zeros((3,), dtype=np.int32) - normals = np.zeros((0, 3), dtype=np.float32) - uvs = np.zeros((0, 2), dtype=np.float32) - - class _FakeViewer: - def __init__(self): - self.meshes = [] - - def log_mesh(self, name, vertices, indices, **kwargs): - self.meshes.append((name, vertices, indices, kwargs)) - - monkeypatch.setattr(newton_markers, "_create_mesh", lambda cfg: _FakeMesh()) - monkeypatch.setattr(newton_markers.wp, "array", lambda value, dtype=None: value) - - spec = newton_markers._NewtonMarkerSpec(renderer="mesh", mesh_type="box", mesh_params={"size": (1.0, 1.0, 1.0)}) - viewer_a = _FakeViewer() - viewer_b = _FakeViewer() - - marker._ensure_mesh_registered(viewer_a, "/Visuals/marker/meshes/arrow", spec) - marker._ensure_mesh_registered(viewer_a, "/Visuals/marker/meshes/arrow", spec) - marker._ensure_mesh_registered(viewer_b, "/Visuals/marker/meshes/arrow", spec) - - assert len(viewer_a.meshes) == 1 - assert len(viewer_b.meshes) == 1 - - -class _FakeNewtonMarkerMesh: - vertices = np.zeros((1, 3), dtype=np.float32) - indices = np.zeros((3,), dtype=np.int32) - normals = np.zeros((0, 3), dtype=np.float32) - uvs = np.zeros((0, 2), dtype=np.float32) - - -class _FakeNewtonMarkerViewer: - def __init__(self): - self.meshes = [] - self.instances = [] - self.lines = [] - - def log_mesh(self, name, vertices, indices, **kwargs): - self.meshes.append((name, vertices, indices, kwargs)) - - def log_instances(self, batch_name, mesh_name, xforms, scales, colors, materials, hidden=False): - self.instances.append( - { - "batch_name": batch_name, - "mesh_name": mesh_name, - "xforms": xforms, - "scales": scales, - "colors": colors, - "materials": materials, - "hidden": hidden, - } - ) - - def log_lines(self, batch_name, starts, ends, colors, width=None, hidden=False): - self.lines.append( - { - "batch_name": batch_name, - "starts": starts, - "ends": ends, - "colors": colors, - "width": width, - "hidden": hidden, - } - ) - - -def _make_newton_marker_for_render( - *, - marker_names: list[str], - translations: torch.Tensor, - marker_indices: torch.Tensor | None = None, - visible: bool = True, -): - marker = object.__new__(newton_markers.NewtonVisualizationMarkers) - marker_cfg_type = type("MarkerCfg", (), {"visual_material": None}) - marker.cfg = type("Cfg", (), {"markers": {name: marker_cfg_type() for name in marker_names}})() - marker.group_id = "/Visuals/marker::test" - marker.visible = visible - marker.translations = translations - marker.orientations = torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32).repeat(translations.shape[0], 1) - marker.scales = torch.ones((translations.shape[0], 3), dtype=torch.float32) - marker.marker_indices = marker_indices - marker.count = translations.shape[0] - marker._registered_meshes = set() - marker._warned_unsupported = set() - return marker - - -def _patch_newton_marker_render_deps(monkeypatch: pytest.MonkeyPatch) -> None: - specs = { - "arrow": newton_markers._NewtonMarkerSpec( - renderer="mesh", - mesh_type="box", - mesh_params={"size": (1.0, 1.0, 1.0)}, - color=(1.0, 1.0, 1.0), - texture=np.zeros((2, 2, 3), dtype=np.uint8), - ), - "sphere": newton_markers._NewtonMarkerSpec(renderer="mesh", mesh_type="sphere", mesh_params={"radius": 1.0}), - "frame": newton_markers._NewtonMarkerSpec(renderer="frame"), - } - - monkeypatch.setattr(newton_markers, "_create_mesh", lambda cfg: _FakeNewtonMarkerMesh()) - monkeypatch.setattr(newton_markers.wp, "array", lambda value, dtype=None: value) - monkeypatch.setattr(newton_markers, "_resolve_newton_marker_cfg", lambda name, marker_cfg, cfg: specs[name]) - - -def test_newton_marker_render_filters_visible_envs(monkeypatch: pytest.MonkeyPatch): - _patch_newton_marker_render_deps(monkeypatch) - translations = torch.arange(8, dtype=torch.float32).unsqueeze(1).repeat(1, 3) - marker = _make_newton_marker_for_render( - marker_names=["arrow"], - translations=translations, - marker_indices=torch.zeros(8, dtype=torch.int32), - ) - viewer = _FakeNewtonMarkerViewer() - - marker.render(viewer, visible_env_ids=[1, 3], num_envs=4) - - assert len(viewer.instances) == 1 - assert viewer.instances[0]["hidden"] is False - assert viewer.instances[0]["xforms"][:, 0].tolist() == [1.0, 3.0, 5.0, 7.0] - - -def test_newton_marker_render_routes_instances_by_prototype(monkeypatch: pytest.MonkeyPatch): - _patch_newton_marker_render_deps(monkeypatch) - translations = torch.arange(4, dtype=torch.float32).unsqueeze(1).repeat(1, 3) - marker = _make_newton_marker_for_render( - marker_names=["arrow", "sphere"], - translations=translations, - marker_indices=torch.tensor([0, 1, 0, 1], dtype=torch.int32), - ) - viewer = _FakeNewtonMarkerViewer() - - marker.render(viewer, visible_env_ids=None, num_envs=4) - - visible_instances = [call for call in viewer.instances if not call["hidden"]] - assert [call["batch_name"] for call in visible_instances] == [ - "/Visuals/marker::test/arrow", - "/Visuals/marker::test/sphere", - ] - assert [call["xforms"].shape[0] for call in visible_instances] == [2, 2] - assert visible_instances[0]["materials"][:, 3].tolist() == [1.0, 1.0] - assert visible_instances[1]["materials"][:, 3].tolist() == [0.0, 0.0] - - -def test_newton_marker_render_hides_unselected_prototypes(monkeypatch: pytest.MonkeyPatch): - _patch_newton_marker_render_deps(monkeypatch) - marker = _make_newton_marker_for_render( - marker_names=["arrow", "sphere", "frame"], - translations=torch.zeros((3, 3), dtype=torch.float32), - marker_indices=torch.zeros(3, dtype=torch.int32), - ) - viewer = _FakeNewtonMarkerViewer() - - marker.render(viewer, visible_env_ids=None, num_envs=3) - - hidden_instances = [call for call in viewer.instances if call["hidden"]] - assert [call["batch_name"] for call in hidden_instances] == ["/Visuals/marker::test/sphere"] - assert viewer.lines == [ - { - "batch_name": "/Visuals/marker::test/frame", - "starts": None, - "ends": None, - "colors": None, - "width": None, - "hidden": True, - } - ] @pytest.mark.parametrize( @@ -531,6 +287,7 @@ def __init__( self, *, port: int, + bind_address: str, label: str | None, verbose: bool, share: bool, @@ -539,6 +296,7 @@ def __init__( ): captured["init"] = { "port": port, + "bind_address": bind_address, "label": label, "verbose": verbose, "share": share, @@ -555,6 +313,10 @@ def set_visible_worlds(self, worlds) -> None: def set_world_offsets(self, spacing) -> None: captured["set_world_offsets"] = tuple(spacing) + @property + def share_url(self) -> str | None: + return None + monkeypatch.setattr(viser_visualizer, "NewtonViewerViser", _FakeNewtonViewerViser) monkeypatch.setattr( viser_visualizer.ViserVisualizer, @@ -574,6 +336,7 @@ def set_world_offsets(self, spacing) -> None: visualizer._create_viewer(record_to_viser="record.viser", metadata={"num_envs": 8}) assert captured["set_model"] == "dummy-model" + assert captured["init"]["bind_address"] == cfg.bind_address assert captured["visible_worlds"] == expected_visible assert captured["set_world_offsets"] == (0.0, 0.0, 0.0) @@ -605,6 +368,7 @@ def __init__( keep_historical_data: bool, keep_scalar_history: bool, record_to_rrd: str | None, + open_browser: bool, ): captured["init"] = { "app_id": app_id, @@ -615,6 +379,7 @@ def __init__( "keep_historical_data": keep_historical_data, "keep_scalar_history": keep_scalar_history, "record_to_rrd": record_to_rrd, + "open_browser": open_browser, } def set_model(self, model: Any) -> None: @@ -647,7 +412,8 @@ def get_model(): return "dummy-model" @staticmethod - def get_state(): + def get_state(scene_data_provider=None): + captured["state_provider"] = scene_data_provider return {"ok": True} @staticmethod @@ -683,69 +449,6 @@ def get_num_envs() -> int: assert captured["set_world_offsets"] == (0.0, 0.0, 0.0) -def test_rerun_visualizer_marker_failure_still_ends_frame(monkeypatch: pytest.MonkeyPatch): - class _FakeRerunViewer: - def __init__(self): - self.calls = [] - - def is_paused(self): - return False - - def begin_frame(self, sim_time): - self.calls.append(("begin_frame", sim_time)) - - def log_state(self, state): - self.calls.append(("log_state", state)) - - def end_frame(self): - self.calls.append(("end_frame",)) - - class _DummyRerunSceneDataProvider: - def get_metadata(self) -> dict: - return {"num_envs": 4} - - def get_newton_state(self): - return {"ok": True} - - def get_camera_transforms(self): - return {} - - def _raise_marker_render(*args, **kwargs): - raise RuntimeError("marker render failed") - - monkeypatch.setattr(rerun_visualizer, "render_newton_visualization_markers", _raise_marker_render) - - class _FakeNewtonManager: - @staticmethod - def get_model(): - return "dummy-model" - - @staticmethod - def get_state(): - return {"ok": True} - - @staticmethod - def get_num_envs() -> int: - return 4 - - import isaaclab_newton.physics as _np_mod - - monkeypatch.setattr(_np_mod, "NewtonManager", _FakeNewtonManager) - - visualizer = rerun_visualizer.RerunVisualizer(RerunVisualizerCfg()) - viewer = _FakeRerunViewer() - visualizer._is_initialized = True - visualizer._is_closed = False - visualizer._viewer = viewer - visualizer._scene_data_provider = _DummyRerunSceneDataProvider() - visualizer._resolved_visible_env_ids = None - - with pytest.raises(RuntimeError, match="marker render failed"): - visualizer.step(0.25) - - assert [call[0] for call in viewer.calls] == ["begin_frame", "log_state", "end_frame"] - - def test_kit_visualizer_default_camera_source_does_not_require_camera_prim(monkeypatch: pytest.MonkeyPatch): """Default ``--viz kit`` should work for envs without a camera prim.""" @@ -806,6 +509,23 @@ def get_usd_stage(self): assert visualizer._controlled_camera_path == "/OmniverseKit_Persp" +def test_kit_visualizer_default_camera_source_accepts_set_camera_view(monkeypatch: pytest.MonkeyPatch): + """Default Kit visualizer camera follows SimulationContext/ViewportCameraController updates.""" + applied_camera_poses = [] + monkeypatch.setattr( + kit_visualizer.KitVisualizer, + "_set_viewport_camera", + lambda self, eye, target: applied_camera_poses.append((tuple(eye), tuple(target))), + ) + + visualizer = kit_visualizer.KitVisualizer(KitVisualizerCfg()) + visualizer._is_initialized = True + + visualizer.set_camera_view((1.0, 2.0, 3.0), (0.0, 0.0, 1.0)) + + assert applied_camera_poses == [((1.0, 2.0, 3.0), (0.0, 0.0, 1.0))] + + def test_get_cli_visualizer_types_handles_non_string_setting_without_crashing(): ctx = object.__new__(SimulationContext) ctx.get_setting = lambda name: {"types": "newton,kit"} if name == "/isaaclab/visualizer/types" else None diff --git a/source/isaaclab_newton/changelog.d/mtrepte-update-debug-viz.rst b/source/isaaclab_newton/changelog.d/mtrepte-update-debug-viz.rst new file mode 100644 index 000000000000..2ca3ab374223 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/mtrepte-update-debug-viz.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed Newton visualization state updates for PhysX-backed simulations. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index fb8188ff5909..24a99aba4282 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -1442,7 +1442,7 @@ def get_state_0(cls) -> State: return cls._state_0 @classmethod - def get_state(cls) -> State: + def get_state(cls, scene_data_provider=None) -> State: """Get the current Newton state for visualization. Use this method from visualizers/renderers/video recorders that need a @@ -1453,7 +1453,7 @@ def get_state(cls) -> State: :meth:`update_visualization_state` is a no-op and this is equivalent to :meth:`get_state_0`. """ - cls.update_visualization_state() + cls.update_visualization_state(scene_data_provider) return cls.get_state_0() @classmethod @@ -1461,8 +1461,10 @@ def get_num_envs(cls) -> int: return cls._num_envs @classmethod - def _backend_is_newton(cls) -> bool: + def _backend_is_newton(cls, scene_data_provider=None) -> bool: """Return ``True`` when the active sim backend is Newton.""" + if scene_data_provider is not None: + return isinstance(scene_data_provider.backend, NewtonSceneDataBackend) sim = PhysicsManager._sim if sim is None: return False @@ -1668,7 +1670,7 @@ def _build_visualization_model_from_stage(cls, stage) -> ModelBuilder | None: return builder @classmethod - def update_visualization_state(cls) -> None: + def update_visualization_state(cls, scene_data_provider=None) -> None: """Refresh visualization state for the active sim backend. Newton sim backend: no-op — ``_state_0`` is the live, authoritative state @@ -1683,16 +1685,19 @@ def update_visualization_state(cls) -> None: Invoked lazily from :meth:`get_state` so consumers do not need to coordinate the sync explicitly. """ - if cls._backend_is_newton(): + if cls._backend_is_newton(scene_data_provider): return cls._ensure_visualization_model() if cls._state_0 is None or cls._model is None or cls._state_0.body_q is None: return - sim = PhysicsManager._sim - if sim is None: + sdp = scene_data_provider + if sdp is None: + sim = PhysicsManager._sim + if sim is not None: + sdp = sim.get_scene_data_provider() + if sdp is None: return - sdp = sim.get_scene_data_provider() if cls._visualization_scene_data is None: cls._visualization_scene_data = SceneDataFormat.Transform() if cls._visualization_mapping is None: diff --git a/source/isaaclab_physx/changelog.d/mtrepte-update-debug-viz.skip b/source/isaaclab_physx/changelog.d/mtrepte-update-debug-viz.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index bb23bc130ad5..00e9ed3007d6 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -335,7 +335,6 @@ def step(cls) -> None: cls._physx_sim.simulate(sim.cfg.dt, 0.0) cls._physx_sim.fetch_results() - device = PhysicsManager._device if "cuda" in device: torch.cuda.set_device(device) @@ -348,6 +347,7 @@ def play(cls) -> None: cls._timeline.play() # Pump events so timeline callbacks fire synchronously omni.kit.app.get_app().update() + cls._sync_fabric_after_resume() @classmethod def pause(cls) -> None: @@ -377,15 +377,20 @@ def wait_for_playing(cls) -> None: app.update() if cls._timeline.is_stopped(): break - # Force fabric to re-sync articulation transforms after resume. - # detach/attach resets the FabricManager, then we immediately push - # current poses so the first render after resume shows correct state. - if not cls._timeline.is_stopped(): - cls._re_sync_fabric() - if cls._view is not None: - cls._view.update_articulations_kinematic() - if cls._update_fabric is not None: - cls._update_fabric(0.0, 0.0) + cls._sync_fabric_after_resume() + + @classmethod + def _sync_fabric_after_resume(cls) -> None: + """Force Fabric to show current articulation transforms after timeline resume.""" + if cls._timeline.is_stopped(): + return + # detach/attach resets the FabricManager, then immediately push current + # poses so the first render after resume shows correct state. + cls._re_sync_fabric() + if cls._view is not None: + cls._view.update_articulations_kinematic() + if cls._update_fabric is not None: + cls._update_fabric(0.0, 0.0) @classmethod def close(cls) -> None: diff --git a/source/isaaclab_rl/changelog.d/mtrepte-update-debug-viz.skip b/source/isaaclab_rl/changelog.d/mtrepte-update-debug-viz.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/changelog.d/mtrepte-update-debug-viz.skip b/source/isaaclab_tasks/changelog.d/mtrepte-update-debug-viz.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_visualizers/changelog.d/mtrepte-update-debug-viz.rst b/source/isaaclab_visualizers/changelog.d/mtrepte-update-debug-viz.rst new file mode 100644 index 000000000000..c26216180202 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/mtrepte-update-debug-viz.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed Rerun and Viser visualizers to avoid opening browser tabs by default and to show browser URLs in the startup logs instead. +* Changed visualizer initialization tables to debug-level logging to reduce default startup log noise. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 4766fbe7f68f..133c565ccbad 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -188,17 +188,10 @@ 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 diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 31c17a7b16d6..565c16b0251b 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -10,13 +10,13 @@ import logging from typing import TYPE_CHECKING -import numpy as np import warp as wp from newton.viewer import ViewerGL from pyglet.math import Vec3 as PygletVec3 from isaaclab.visualizers.base_visualizer import BaseVisualizer +from isaaclab_visualizers.newton.newton_visualization_markers import render_newton_visualization_markers from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds, resolve_visible_env_indices from .newton_visualizer_cfg import NewtonVisualizerCfg @@ -290,7 +290,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: metadata = {"num_envs": num_envs} self._env_ids = self._compute_visualized_env_ids() self._model = NewtonManager.get_model() - self._state = NewtonManager.get_state() + self._state = NewtonManager.get_state(self._scene_data_provider) # 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`. @@ -316,6 +316,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: num_envs=num_envs, ) self._viewer.set_world_offsets((0.0, 0.0, 0.0)) + self._apply_camera_focal_length() initial_pose = self._resolve_initial_camera_pose() self._apply_camera_pose(initial_pose) self._viewer.up_axis = 2 # Z-up @@ -352,6 +353,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: tuple(float(x) for x in self._viewer.camera.pos) if self._viewer is not None else self.cfg.eye, ), ("lookat", self._last_camera_pose[1] if self._last_camera_pose else self.cfg.lookat), + ("focal_length", self.cfg.focal_length), ("cam_source", self.cfg.cam_source), ("num_visualized_envs", num_visualized_envs), ("headless", self.cfg.headless), @@ -374,28 +376,35 @@ def step(self, dt: float) -> None: from isaaclab_newton.physics import NewtonManager if self._viewer is None: - self._state = NewtonManager.get_state() + self._state = NewtonManager.get_state(self._scene_data_provider) return if self.cfg.cam_source == "prim_path": self._update_camera_from_usd_path() - self._state = NewtonManager.get_state() + self._state = NewtonManager.get_state(self._scene_data_provider) update_frequency = self._viewer._update_frequency if self._viewer else self._update_frequency if self._step_counter % update_frequency != 0: return + num_envs = NewtonManager.get_num_envs() + try: if not self._viewer.is_paused(): self._viewer.begin_frame(self._sim_time) - if self._state is not None: - body_q = getattr(self._state, "body_q", None) - if hasattr(body_q, "shape") and body_q.shape[0] == 0: - self._viewer.end_frame() - return - self._viewer.log_state(self._state) - self._viewer.end_frame() + try: + if self._state is not None: + body_q = getattr(self._state, "body_q", None) + if hasattr(body_q, "shape") and body_q.shape[0] == 0: + return + self._viewer.log_state(self._state) + if self.cfg.enable_markers: + render_newton_visualization_markers( + self._viewer, self._resolved_visible_env_ids, num_envs=num_envs + ) + finally: + self._viewer.end_frame() else: self._viewer._update() except Exception as exc: @@ -450,16 +459,15 @@ def _apply_camera_pose(self, pose: tuple[tuple[float, float, float], tuple[float cam_pos, cam_target = pose # Match Newton's Camera native pos type: PyVec3, not wp.vec3. self._viewer.camera.pos = PygletVec3(*cam_pos) - cam_pos_np = np.array(cam_pos, dtype=np.float32) - cam_target_np = np.array(cam_target, dtype=np.float32) - direction = cam_target_np - cam_pos_np - yaw = np.degrees(np.arctan2(direction[1], direction[0])) - horizontal_dist = np.sqrt(direction[0] ** 2 + direction[1] ** 2) - pitch = np.degrees(np.arctan2(direction[2], horizontal_dist)) - self._viewer.camera.yaw = float(yaw) - self._viewer.camera.pitch = float(pitch) + self._viewer.camera.look_at(cam_target) self._last_camera_pose = (cam_pos, cam_target) + def _apply_camera_focal_length(self) -> None: + """Apply cfg focal length to Newton's vertical-FOV camera.""" + if self._viewer is None: + return + self._viewer.camera.fov = self._focal_length_to_vertical_fov_degrees() + 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.cam_prim_path) diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py index 7e6f9a00331a..cc55dcef7fa5 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py @@ -8,6 +8,8 @@ from __future__ import annotations import atexit +import contextlib +import inspect import logging import socket import webbrowser @@ -85,15 +87,32 @@ def _open_rerun_web_viewer(host: str, web_port: int, connect_to: str) -> None: def _rerun_web_viewer_url(host: str, web_port: int, connect_to: str) -> str: """Return rerun web UI URL with prefilled endpoint.""" - return f"http://{host}:{int(web_port)}/?url={quote(connect_to, safe='')}" + # Keep the nested URL readable while still encoding '+' in the rerun+http scheme. + return f"http://{host}:{int(web_port)}/?url={quote(connect_to, safe=':/')}" class NewtonViewerRerun(ViewerRerun): """Wrapper around Newton's ViewerRerun with rendering pause controls.""" - def __init__(self, *args, **kwargs): + def __init__(self, *args, open_browser: bool = False, **kwargs): """Initialize viewer wrapper and Isaac Lab pause state.""" - super().__init__(*args, **kwargs) + if open_browser: + super().__init__(*args, **kwargs) + else: + original_serve_web_viewer = rr.serve_web_viewer + + # Rerun Viewer launches a browser automatically, so here we suppress that behavior + def _serve_web_viewer_without_browser(*serve_args, **serve_kwargs): + with contextlib.suppress(TypeError, ValueError): + supports_open_browser = "open_browser" in inspect.signature(original_serve_web_viewer).parameters + if supports_open_browser: + serve_kwargs.setdefault("open_browser", False) + return original_serve_web_viewer(*serve_args, **serve_kwargs) + + with contextlib.ExitStack() as stack: + rr.serve_web_viewer = _serve_web_viewer_without_browser + stack.callback(setattr, rr, "serve_web_viewer", original_serve_web_viewer) + super().__init__(*args, **kwargs) self._paused_rendering = False def is_rendering_paused(self) -> bool: @@ -153,7 +172,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: num_envs = scene_data_provider.num_envs self._env_ids = self._compute_visualized_env_ids() self._model = NewtonManager.get_model() - self._state = NewtonManager.get_state() + self._state = NewtonManager.get_state(self._scene_data_provider) grpc_port = int(self.cfg.grpc_port) web_port = int(self.cfg.web_port) @@ -177,11 +196,14 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: keep_historical_data=self.cfg.keep_historical_data, keep_scalar_history=self.cfg.keep_scalar_history, record_to_rrd=self.cfg.record_to_rrd, + open_browser=self.cfg.open_browser, ) if start_server_in_viewer: rerun_address = getattr(self._viewer, "_grpc_server_uri", rerun_address) viewer_host = _normalize_host(bind_address) viewer_url = _rerun_web_viewer_url(viewer_host, web_port, rerun_address) + print() + self._log_viewer_url("RerunVisualizer", viewer_url) 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) @@ -209,10 +231,10 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: rows=[ ("eye", self.cfg.eye), ("lookat", self.cfg.lookat), + ("focal_length", f"{self.cfg.focal_length} (not applied: Rerun EyeControls3D has no FOV field)"), ("cam_source", self.cfg.cam_source), ("num_visualized_envs", num_visualized_envs), ("endpoint", f"http://{viewer_host}:{web_port}"), - ("viewer_url", viewer_url), ("bind_address", bind_address), ("grpc_port", grpc_port), ("web_port", web_port), @@ -241,7 +263,7 @@ def step(self, dt: float) -> None: if self.cfg.cam_source == "prim_path": self._update_camera_from_usd_path() - self._state = NewtonManager.get_state() + self._state = NewtonManager.get_state(self._scene_data_provider) num_envs = NewtonManager.get_num_envs() if not self._viewer.is_paused(): 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 dad671bf57f7..c3229a1916de 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer_cfg.py @@ -22,7 +22,7 @@ class RerunVisualizerCfg(VisualizerCfg): """Application identifier shown in viewer title.""" web_port: int = 9090 - """Port of the local rerun web viewer which is launched in the browser.""" + """Port of the local rerun web viewer whose URL is logged during initialization.""" grpc_port: int = 9876 """Port of the rerun gRPC server (used when serving web viewer externally).""" @@ -36,8 +36,11 @@ class RerunVisualizerCfg(VisualizerCfg): - Local browser links normalize common loopback/wildcard hosts to ``127.0.0.1``. """ - open_browser: bool = True - """Whether to attempt opening the rerun web viewer URL in a browser.""" + open_browser: bool = False + """Whether to attempt opening the rerun web viewer URL in a browser. + + The viewer URL is always logged during initialization. Set this to ``True`` to auto-launch it. + """ keep_historical_data: bool = False """Keep transform history for time scrubbing (False = constant memory for training).""" diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py index b3569b04dbf3..5848690263f1 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py @@ -10,6 +10,7 @@ import contextlib import io import logging +import math import os import webbrowser from pathlib import Path @@ -46,26 +47,8 @@ def _disable_viser_runtime_client_rebuild_if_bundled() -> None: client_autobuild.ensure_client_is_built = lambda: None -@contextlib.contextmanager -def _suppress_viser_startup_logs(enabled: bool): - """Temporarily quiet noisy viser/websockets startup output.""" - if not enabled: - yield - return - - websockets_logger = logging.getLogger("websockets.server") - previous_level = websockets_logger.level - websockets_logger.setLevel(logging.WARNING) - try: - with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): - yield - finally: - websockets_logger.setLevel(previous_level) - - -def _open_viser_web_viewer(port: int) -> None: - """Open the local viser web UI in a browser.""" - url = _viser_web_viewer_url(port) +def _open_viser_web_viewer(url: str) -> None: + """Open the Viser web UI in a browser.""" try: if not webbrowser.open_new_tab(url): logger.info("[ViserVisualizer] Could not auto-open browser tab. Open manually: %s", url) @@ -73,9 +56,9 @@ def _open_viser_web_viewer(port: int) -> None: logger.info("[ViserVisualizer] Could not auto-open browser tab. Open manually: %s", url) -def _viser_web_viewer_url(port: int) -> str: - """Return local viser web UI URL.""" - return f"http://localhost:{int(port)}" +def _viser_web_viewer_url(port: int, display_address: str) -> str: + """Return Viser web UI URL for display to users.""" + return f"http://{display_address}:{int(port)}" class NewtonViewerViser(ViewerViser): @@ -84,6 +67,7 @@ class NewtonViewerViser(ViewerViser): def __init__( self, port: int = 8080, + bind_address: str = "0.0.0.0", label: str | None = None, verbose: bool = True, share: bool = False, @@ -94,6 +78,7 @@ def __init__( Args: port: HTTP port for viser server. + bind_address: Host/interface for the Viser server to bind. label: Optional viewer label. verbose: Whether to keep verbose startup output enabled. share: Whether to enable sharing/tunneling. @@ -101,15 +86,34 @@ def __init__( metadata: Optional metadata attached to the viewer. """ _disable_viser_runtime_client_rebuild_if_bundled() - super().__init__( - port=port, - label=label, - verbose=verbose, - share=share, - record_to_viser=record_to_viser, - ) + viser = self._get_viser() + original_viser_server = viser.ViserServer + + def _viser_server_with_bind_address(*args, **kwargs): + kwargs["host"] = bind_address + kwargs["verbose"] = verbose + return original_viser_server(*args, **kwargs) + + with contextlib.ExitStack() as stack: + viser.ViserServer = _viser_server_with_bind_address + stack.callback(setattr, viser, "ViserServer", original_viser_server) + if not verbose: + stack.enter_context(contextlib.redirect_stdout(io.StringIO())) + stack.enter_context(contextlib.redirect_stderr(io.StringIO())) + super().__init__( + port=port, + label=label, + verbose=verbose, + share=share, + record_to_viser=record_to_viser, + ) self._metadata = metadata or {} + @property + def share_url(self) -> str | None: + """Return the public share URL created by Viser, if any.""" + return self._share_url + class ViserVisualizer(BaseVisualizer): """Viser web-based visualizer backed by Newton's ViewerViser.""" @@ -152,7 +156,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: metadata = {"num_envs": num_envs} self._env_ids = self._compute_visualized_env_ids() self._model = NewtonManager.get_model() - self._state = NewtonManager.get_state() + self._state = NewtonManager.get_state(self._scene_data_provider) self._active_record_path = self.cfg.record_to_viser self._create_viewer(record_to_viser=self.cfg.record_to_viser, metadata=metadata) @@ -160,17 +164,18 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: num_visualized_envs = ( len(self._resolved_visible_env_ids) if self._resolved_visible_env_ids is not None else num_envs ) - viewer_url = _viser_web_viewer_url(self.cfg.port) self._log_initialization_table( logger=logger, title="ViserVisualizer Configuration", rows=[ ("eye", self.cfg.eye), ("lookat", self.cfg.lookat), + ("focal_length", self.cfg.focal_length), ("cam_source", self.cfg.cam_source), ("num_visualized_envs", num_visualized_envs), + ("bind_address", self.cfg.bind_address), + ("display_address", self.cfg.display_address), ("port", self.cfg.port), - ("viewer_url", viewer_url), ("record_to_viser", self.cfg.record_to_viser or ""), ], ) @@ -191,7 +196,7 @@ def step(self, dt: float) -> None: self._update_camera_from_usd_path() self._apply_pending_camera_pose() - self._state = NewtonManager.get_state() + self._state = NewtonManager.get_state(self._scene_data_provider) num_envs = NewtonManager.get_num_envs() self._sim_time += dt @@ -266,14 +271,21 @@ def _create_viewer(self, record_to_viser: str | None, metadata: dict | None = No if self._model is None: raise RuntimeError("Viser visualizer requires a Newton model.") - with _suppress_viser_startup_logs(enabled=not self.cfg.verbose): - self._viewer = NewtonViewerViser( - port=self.cfg.port, - label=self.cfg.label, - verbose=self.cfg.verbose, - share=self.cfg.share, - record_to_viser=record_to_viser, - metadata=metadata or {}, + self._viewer = NewtonViewerViser( + port=self.cfg.port, + bind_address=self.cfg.bind_address, + label=self.cfg.label, + verbose=False, + share=self.cfg.share, + record_to_viser=record_to_viser, + metadata=metadata or {}, + ) + viewer_url = self._viewer.share_url or _viser_web_viewer_url(self.cfg.port, self.cfg.display_address) + if self.cfg.verbose: + print() + self._log_viewer_url( + "ViserVisualizer", + viewer_url, ) num_envs = int((metadata or {}).get("num_envs", 0)) self._viewer.set_model(self._model) @@ -286,7 +298,7 @@ def _create_viewer(self, record_to_viser: str | None, metadata: dict | None = No # 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: - _open_viser_web_viewer(self.cfg.port) + _open_viser_web_viewer(viewer_url) initial_pose = self._resolve_initial_camera_pose() self._set_viser_camera_view(initial_pose) self._sim_time = 0.0 @@ -336,12 +348,16 @@ def _try_apply_viser_camera_view(self, pose: tuple[tuple[float, float, float], t client_iterable = clients.values() if isinstance(clients, dict) else clients cam_pos, cam_target = pose + fov_radians = math.radians(self._focal_length_to_vertical_fov_degrees()) applied = False for client in client_iterable: camera = getattr(client, "camera", None) if camera is None: continue try: + if hasattr(camera, "fov"): + camera.fov = fov_radians + applied = True if hasattr(camera, "position"): camera.position = cam_pos applied = True 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 68ab3116b45a..b039924d392b 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer_cfg.py @@ -21,8 +21,23 @@ class ViserVisualizerCfg(VisualizerCfg): port: int = 8080 """Port of the local viser web server.""" - open_browser: bool = True - """Whether to attempt opening the viser web viewer URL in a browser.""" + bind_address: str = "0.0.0.0" + """Host/interface for the Viser server to bind. + + Use ``"0.0.0.0"`` to listen on all interfaces for remote access. + """ + + display_address: str = "localhost" + """Host name or IP address shown in the printed browser URL. + + For remote access, set this to the hostname/IP reachable from your browser. + """ + + open_browser: bool = False + """Whether to attempt opening the viser web viewer URL in a browser. + + The viewer URL is always logged during initialization. Set this to ``True`` to auto-launch it. + """ label: str | None = "Isaac Lab Simulation" """Optional label shown in the viewer page title.""" diff --git a/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py index 79bad6ea1459..bcdeac5e2419 100644 --- a/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py +++ b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py @@ -522,6 +522,7 @@ def test_cartpole_newton_visualizer_tiled_camera_rgb_non_black( @pytest.mark.isaacsim_ci +@pytest.mark.skip(reason="ViewerGL frame motion is flaky on the current pinned Isaac Sim CI image.") @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.""" From 3896938e408add7a8042ad4652af2ef22a837503 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis <46975745+mmichelis@users.noreply.github.com> Date: Wed, 20 May 2026 03:47:17 +0200 Subject: [PATCH 120/133] Add experimental Newton deformable support with rigid-deformable coupling (#5443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Introduces experimental Newton-backend deformable simulation alongside the existing PhysX deformables, refactors deformable APIs to be backend-neutral, and adds the manager-based Franka soft-body lifting environment used to validate two-way coupling. ### Backend-neutral deformable APIs (`isaaclab`) - Moved deformable schemas, materials, spawners, and `DeformableObjectCfg` out of `isaaclab_physx` into `isaaclab.sim` / `isaaclab.assets` so user code imports a single backend-agnostic API. - **Breaking (PhysX):** `isaaclab_physx.sim.DeformableBodyPropertiesCfg`, `DeformableObjectSpawnerCfg`, `DeformableBodyMaterialCfg`, `SurfaceDeformableBodyMaterialCfg`, `spawn_deformable_body_material`, `define/modify_deformable_body_properties` and `isaaclab_physx.assets.DeformableObjectCfg` are now imported from `isaaclab.sim` / `isaaclab.assets`. See `docs/source/migration/migrating_deformables.rst`. - USD spawners now support assets that embed tetrahedral mesh data, with automatic surface-mesh extraction. - Added `pytetwild` dependency for tet-mesh generation. ### Newton manager abstraction (`isaaclab_newton`) - Added deformable registration hooks to Newton cloning + Fabric sync fixes for particle meshes and particle-only scenes. ### Experimental Newton deformables (`isaaclab_contrib.deformable`) - `DeformableObject` (Newton backend), `VBDSolverCfg`, and coupled solver configs `CoupledMJWarpVBDSolverCfg` / `CoupledFeatherstoneVBDSolverCfg` providing one- and two-way rigid–deformable coupling. - New docs: `docs/source/features/newton-physics-integration/using-vbd-solver.rst` (VBD solver tuning + Franka soft-body lift guidance). ### Tasks, demos, tutorials - Added `Isaac-Lift-Soft-Franka-v0` (manager-based), the documented rigid–deformable coupling task. - Updated `scripts/demos/deformables.py`, the `01_assets/run_deformable_object` tutorial, and `00_sim/spawn_prims` to use the backend-neutral API with selectable PhysX or Newton backends. - Added IK + video-record state-machine demo `scripts/environments/state_machine/lift_franka_soft.py`. Continues on #5439 adding VBD solver and coupled MJWarp + VBD solver as newton managers in isaaclab_contrib. Fixes #5285 ## Type of change - New feature (non-breaking change which adds functionality) - Breaking change: Moved deformable schemas, materials, spawners, and `DeformableObjectCfg` out of `isaaclab_physx` into `isaaclab.sim` / `isaaclab.assets` so user code imports a single backend-agnostic API. ## Test plan - [x] `./isaaclab.sh -p -m pytest source/isaaclab/test/sim/test_deformable_backend_split.py` - [x] `./isaaclab.sh -p -m pytest source/isaaclab_physx/test/assets/test_deformable_object.py` - [x] `./isaaclab.sh -p -m pytest source/isaaclab_contrib/test/deformable/` - [x] Run `scripts/demos/deformables.py` against PhysX and Newton backends - [x] Run `Isaac-Lift-Soft-Franka-v0` training for sanity-check rollout ## 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 --------- Signed-off-by: Mike Yan Michelis <46975745+mmichelis@users.noreply.github.com> Signed-off-by: Kelly Guo Co-authored-by: Copilot Co-authored-by: Donglai Co-authored-by: Anka Chen Co-authored-by: Kelly Guo Co-authored-by: ooctipus Co-authored-by: mingxueg --- .github/workflows/license-exceptions.json | 5 + docs/conf.py | 1 + docs/source/_static/css/custom.css | 7 + .../newton/franka-mjwarp-vbd-coupling.png | Bin 0 -> 1309804 bytes docs/source/api/index.rst | 48 +- docs/source/api/lab/isaaclab.assets.rst | 36 + docs/source/api/lab/isaaclab.sim.schemas.rst | 21 +- docs/source/api/lab/isaaclab.sim.spawners.rst | 56 +- .../isaaclab_contrib.deformable.rst | 73 ++ .../isaaclab_experimental.envs.rst | 4 + .../isaaclab_experimental.managers.rst | 4 + .../isaaclab_experimental.utils.rst | 4 + .../api/lab_newton/isaaclab_newton.assets.rst | 79 ++ .../lab_newton/isaaclab_newton.physics.rst | 100 ++ .../isaaclab_newton.sim.schemas.rst | 12 + .../isaaclab_newton.sim.spawners.rst | 33 + .../lab_ovphysx/isaaclab_ovphysx.assets.rst | 4 + .../lab_ovphysx/isaaclab_ovphysx.cloner.rst | 4 + .../lab_ovphysx/isaaclab_ovphysx.physics.rst | 4 + .../api/lab_physx/isaaclab_physx.assets.rst | 13 +- .../lab_physx/isaaclab_physx.sim.schemas.rst | 22 +- .../lab_physx/isaaclab_physx.sim.spawners.rst | 48 +- .../migration/migrating_deformables.rst | 96 +- .../migration/migrating_to_isaaclab_3-0.rst | 43 +- .../multi_backend_architecture.rst | 8 + .../physical-backends/newton/index.rst | 12 +- .../newton/mjwarp-solver.rst | 4 +- .../newton/newton-manager-abstraction.rst | 237 +++++ .../newton/supported-features.rst | 22 +- .../newton/using-vbd-solver.rst | 344 +++++++ .../newton/warp-environments.rst | 9 +- docs/source/overview/environments.rst | 15 + docs/source/overview/showroom.rst | 5 +- docs/source/tutorials/00_sim/spawn_prims.rst | 3 +- .../01_assets/run_deformable_object.rst | 39 +- scripts/demos/deformables.py | 121 ++- .../state_machine/lift_franka_soft.py | 382 +++++++ scripts/tutorials/00_sim/spawn_prims.py | 7 +- .../01_assets/run_deformable_object.py | 56 +- .../mym-deformable-backend-split.major.rst | 19 + .../mym-deformable_experimental.minor.rst | 34 + source/isaaclab/isaaclab/assets/__init__.pyi | 12 + .../assets/deformable_object/__init__.py | 10 + .../assets/deformable_object/__init__.pyi | 18 + .../base_deformable_object.py | 371 +++++++ .../base_deformable_object_data.py | 136 +++ .../deformable_object/deformable_object.py | 27 + .../deformable_object_cfg.py | 0 .../deformable_object_data.py | 25 + .../isaaclab/scene/interactive_scene.py | 6 +- source/isaaclab/isaaclab/sim/__init__.py | 7 +- source/isaaclab/isaaclab/sim/__init__.pyi | 28 +- .../isaaclab/isaaclab/sim/schemas/__init__.py | 3 +- .../isaaclab/sim/schemas/__init__.pyi | 8 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 364 ++++++- .../isaaclab/sim/schemas/schemas_cfg.py | 14 +- .../isaaclab/sim/spawners/__init__.pyi | 21 +- .../sim/spawners/from_files/from_files.py | 16 +- .../sim/spawners/from_files/from_files_cfg.py | 5 +- .../sim/spawners/materials/__init__.py | 4 + .../sim/spawners/materials/__init__.pyi | 11 +- .../spawners/materials/physics_materials.py | 50 +- .../materials/physics_materials_cfg.py | 27 +- .../isaaclab/sim/spawners/meshes/__init__.pyi | 8 +- .../isaaclab/sim/spawners/meshes/meshes.py | 27 +- .../sim/spawners/meshes/meshes_cfg.py | 19 +- .../isaaclab/sim/spawners/spawner_cfg.py | 22 + .../sim/spawners/wrappers/wrappers_cfg.py | 19 +- source/isaaclab/setup.py | 2 + .../test/sim/test_deformable_backend_split.py | 131 +++ source/isaaclab/test/sim/test_schemas.py | 16 - source/isaaclab/test/sim/test_schemas_shim.py | 31 +- source/isaaclab/test/sim/test_spawn_meshes.py | 15 +- .../mym-deformable-backend-split.skip | 1 + .../mym-deformable_experimental.minor.rst | 16 + .../isaaclab_contrib/deformable/__init__.py | 13 + .../isaaclab_contrib/deformable/__init__.pyi | 22 + .../coupled_featherstone_vbd_manager.py | 509 +++++++++ .../deformable/coupled_mjwarp_vbd_manager.py | 452 ++++++++ .../deformable/deformable_object.py | 962 ++++++++++++++++++ .../deformable/deformable_object_data.py | 206 ++++ .../isaaclab_contrib/deformable/kernels.py | 384 +++++++ .../deformable/newton_manager_cfg.py | 211 ++++ .../deformable/vbd_manager.py | 285 ++++++ .../test_deformable_builder_hooks.py | 100 ++ .../test/deformable/test_deformable_object.py | 717 +++++++++++++ .../test_rigid_deformable_coupling.py | 332 ++++++ .../mym-deformable-backend-split.minor.rst | 4 + .../mym-deformable_experimental.minor.rst | 33 + .../isaaclab_newton/assets/__init__.pyi | 3 + .../assets/articulation/articulation.py | 6 + .../assets/deformable_object/__init__.py | 8 + .../assets/deformable_object/__init__.pyi | 12 + .../cloner/newton_replicate.py | 31 + .../physics/newton_collision_cfg.py | 8 +- .../isaaclab_newton/physics/newton_manager.py | 192 +++- .../isaaclab_newton/sim/__init__.pyi | 14 +- .../isaaclab_newton/sim/schemas/__init__.py | 15 +- .../isaaclab_newton/sim/schemas/__init__.pyi | 13 + .../sim/schemas/schemas_cfg.py | 19 + .../isaaclab_newton/sim/spawners/__init__.py | 10 + .../isaaclab_newton/sim/spawners/__init__.pyi | 10 + .../sim/spawners/materials/__init__.py | 10 + .../sim/spawners/materials/__init__.pyi | 18 + .../spawners/materials/physics_materials.py | 10 + .../materials/physics_materials_cfg.py | 79 ++ .../mym-deformable-backend-split.minor.rst | 11 + .../mym-deformable_experimental.major.rst | 24 + .../assets/deformable_object/__init__.pyi | 3 +- .../deformable_object/deformable_object.py | 38 +- .../deformable_object_data.py | 6 +- .../isaaclab_physx/sim/__init__.pyi | 12 +- .../isaaclab_physx/sim/schemas/__init__.py | 2 +- .../isaaclab_physx/sim/schemas/__init__.pyi | 6 +- .../isaaclab_physx/sim/schemas/schemas.py | 204 +--- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 77 +- .../isaaclab_physx/sim/spawners/__init__.py | 2 +- .../isaaclab_physx/sim/spawners/__init__.pyi | 8 +- .../sim/spawners/materials/__init__.py | 2 +- .../sim/spawners/materials/__init__.pyi | 8 +- .../spawners/materials/physics_materials.py | 80 -- .../materials/physics_materials_cfg.py | 139 +-- .../sim/spawners/spawner_cfg.py | 39 - .../test/assets/test_deformable_object.py | 50 +- .../test/sim/test_spawn_materials.py | 28 +- .../test/sim/test_spawn_meshes.py | 58 +- .../mym-deformable-backend-split.rst | 6 + .../mym-deformable_experimental.minor.rst | 6 + .../lift/config/franka/ik_abs_env_cfg.py | 6 +- .../manipulation/lift_franka_soft/__init__.py | 33 + .../lift_franka_soft/agents/__init__.py | 4 + .../lift_franka_soft/agents/rsl_rl_ppo_cfg.py | 38 + .../lift_franka_soft/franka_cloth_env_cfg.py | 242 +++++ .../lift_franka_soft/franka_soft_env_cfg.py | 448 ++++++++ .../lift_franka_soft/mdp/__init__.py | 10 + .../lift_franka_soft/mdp/__init__.pyi | 28 + .../lift_franka_soft/mdp/observations.py | 115 +++ .../lift_franka_soft/mdp/rewards.py | 167 +++ 138 files changed, 9148 insertions(+), 944 deletions(-) create mode 100644 docs/source/_static/newton/franka-mjwarp-vbd-coupling.png create mode 100644 docs/source/api/lab_contrib/isaaclab_contrib.deformable.rst create mode 100644 docs/source/api/lab_experimental/isaaclab_experimental.envs.rst create mode 100644 docs/source/api/lab_experimental/isaaclab_experimental.managers.rst create mode 100644 docs/source/api/lab_experimental/isaaclab_experimental.utils.rst create mode 100644 docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst create mode 100644 docs/source/api/lab_ovphysx/isaaclab_ovphysx.assets.rst create mode 100644 docs/source/api/lab_ovphysx/isaaclab_ovphysx.cloner.rst create mode 100644 docs/source/api/lab_ovphysx/isaaclab_ovphysx.physics.rst create mode 100644 docs/source/overview/core-concepts/physical-backends/newton/newton-manager-abstraction.rst create mode 100644 docs/source/overview/core-concepts/physical-backends/newton/using-vbd-solver.rst create mode 100644 scripts/environments/state_machine/lift_franka_soft.py create mode 100644 source/isaaclab/changelog.d/mym-deformable-backend-split.major.rst create mode 100644 source/isaaclab/changelog.d/mym-deformable_experimental.minor.rst create mode 100644 source/isaaclab/isaaclab/assets/deformable_object/__init__.py create mode 100644 source/isaaclab/isaaclab/assets/deformable_object/__init__.pyi create mode 100644 source/isaaclab/isaaclab/assets/deformable_object/base_deformable_object.py create mode 100644 source/isaaclab/isaaclab/assets/deformable_object/base_deformable_object_data.py create mode 100644 source/isaaclab/isaaclab/assets/deformable_object/deformable_object.py rename source/{isaaclab_physx/isaaclab_physx => isaaclab/isaaclab}/assets/deformable_object/deformable_object_cfg.py (100%) create mode 100644 source/isaaclab/isaaclab/assets/deformable_object/deformable_object_data.py create mode 100644 source/isaaclab/test/sim/test_deformable_backend_split.py create mode 100644 source/isaaclab_contrib/changelog.d/mym-deformable-backend-split.skip create mode 100644 source/isaaclab_contrib/changelog.d/mym-deformable_experimental.minor.rst create mode 100644 source/isaaclab_contrib/isaaclab_contrib/deformable/__init__.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/deformable/__init__.pyi create mode 100644 source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object_data.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py create mode 100644 source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py create mode 100644 source/isaaclab_contrib/test/deformable/test_deformable_object.py create mode 100644 source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py create mode 100644 source/isaaclab_newton/changelog.d/mym-deformable-backend-split.minor.rst create mode 100644 source/isaaclab_newton/changelog.d/mym-deformable_experimental.minor.rst create mode 100644 source/isaaclab_newton/isaaclab_newton/assets/deformable_object/__init__.py create mode 100644 source/isaaclab_newton/isaaclab_newton/assets/deformable_object/__init__.pyi create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/spawners/__init__.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/spawners/__init__.pyi create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py create mode 100644 source/isaaclab_physx/changelog.d/mym-deformable-backend-split.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/mym-deformable_experimental.major.rst delete mode 100644 source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials.py delete mode 100644 source/isaaclab_physx/isaaclab_physx/sim/spawners/spawner_cfg.py create mode 100644 source/isaaclab_tasks/changelog.d/mym-deformable-backend-split.rst create mode 100644 source/isaaclab_tasks/changelog.d/mym-deformable_experimental.minor.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/agents/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/agents/rsl_rl_ppo_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/franka_cloth_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/franka_soft_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/__init__.pyi create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/observations.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/rewards.py diff --git a/.github/workflows/license-exceptions.json b/.github/workflows/license-exceptions.json index 461439328380..49b1bb44a351 100644 --- a/.github/workflows/license-exceptions.json +++ b/.github/workflows/license-exceptions.json @@ -502,5 +502,10 @@ "package": "decorator", "license": "UNKNOWN", "comment": "BSD-2" + }, + { + "package": "pytetwild", + "license": "Mozilla Public License 2.0 (MPL 2.0)", + "comment": "MPL-2.0 / OSRB" } ] diff --git a/docs/conf.py b/docs/conf.py index 66a441be9718..680ae165513e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -224,6 +224,7 @@ "hydra.core", "hydra.core.config_store", "omegaconf", + "newton", ] # List of zero or more Sphinx-specific warning categories to be squelched (i.e., diff --git a/docs/source/_static/css/custom.css b/docs/source/_static/css/custom.css index 7f4f1601c6c5..e9b6b0bb603d 100644 --- a/docs/source/_static/css/custom.css +++ b/docs/source/_static/css/custom.css @@ -110,3 +110,10 @@ a { text-align: center; vertical-align: middle; } + +.square-crop-figure img { + aspect-ratio: 1 / 1; + object-fit: cover; + object-position: center; + width: min(100%, 480px); +} diff --git a/docs/source/_static/newton/franka-mjwarp-vbd-coupling.png b/docs/source/_static/newton/franka-mjwarp-vbd-coupling.png new file mode 100644 index 0000000000000000000000000000000000000000..9066a3c65c7f41b62daace9202d1b8b035d1b62a GIT binary patch literal 1309804 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�zl+Sp~g4Y-_vp=cpMC(_`7@xw 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

k+W3um{|r1(1LTd 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⁣^ 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

*s&^ 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

uKRn8iZ`fv6F#SbFI#K$8MT_ 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

xbJ2*0WfQgOVC)A%U=hY(Oyp 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
Lq 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
?KKRt8ng=`*AtDx_V*LYu0Rzyb$ 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�(r+SAIWzC8i6A7+BTmA*_A-mje=@&w z->;J77e^UbJKksMz}Cr_c}<~Ay*C#Ul(ZE4``Y9{2q`9Up&n~E&Nmf|69P!WXsE*? z{nisk$uNutxB^=?kqMh9)`cP*cN(DBgsp|)=~ihlG0g@XuWc5ew_*6=J)g^@Wossq zfqPjsox`5A0-6KtR z)Ci-pWvipA^}?PS*GSP98tAFu;)g-reZvotE(3n3yB#SJk(Md2k%McAdn>C@@+gUp zSr-gpOSWc1?@d824hlES*J}FhS9MI*X%_3Rwp1wv_Q6SOGS#g zQ&O!<${u(Qn99b6rX5>}IGog=qMOdsb*mnFS~Ais<49A$p$=qHDkA~DA|MAr5rPLK z>`Tysd+rs|uzp@@ADg_Y0UrEW5JRkzuqI{)v!tAS4$9`k7`-a1mBF4WB`@@ z2~-d;kh3STbDdk}zyrpW+??^T=dm_}zPrGygGCJBJz!{7nIJ%d6xdju2w-7ld#r?6Eh3#iI;k@Qb4l2OiLH3`qhZnpkCn@>AQ5Nn%z6nNWrCOd?j1Ph(fLXp!$q>b|u9kJ>o9DCxr|sC2YwV|~4ldnr~ipd#!_xO20;yF0wI-QEr;<>>IvTAV6J z-EbJ&Z1C>ea##XXBAp^m`d4o{P`P42<%uVsJb(Ve5585VbtcWC_dN6L<#*ou;LET7 z{PAaBJpRN}{}!Nf7FIcT{*gzYc>C=SKKk(U-~9Tm-S*J4uqgdX%_j*INzq8$^IkyZ z6j9^SvJ|WMJx;2eW6ho4zRXvP& z-nBAVUz309cSZms@x%v8j98V5);MA;W}~98D->Dfo|7AgV1*)UuqA>wMM>2pjuAKthNS_Rh<3vMxz(aa6NRVMO0rbh1p1 z3wEwkt0o&IVKU!ah;47;Hw#l%jT}SC6xSKw(nX-6I7CeU>XKht2|7IM?g?@8dhB3{ zInLlu1}N?_tGm%vd3&s7L&JG*VOb*$C%7|hueW-83mVq=GW#h~wtxx%Cn}@{D+(2b zmEj6hE`qgi&ICfQQj9Ai$5P-5QbivKPDURh6S*qW^ex^X@(V!4h>;yaz`)2=$qphy z>yGp^2!7A_E80p7IjOPQ$v0 zBC*k{rF;XCn=oRdLFH z5_=~}2J)xY*pNUAJ5E7wi3VzqE{?JW_t+wzew+A(oE3?921E*$04#mWl8%ZRo_PFa zEs6Kq1nsmIkw?2r_=qy>PSVDuS;fGEB@vtMKPC=q6l}t9#!_eGSfq=WbyQxB4ph7m zg<^48jMysV*r;P3iCZIR8Pk=G4AKz&70Cp(uc#>^jQGktgyUG13iFF|<3=hA)Jm-> z6?DSwhTSaIA5VEnZ~Z3&Z6|5}N%#spQzltqwW-%SUF^4HVVFadU73!WiSnm5N!*G( zi3(R#0|vOFo{i1ja0AdKY-C+PmGtV&v}^zi2ZPDvDCJn@9VTp71eSm*4E-vj7B~)a zb?}Hyn6MeR~)E3clq+8k3W7+ zfy%!DpPa%f2aDCa0jqrTmv`R#@R3I!-v=uE_Wth#RMcDl*b`5^^WMiFzW3ShfBWv? z;c?#&R@vSr9{LDE>N&t@6V8OdCW^c|0u>^HXqS)MoKZOolQ^$p^(pcnPcmsl>1Jj2 zte-!l{BcgF$TRkM_^{pgsK^!(i{)X=a0`P0waNaJBy3OFb=sqnlt`ZPuG|NWkQEf# z4WED+dOO)}uutgBR}9LNY`KNOwBAYw)DFqf!>^a4>!VB%g zhDRDL%-P~yoI_s4dmP53sRmx(?zBweHInNFy_SN3jb%McRdD!~9lmORZ;Xrp01yC4 zL_t(4<)~4=iY{amkyzaUW=Q#rfSh(K`mS!LxKUeEuq6zMLlJ$)dk;94 zLO7OT5r!BICs2?hI49h%Pu*K`acBLQO~!Wwabc8ifpsCI$!fqA=)rVtk|mPn!l$cV zYh-HclQG01Vg9DWUa6JDSc%)2Hac+T=B2h;iFrstcy)4Wq?$ThH}9n3RJ|DkMk_!sy}V+S#hw+!yo3< z%Aa`{@vIB7@q{ary2aywNC9B8t`COc#t^fdMwGf9QHK(C3qQSiu)4L#ZXIMx?S(|a zynNNoOCg!S8_9$Q31D@@ku;Av(Y2d`ijoc=t=gVPZ964aDx7tLR>=l*-pfU!)ASgw675K}?O++kK<$h#d6oW1aJ)*18&XAB!af`?x z^u_DwNUqvMm0KmGipPyg`tyB}P<^Z*mU_LX2~ zx2f!@zrd?ped*N?KK#QcAAR-VhoA2bkGg&!P)Qh-)AFt8LWy*W#}-q=m5?gwb_%!5 zjrHBkG(U?c7I%C<%z;YhDzDk2&c-Kmpb`O<02NLB+VEdfVpAbdajY`)qMTu%Kh40T zeJK&NF!gKNx7X`E_hBtxBwo^&LYa^TNsU64P_?!7tXsq@v3XGJ0h?6Wg;XdktE1L~ z^L-IBc1`;v!~)DAnAatn7&*ULcZ=01U2GG_m~PR-Cd~>z9ah;O9W0rc$e~ICmy#=U z;${^bFo&E>;f)H<`kAsT`bnY{yRfPmZJQaLl<+9w14}e!KJp5#>p=@uM(r3m;|+1q_T4_(j2m0B1xHW1;z<4 zEm0<_l8qEuy@3#`?j`6Q%^@1MCE6+h6^aUAJAv*i(QXOpq0nL@xI(dtQ8W?`R!N-8 z7L_Kz9;!6kRE>ic!@AN{uPUz+parza4yrTY9~8v^#aSt2>;5Z`Ar{G;dQN~n!i@ke z=oL|!qzIaqF^IT0}U-bc$8JFa6>E@J5uy^TH{EzgwA-F>0$Sm zU;4(AC}`2do{YgqsqY@YM+t=L@C=I zjEa&NWokGPfT2W+>Xb-y=saB&#?rXmMV@JZ0O9Lh=J7aAs$}Ls0CNRDPj1&lAV=9(n)z#%dS+zh1Rfn2 zl^jXL%4RrFDa#mDjDQ5u`)MXMoGO&VDJj7>v33nQ-*gDTXulEnw!Y&HgRPPMRawFq z3wCm(X*EbUE)pch?oP<<__h*w*fk-Yf~#kl$aUiQrx+~pu{0jfm09w&2BtY;RFq<( zGTW0qEdVW!f)6c9mLdPIr9J{^djPx(xSa;ZaL*)?p0Ga6#lsZ z%+%Iuk2vE7O3|)lKYgDk5yp%WV+1+O`d;2TxQE0$lmODK_7N<~NFk_>9fpWDbcQ5= zr;@jOV}hxkR{#;AyE!&=E^DAY8sEsYzspd59Z zqkbd&iHoo3w%3y})U<}z-2M8dwvvvN&O@{;YYUV57_f?D2~YsmkH_JLPVIM@NUXhtv>DG!C!|8y>{^KH_jX81!sxb-_4aIpBtAiF3L=RGsB8{j*VlV{y}fg+DCPB&*AKV1N9R%` zwA#vN8e}RA#;An9xnXh>;`invb2{ba>o>IL@ue3f^6P2YIR7&R@8w_~iSilwkKd)IMA}cm85{IiGy`)fZp<{o8MU zFs(Q4t*`fGU8k6kDE-=Rf}BH?CKZXzm0)|vbVg;)mYg=qr_!%Tyu{o0rc+WzMRsi< z{Zc0qHUlc_(|&r!i}Usw9u=BW+H{}PmqO_m)^5ru_nO5PD^SVYkUaoDpf{r^)>%Gg z`2f!eOVDfNIO%e`LKIGrI#A-aE|BjE>{C_Za9E9wDP$g8Y#1HJn8K^cfJ z;$Gh@Gq0nTzUY#wAEi?iP!T5^2mytX2cS{~;Ma(^UMwi1`r`+SX!D&r+>m@{6>f=p zUS7qRv+%^aP!w-iC;~Fy067M4Wy!3P(sf@J3)X9{al2yq2_<1zy2ycH6p)|=1#%1E z6L2hW;p@WlgcUDj?_{di&lwCgVj`*Fmqmw0u4ArpAmV<>D{JQVKJ~^67TxBq-k6%@ zaZ0Lam32Tt9B>_zU1t^LQ3+N+BY+lcN3j8S366!LuNo#3 z{ZZ)_d5*&PQ5kR35MTPNJfkwQ`T28R2bz*`lURiZL8poswZ-V~)(gf-&5K#+rid+s zZpNT@0tf-d52d6!Z9%be5_SGmXn!x!WYU)`DiL0#7AO6H2^&N8 znwB#*gUBJw2!ww-sx#EjPea6F{qgdTNcI;CxcvPFv4e}OxMk3e6qyFVz$gP-d{zjR%6UkX&Ny>R8l z%g;am^s~=gy!2o|1>Z^fmD=mikby~H@sEG@^AA4y^oy^)`Q@*FD@kJUzeuW_fmP0; zRsvEvf8o;M@oS%a_{Ha+ee<)Q{_Qxf)wz+v3|dHL8NzKBt5h7T?3?M^1e4qYs6-u^ z1Csb(;;R1^j|$n9w%f-r_wuM1FlriMRGNDLm9uOrXR5qnk4jqXc)CZ$$|9Z1J#D@h zprUSdL}SV7{#r4~CNHKupAbZwm%6epL*!28RJsCmVOx@#HsD@zGrlvIDRnUCXw`sh zY~)04LuASTxPx2?1Z$mlQ@9#w#NVH_IUM}95??Z(YQutarD$AB#$c#QO$&mi(BA?V z%3#U7V(v$fu8!lv3l;Q#tNtw?%#PAp+8DVGhJYn{uvOv!Val`HMdAQ0BnC^_yB(8! zP5R}C&NW?HA>VUjAB*UbvSxzYn_*&kbn>{*-m}YANqtQbjUrnyeu%e(safG$CUKeX zs8aUBaR~QRgiwezLKI*0#6z70R)l)3O{6{qC3eO<@DoDym+a5Bd+m+UH&+j00zB`w zZ+%PR1d=oblNXXA)1WXm)#zo+sP7ufZlk0Mpa_a?ggFUUHDlPFph)!^li>{OA< z+p0@`HcsiZf!0O6r$U`Yo2)U5IhXL25SW{=tTvHJ zyEU_n4IROn!Ynmp71U!AJnGjm|qC3xfncZTsgz zNflP6wQrrwU%iqzpcN-k9jcMbKvQ$<>}0O)2h3Q75i;o=AxB zu&oo~rGd_5+-a=vC_%$I7{Q|wfC{?gt3kc>qqevxl2IVEcD0xnHKJqP7>XVM01yC4 zL_t)(A)8?yk9pjdTI$8|f{uAnd?^U&-R2?NiEi|+Oq)BC80+tVa)DIL+EF}u>lhvI zDTAS|6`^sQyKtZK*F{AtF$)ICtv5aiQZ4& z#1<9955}vsd(y8{DzbZ<=7CkdC7yUL{W`NdA< zQ}vWNPu#q|E12K4QQ!O#07-xxO@Gu3^qD~BS2u@hVYdKc_5YNWz2k`y1y7_Om@z4E z)DMYX=hAF2=8sR(y{fg5U;|RHp*N#k3buF!48{}<28knAW)1Z2kAihxu!^>zOnPI) zjin(L80&FcoF^^wzOb|Dz67LcgxAQHdOlrvidKV+Ya|PM1F7VF3&vop{fd1xq-Yj7 z4JY2IlJRInCR!rd5VxqSUb&P??E9rDLdF6}NFmP8HHC@WRejl<+L!zVQ&n-|O6{Nmz}VeO`N7eKOw zO=63?HGT@z(TG zEhU+daKCEL%rc(P3qUN1;tOVU>CMFFXSL_*i4o6@``rPA+!+$f;2mI_i(Ox4YmM)5 zPtR^<&q-cq-qILfCSN1Ib%rNrh8WX5u~ zTr1sJwDKqc4KpB<)zq+q^7i9SQ z0gs;mV~4e)xC4{Pj1Vefie!-o1TtXBZ+@ zN&ZXujm~+MR366IuC7ZTwTDvnDj=Oand!dz$ef&Ra*Q{6ch9Haej8S40aV2GQrGta zD%sc!>&tiY0m>bvM8aS$Aj16&Q)ar%#=#SJFG-CV2X@ z50*9LRm&{=n(iZsK`;A=EM01iFrHchSO5T`-SWlnB9^1aGI*B&=W|Oy;EspL>QR1S}(5DD3fIt?s zM+*Ddd4~%gU!WW5YWH?6kJ0^R5fuj{ljs`Hc01~F%+Ez7ODB;G2HzeR|r#dkK zDr$M3jDp+3<$@P30yAo~SZiaAD@-otJ3@TORANz=p*fL3*y|!Wh-M*lWLg*kF)>|0 zKSIL4~KPWq8!s_%Ld~gY6IsNUGSFPAQZa(5a!KFicfjh+!pRqJw7DR@yOd zqkoMp3c$)*(!H8+Wx_9+A~Ri9rqVAn%2!}Wlr|A21j?>L2RpDRE5{GCugH(8R;oO} ziey&Ff;6zDHQcAsTh4VXsN*uNn!d)R5iAZ))RR-LVv~Ilk5g2*Ca4funa>AVy?JR} zE9MJA(Mo_TnH4OeRnyuzM;1FO-l%8@k%j!)bBc9FH#7c zAjKz&Iy&@GI_=}x25{Sh!!#vWtCgd*ep>K>(dWXC3gkWTuslAP9J=lMqJd@KOesoz z&?{o~_HrpA%veKfVVaG#t(wmg!_+DqJ}{o$G&n;z7PA_r0> z8}v3-dPBF3TIbiKSroUJLTs|g)Qu+>JAuPMWc7_WR)LAd*M`IFJ~)e_5nExaJ7;FL zN`oA$Dl7n%J7%n>&)(Hvj0Fo|0NKPOAB2C<=%gJY&gehs-B-h67AZ&pm?6)DbO8f4 zJQRvnb|_a-pki(Gn-k+k?xfB1#;BYaP(e@Sk=plfC)O0uDFRgFtD=-)I$p1D2cWXu z9QWZFHO87}o}d&aF+;(zk!@YT9)!E7k>Eg zqi_A@t*^fL=B?kqe{^&*jwAdk1RIW35;urv5J#YrFfC_xs?_^HWk#x;Qh7yO)I}td zHq$4JO21FDd@G|8fJ#-j4LvGbSAR9B`pcM;{r*QVA>DLJ9=0z7OSd$J*!QXSdQ@oU z4)bd!9t^JB3zm>kexhop-vR!s4axuk>oL|b?mz+ zr;NoHo(^nHCRwKtNcSmZ%PX$LtI(@wfnz{WvS{5Y_>uD$2evc=PVS`vDTLNSf*e4R za`ICukRqk}G3AR`+6WKHt*nMow;NkNZy^;^kHorYx5_C|0*7E_I&;`7 zFKRcMbLe+hpi&Bql%!*I(tCt1vZs>7D%CQnJ3JGtq7C#?{jt$Yimn(Duw~^$5W?&P z=AIQ503X&?c+2BE|p!unih-;3s6g!~Mqqe88@_2E; zS1z?OOSCxS;acH7^x}&pfM|-QZO|Z!3M_+5LbMi{Rhj^DN4D`QGBqOR_mmLV`&lwjc(NYR#+2N@5}t`sSjNctb`6*00bK^n1Xf%_A#T?<7 z+e@nn1I^4A25CH1EMZ`vL+3Ecu~_e@6MR2`U;*iI8$np`)AyDMCdT^l1estA3In^M z-^y*IX(`14e+v$%K9<9jN|J4~vPdMPMPA6WoB6uvy_s;%$4RKyG2~tWnP^E1FbRG0 z5O=|&qRO-8nhDJ4p`pVT%(+@|)s7ewL8+qUA!% z({##prBg)r^~#m2*FdLSe*XCYR33ihQOPDg4O9Rd&d3qI7e1No69C3ynf?CFw?F;- z%a1?%@~Nkv{oeP!cVGD5_sFiCJAdiY0}ow%;Nc6G9y)*V!Sfd$NI;8i=LDkQYdFa# zo_zX)_dX6-<(;=bJUY6ak}3)IaA!z+22g3;zTyS;U23e40b+X)6+ExmIlkRk%2R}4#5$nNFbpEcjMvH=2?e=n;6Y8lzR10xO_~&t zbOAM~!&@*+){M()!09E$DFgZ_-Lhg(aA3^{@|V#!U#+F9q&-Y74kd7cJ^I6{-<6&C zRp=3FwIc;i8zjG3gGz}wW8q<@QjQF{AXAU!A)W1J74j-=YBK`bHs$UFTN%>;01yC4 zL_t(69jn82NbKo|FJnt1@`wRWpafIs-bEyA+irURT>w7?H{3nxT^(v0dsPY3Ba%pLB}c;HCieL zGu>Am5fPjxDp6A@S_$Jj#9jU^dR@ZT$RvOsiAltnB36<+a5tUbg=Z04X`2_duh;s5xwLQpTxieXMyyUvaxZQbg&r7 z5R2-R5K!sRdl)*PFErHbP4uiRR>tq~` zM1{4zE5(SI?{$NRWE4LM9;G>5;8&n3QO-y?b3NGT zM*Ks62`1Az7W=z}5kut#M#TzSPz8hFGMe%|{;PAZ%kHjXC*%>|fn@~{1u10f-QCSm zIOhPaj{EU=T%Q~t-Q8~G+b%psf7lwGqDhj^q*GSqtp(_m)vGra*M&~G2|5M! zDF7f=wp`0&%uzxne&yl~~JFe>3^ z=Po?-*fXzY+sB{2a`Ayj!glGwM=t&F(EwV`U;N>Puo-j_^m5@6h!@$;U3mV=wZH%R z@BjSupMU*}-%R5KjB>iAab`hjP0(cqmD3iKeZR_SF2x8Fl%@3jJ>ZHEHq-CKQokqtnz`y{4*QxZ8nYUE0WDUeRG*47_Mp-9hCXDRA8NV} z3+Ei!VIS8VtwNmB8`{Ce4e!N>G^=P}@d!IdMu&RXCiT`&;>8c}LwJt>SHw(@`P(#6 zq7YkV?uSOAZf|b;0g@i8MzQ*K+s#RmQnPf6a&lHg=Hpn$VGH)IDXM^2#>_k_zK{=j z8x>-TC~|gCI!|JfAstfLt&z7k7sZW(0@-3liPWf;_hjK)tH$DhTN|kjITXD)D5U5; z6QBjxJMz$a5sYbn3v-RdnvWQ?K5sWgx1mC*>{i0GFsLe=;X%IKP{A-&&&nE>z<9=210Kdr?K1kSKL?S@DdHs`h`eA5x_ZYa zi=sIY;a0f3>+n-tTjN-IwVgDS)mb@x4zG(v5+h6^i`k9wy# zSabr=?&MYw2&{@3T3Psqp@9G_BY+VRiw1>hha;W7o=qHSmT>(gxwu`o+C8sC%BC9+ zHBzjz&6loej>l~s854Jk#!!YoPCC1{aS0s8ar2_&wAwco4Q#E5(QraD34(o{por_@ zn#>#VjJn=qrDHAf7#Cl$O}r4zm&O3VuG4bF4=T6!05?K ziDZKU8O)T7`7&hHFa((!BZ)oj$_DPrjWLANz$=5sYZcMBQIFAuVwEYec{;ZEDGiIK5G*t)Dqsr= zdT9$%C?o;A1kmc+Hx~1?Z3I_rwcBK8&GCnAQs$`2F6)&KfJ89KQ6sQq+rWQsJ8S&c z4tNH@rr(Hv9jdBXi96&~{mwfj!?cYQFY$ZOI${+reJo=^q!>2Yf2;B^ML1e@a+j4O zhi`0-e(bPhL=}npShO!voF@U{ga@WJl^Ou7wGx=oOI_FtNsO^RvFu=4;X8{wb_|9e zQGSMVQ&7|k6uIM0dZ@h8NMcy+ad3racQ!|_4{H&*b?Td6Z%zVA84-wVaWzcz)O}r7-Iw+}Ub%8P0F~#Tf9Ba|CGzXs`5%0j)USIg!OopU zwkWT1?%dJw?Kj_k_p?9z@#D|_aOLG&H;cn^`-|;QKlGSM=o4?7|`Xy#fQ#ch*;&kZs*QlI)DCB_{I1i|M8#Sd-L6+-BB;iDtq}|r(|7i zBIR=3SCit9Jzx`6WP5Z9z(xX8PRYIcXhCr(l0I<8tMs~I|DGn61gH!IDnklXHqlJq zpH-AJLniC_HbE*@w{rlKf|D-V?tTCNkF@vjkF&bc#|hwWY%pMh4W`)G(#*WQP18u0 zY|D*aLb0(;Zy{~--Rx$wNeBc2yPJRcchB>jbM8B`L$dqx=Q4~&Q{E|(&U2sEyDNB9 zw5=jX@>0P@d5oL&B24_AHk+61OPOFcU5NiXO#F&Nx&TFx3^w=bl}vNMl<}~3l=AGL zK%Q@@9uEQl2`R43h{Dt?GpwiTupe0~JOp4JQAZe~X#uCDMBV?)m@4NMX*J=IXa=rI zo($WtquqvP*Lu;8(yK?TR{Pe1oQY2=Pa=5|Srpsy=S&>t?0{s>LeK>aQ<7O?IM}K^ zWo-FRMoj&}m(haXi?K2@jIaT4cuxUT8cnQHuZ4DlaKa9NL=Frn5foMn>DL-wA11R+ zd7s$GXF9@!+0!^P%{2SVsS;(q80VFl{au^s!z&Nk3XLeD_>#hb@Sx%T6Phbpw;O31 zm=?Co!Lh*8ulE+9@_3b3&fCPJ7c}XsHF;G>YmM4elKJZp7|K;ED{Rq8Y1HjV7o=dmD#{Fdn4YAW7kV;eA3vmgSx&M)2cN#Y6g1 zV5AvYmaZV5G!y3lVNa@l827jI*?`ny&|E}kLa96+bMDb+J3Zx$C-yc;4+M*ex`H19 zXc6GSM#5B}qk%EgP0@}bX<(pVD!TB+SKScOIE_FjOs1O)GRL!|QbWMxqoSV#8hG4? zBOjQ&F$)bFImXC7mXAjQ)FHwuBt8>y5zSig0ME0N)B*|cEF!ak&u2gXHv){*h(0WX zyNpxIL1Y=OV4^_j5nkw}0j{7POy93wd;Q20nG*)1vGYVjCfFJj`4a^>F}}=G)C@Q3 zN{zsDK1agMxIq977&()SKt*VTiT1}chO6+T^0AkeK%B#Fdp6ULT^NRd;|@TG`GGYz zBXVn?w^%0~7pOGL(Pxqd%qf1Dj>RS%zBOn#hC!Lr-RB$O0cHd_QNAT$V?ko1YW6I*p9NUqyJcQjg>R8 zUKH(zSz=MVr_#=l9YUrspwWeJrUO1fr{q>QKi>hm7)`WE7rvjiGhuATb1|q)=39cw zw8wKX373zBNTJ*d#VLTGODKiAW|h(@aHml8b^19ms2H6h1{LOg9Y68JU9vx=_Wd-h(uborxCKmYLKPi}tn^X=DvcKPk!yzuUCU%v74C!RXJ z|KQ=h`woV+CoHfi;S}d(?Ur?bh(*8LbKtOwW3`N#v0&e+cg%ARcEW+211ECOBa&GR5JDreDxe@IW8q2XqbF@Avhk1l zKiRym*R(suIsr+KWPUIj+cl0rtDLHRT!Ozk_Nzd3HjU158+=D*>vsVX$q)-ntNS@! z1MRJEeXYXJt)-BbY^MB7Lehhnq%s4&_OuN9?180MSz2*R^%u?bn425WDG{h}_|9q} zWZcLh!C8g2D{4Q9ne|bf-Xia7jVde+<0>_wINSaV;MQwmde<+)32Ll`6Kgeh~G_=Unv5HGgCu}wXRSfk`JmcuY#p`fpZ3{`lo0=y~P z1yNI4&fIoIcLBH8M3IMEy9E%I^? z;b_BRv7>uVm_7{tWT{Gpew7RW!wMf;4g1O(78>MKk--YD-E&1}WhrfRs>Ir9P5ojG zpC{WEYOZxzh&o<4evNvHmkgsADkH|Z*DO#aQZ(jlF1?%2wz_5DhC8At%J2(u&d@OK zWcx-khuze;7iJTebyu|$hF>Mds7P{Gp~=OX{beR~_HSq$u(a1JE8+!_O#M-EP$-qvMWto-#4|uCGg(BffELUY4i`3J zpPg5b4q_s)f)``kloYZMXW}SSj^wEIU`3C|Va32V$1_A<;`~W6O?)1w0Pt!601yC4 zL_t)gQB~CgPD69FJ5Wh99w6RlYA8Z|Q=?pEY{t3}{6rw3$;-w>H4GY_@l453Ec!-D zjaPO_WPB8*2jx1c$Ro>CSsL~CyH}oYLeYzsmM~U$%&0DTB@sKJ2@T<{%+?YhO4&_O zcPJ=D&;qVzH}4WwwUn;;)}qFgOiS6BM@3VHiF4!!oLR<2i!nkK8ilrGa1(S!@mRFG z0H!h!3@Kh~?1T3Zu*{A1{4KPOkTyvlS(CFKrA>p{D%Uc@It3x|!e?L@$js*_-Kqp~ezq4FjmCx1I-M^0sxVx#%;&U9tQ` zjzPp}rZFQkGuF^Bxq^HPDz940>Cf>_y4g8Vg{__EAqmS=&${tq3~P#Lwrc}>Or5T# zODF}g-9RZur<`Hf*9uA=#hrp+;%A;XdGh!ZPY|d)^5~?#($S9j1RRRke-qf?gomL8+hI-OGQWK_JvaS4q?I;F?4#2m04%bHjKRKoAWj)jjq zq+jI{qcSabx>lykpd}J4_;FkEwDdtp1Shq&PJ4fs0iN+a0b``FaOARPc|NyZ6un$m zVeq4Hl@ueP7!Yi+zec}c(|ztqwKjC3Z9TvbHMF#h>OycZO)_JpS!4|DfGc3>HWvyv zl3}ohrCy?kCsI&|MJ1S43K}w@cfyolvFY5)92M1A46Zh-02)c#x+Ye2w3<+8IPgFov0h@{mCllj>mELd^(oqdlErUXhy6H49@83*(V5nHo}nZu5$lD9 zeZ|@qrm>L~Au)mkEsHf1-w2?{oG)WZWD>E*99=7GF8Y$Pd8|^3=NG)3RW=e7CcmQv zb%Zx=FfulY4IxQAN*aME78K?Ps(=9|@iNL%^@ogFru(0H#9EFSAXcGiU;rwUQH*Wi z73#=u@Qpwh4Ury^BrpSvCCn=5)jJNd#?17ZN-XS3PN33h-+Zp9$@0G1k+q$GPSFEE zpQBJEw9x|}mc!)|dUdu~x8sPki6nYW_}5qTV5aH9bb|Q+e>}9Ztz@Cn1QXcI;R^gJ z+*4Nry$@=tk1rMc*mf_}{4A}vZacNXc_Bt(G9KOPjvNb7NBA?ABxQJ%x}CASf;-Sa zK5cGB0x4->nk{Bu$Yduu)mG@5V4|@W0vhoG-G5;;NKJyp(a4ZC6%&auNvv1<7@&v( z6$Tu;aX;K}U>GJ`I5eOVE?fz~AkEXlkp?|Eh3dqN7cyj3k^+L`1&lG9pMiR*KkzFnJebpWMdn3M)EQtng8Sj4KU)qp@D26qb1JYlY$XdsTQhGt*1?qrGX#8vG%+Y^LwPxYmPURap`TGJ6rl$bil&)nqJ^8aGnw7_EfI_% zXiKn%dCg_4M{1VAszt$Hp>mwFO7nuxwVE<)3ae&r&v(sg<`Du0Gkq_fn89C~9!5Cf zB`R7pY0OM(VyXZ8H&Wf6eN7W2>o4hcX0-DJHD$It-pK^BpVYZFAKA5g_g%AzEr#o#NdWs1lfB;0;Rlbr z{PHUwfA;ySZ@hlt^7EIky>|QdhXE`dd+hPY!*PUjPdstr#EJ0#C!aj|6#hN??6ap& zpLy=g*;A*UJ9X;xbI+X(=bwJ&^obMUTW4N<`KQ}gU!BZ1+wn|bK!5g+pk+CWxQ{!Q z&`6|K{CwzZ97_iJo#vC)k3v}#In#AJoh^E`?(e{r@SU*&mDU**Z91yDdC#me z>q)|P)RLHA_qN9g-dfff+}0qlVOtqs@ufIEQ<_qKSgpUt0K>DrZ7O zF^svxroUnxVZW16WlLjCuX4~5t1vs}T)}xiHmm}73P^x~ZU4)JT`~fz?P=94kN_`u z9(^e>)XU*VX6OYk$VBTqHFBE0_3r$cIzoU!h--#8V`;B9j2h5s1#Y2~uwbIl!WbJ} z)(dHcDsmx65}wgmFu)H>2q11`6MR(SjSz9huKIpL9bWB-RYvwwQ+cR%DUI!7nLQjV zIl;-8R8@)=NMlqy+?iOVw0BvBg2W_V_^2*6^1DKrC=;Uu9s{C zRw%)QcLim0bFT-6kA*ZyO-z!(AMeI`AQ?F3tBIu*tKq&R(_#`4rf@h`u`=w~T#@(S ziW!mNg~*%CJq9*jYx7{uRIzc+LXX^9HO$9sl@zTr54seMumHrjP+;Kw?H6@8CoiB zSt*fX44zHs6l8yOr3JGv=9r%ZCiy1XXHq66RP%Bblq$+cu8}zpjf#1bEX8?|FsWIz z5)LwaPQnOY)Y2&_ADb}uNYlK^%nSfWI>jyi9>B%smOh+L`Jgl)rG-Jq+Rf{x!m3n@ zMKsRW#1qx<@ixN=nZ4+5>9I7qeay1q-5VCW<%!W{ zt3iL7n<-!0RP=6V)Q0toSx7#_N)sJlrgzG^$fEdpECD2?H92bPmv^f8&|~+k)%En> zLqaD0d-&|36PsJx{W{$yae}wIhVj*KA$VXGc1lxdMAwN@TgXB5FFuGP6 z140Zg&}84j_X3o%B@kt?HQTrdDcYRLD*>ptte*!;v9c6)gHEU=PSE8Qd*}&N z#GNuEo$}17Gd+|dJ&(tqcp^XpS&tt-{NO{ocHJGQM6mHs)TjIiuX4YwBS#*&{_f2W zKKl5r8}EMn`Om-j*)N8}!I|ft<61cz{+&4^XXPKhkbh^-hVQJbt_}wSS)(Kyjnesa z@%HskUcL3(i*Nti=Ig&+yK=i*+it_hubnhar<1!ih=iPuxPaD?jN4mR%WvBU` zrI5{%X?7ti%eY% zJMPr9U9uY|@7zAsz!sornFVT;6tna|&Esca3Bu^^? zN#z>$d=UsYn^-@f5AJTe7m<=VSZLXwvl^1QUi7P2WEn+Y9a6a}_0VUUCd@!#TwnI= z&kn**b%DZ}+PIgBHI75;U~wysO2TMj0RuB2Igpwvj0hym$I)NW!U$gW*&A0((de=| zk_QbhuY_(v;-yNt7(X!M>$VDMmOneZDNL`j3VCa3%uRa7PD~}-;F32fT}N_8um?1I zMM-96&h8qIJt$y{va%EbCu~{46(&4Fje%xR!Kk8+i|9^X_F*~DL0@%KocqbfFj-sV zRcf+_4@B6TPNi|$!-$#}Bz>i&Hfiz&4?sozKPfFqwcfvKw^X}`Pkv?0PuG`k@yZ#@ z_pD68)>?<&#*#O*9{P&ZN+$g?6U{~|Ck3+9@+wwtp@duZw?tzWCON3Tm;+{K#3=+i zWm1{|EHWoLl2R(xfp0;06nDfq5bG|u7B#59g^K}GaA}jDaT~YSbasE?rH9?;} z%=D!<*4OBiY?PCUV5!Kg0y1F`nObP*X;GBrRrKtTNvMcR^=Z0j%DnhRqDB|(T|w@d zJg#P2sgt|P@~DOyX%&p=owV7o)if}^1E=c*GE@}XpDy}gfI75dldd2lPm zFqVy|zu&AH#6=2k0E9G)tas$KYofGu#0=}aL~;UD_oeyB;ai{wHQ9^@$q!`CRvDKN zSkxH(Ff2k*pO1CCsNBxrN@fq87`xJR^CtWOZYDi69-CpbCerxBeu{u1LxMd$o~UFf zO!1W!YWiX)J<`3Jylx3s*q{P2SOq$TxyAyd!}ZHHq7(*;0UX8-N9{$ar6f@t>SNK2 zg+KPOqTa>g4KWO^iHI#$DhS|b!yUF-R4>t3?2C*I^r(tg(i^}A(Ozp_%SemA z6C={%LPt9UW0ZSUYDZZ|NPyT#Y6<>QM~z*9cKC;^@bLPfYBMgvC} zvorE*38?%?UHy{WtG^muBvSm6opcKSO(xUuUFfa}Ley#Iaeq7561&1b0whau#(kjD zV@{T8>|-B`Hq&Qy6lh`hdY*VEy-lAy9i?nkl;U>u=*b|id)t|oqHx1LTr+@>FqfZM z%~nPghuzc~Ftl?W*h7YU_%~>^*Ws9^l(eJgWJr(4sMLLN^t8CLOUoz?Yh`Mk*K{vA zPI~dR1rmu;x=GI)Le_;x^xDB3;B90{Vwru(T1I#z5V~b@sT>JuuH$lj7m)@gV2%9p z+YT8<49vr&<`$z$6l^G?5-y)C-M{(EMdk#Qf~gvvDgY|Xl_n7K;A0+4l4){4p=vZ%aVR)>$r zguWLKZDahGl+P4nSAr32m`8}m*`(~jq^?XwclPQjkmxi6I#FyhwEP3VCO^aBRb5r< zOFJ^RJ=<3Rc*M9bN;cCdntZEfmY3Zd8IqDIUeQKxr6>u0<7cKb#s;z~g zUV?3M{}eTa&W>DNil{)z{0-9!n%adhU6JE{1&Jf>4HStU1$9X4a*;M9WgZ# z$Tsy0ldLqfN>|?FQJQTUNyp-R5Q*0WeH|U)M^3pv!(q*yk-?TtBBfM1rGXb;XY^Ff z<;-<0y$_xQ#Sq2@40DmqAXdS3YVsjrxG!Y>V>g9-305QKMt4 zE%8(KqVUT^yHnxX(r;0h)%mFlx|EsGFaR3s=_DEZVvuowC2n2Ks(4qqLlgxn6T=~6 zrC-?B#159yO769UeoDBag_4S4Fz8EG2MqK=bSO9xi5C!vCVJ5W1J_E?$`ztfp0@j5 zmxx*Ob!K8i3-XbPtR}odrC0@i6?Tt6xW)S+-au^e+}I&ig8BY2feKtMS~g#`0qSgs zZ=O~VgDAD88_QxI8mBsLMbTm6K!_!eFvl-knkB-m37h62SL59ZR@rD~=VVQ-fQ5-< zW6w0}_d+fj+D=ILOjVQCMz_p4UO}?I*0#nAS@Uz3!faibMILg{X+fb^NtGA&prQ3* z$fR>AL@5mW(vnAWr#$m)52&1c>co>LC6)NG08~yKd+hOj2M+#UfXWVP@4^{ikOSt|ljM#E7u946s3Nzx?Evb@^d zyma&SmzQ38Z~FXa8$bQs){U>WZu~g_mCYMpZ(jd;E36w|pTF_v^EbY}aN|!G-u}1E zmp@s%@akmkd?y^mq@&No(twFucKmA_b+q?&V(zAI;&(~U+!6nEH=xq=&Gd#K#aB%PPfP|qXulkl?WA3`_kQaD>sZYHwhnYpVZW2t&mSuE#~oG%phJRnzAbN zME&ToPTmw>jqlR~Ei}E-#tHl5SI`q8kk9E96?BPRewb^IA%$Cgd%d$I!=Ar|ujDFt zm=#M)BYouEh#LH*9hVm3Trt1PEbJ&J5m<$=vQ^MWN{dBF7-fD_c0>yBfRjG<(t!1{ zcmrmLDI>?4n$nq>igGe5Y^mqrKEY6x*1e+v115@V{M8`4rj(sWkz#x4XoJj(S{K$Iil{0}pjmoqU zc$M%6118tnRx@Non1&I{pNbq#75o+PjV|=H>13dG6l-#viY1yF24W9Lze?>OZ_&ZR z?bR_imCbw-3p9F}vp_P`3z;Hlhj+zm&TAEV$%Z`!$|m((_p zB8{&IIDx(Ln)IdlU;qG>>@{7FaC2?bYSySAVf;JiTXcOK^(S(VowuXDj zHFoccnOjH)Nxw>}HG%{fIggwW1=D%YhG=Y6%`zL>$pk{Cm3ZB zneZi2(~IIs$<#V)b@FUV@m5Fbkw6r5&L<$J3tB8Fwenb0KXoxxl2*OUvC_eb37J|* z%ceBCgqtf=80Di;!86ttUyfoJS8g4PZWqCeNvJ@WWFIz~YGCf~5Om(~>14g=xvU2m zqE5_Gw%iJDxn7DrG&(sNgnBv4I3%b3+_jZNEm@R-X{@kf)CxaJ9Zh24XE3}hI0pJv zP@JVSGs5tGRMginsNh$59(^KYx%S z_qdc|r#oe3bmnXXDysVeN_pbs$rDdL*#jz%?cKNk{#}3Hi1L5T`?{-Tg_rEwwQF~n zzkl|NU;X;GzkB<}jp;lLC;4zVBrsWB9fbdf^Y|x7g;*uc@;oo4roC?8d+*EZw|;){ z#Sb=r`rGsG{4rpX&Ff!nUjGwL|GfDQ*7dIhuDturg`3}O-}>&-?Y~|Qr#HU7_|xBR zz4ZCUl^e75%iUzH70RW(Yo}vV?*JAPIjIgdZ}cTFqa;a%zXuH3<>^ka)vIp$~Dc>bQA)nt zS|`s!dJLoHZ>~wLoE7~QpGE0++gnP@0Hlo>N0#YfH#Xq5Hf!paBkQjuD>Ae+Fq3`( zz2w}>uhq(ucit7qD6oNziEBfZO zV=nwg09vRKFs1v%A|%6bIm8qwZfr3yOtMV4oxCvLf+>YK=JuD($0|2s+aR4Px^pi= zBiReL-Er9VzALW%K}vkW zs4;j*s@!e@-$KbAlE<1b{(`t8of7Vx zeLjiBdCALVTurZyv{#&h8XVf6$j*5VSQ;x_06eU(#y*FZ3DLtO>WTMD00xNdt$otW zGg z#ZdA@oGnuVmD~zdtQJ}kX*8nLOXiT?bfQ6UfYTa-Ui7-*5mcq_3(ZPqGrc)aU_SA5 zuX&}8`8Ae5R(gKTyeLxj=+Y@BWvQ`5eI4+P%r*gC6pF+QVxt{O7*Wg|GN+(RVGJxH zt+qYVbiP>&iqQP7mR^xg6;>cvXoxpC7O{%)Gh{rxyrn>RS7F|jss(BqFU(b<{+XUO zOoPa@Ba#8o=3R+eFBCPhu`$Ze;EF-f1D^`uXN`LDAPNvyH8yf9$cx!|Bmu$R_>~$Nc{V ztK8ZD=(t6dV7qtknJ>=$?3cg!;NwrP-?}|tTN5G$97w=8V|{a8n3iuY zy!)3+H@~}j`>)q-eS78l?=Qagi?vH{jThVPIQ)tm*LAz31EXwAm1D+sEGxtAKq*b% zWZy$6-E!m?)L)&zl|EJ>ozivV@g!gaXdGZQSuRmou6=A~cPPQ87L?b^GQKNNVI5Pw z%&1smSk*%;J)lA?p;l?>QBmW#P$KoJ)vkL-_{t!cS)UoZ%7H)>1n;|fR9fp->Da6? zQX}|SOT5#F zS%O5X4D=bbPcn|1E5_#gOn3eJ zES8u?q_Jyi9(OcIYTZXYP`^-dHvBRgRgo0(7l&Ft;smp=xW7eJb1DVI`vQYX(Jag{ zPe|hpn=`so2!1@ai)t&4Ax^c-k>EkGQ$b+nq*J&)G2xi+EYM3wTRhQ-*@2YSVsB)x z000mGNkl?~|b?f5DN%SyJ%NA?>M(&&Af zVPoW-lHt9JZxGAl)74?ZV>8}B%|c{I$eAQot77*07DBC30xN^~%qRc=VTrm=+9EL3@*;2Kvs3>ccY3WUBe3bc2tZOB_K2UZ6 zd+2M+&K5#4gP^rF+f*ZSxj69xJy@e?$LJS^fDxG&rXMq}mmUC{g-A`PWyLukfGa?h zF2yGnAKIGnUwB@8tj0uHb*yT&%wo#4R0gJkv56S#;cubG-FhMQ`n*qLX~tIgC^|0i z5_4B;m=DLhBRlV!B$whEt^P#9^kKT>OHKEho9JssCp}4(a6TMuTI$_0MhtzC3$}UC zc2X*dG4;J(p5cS4UPZkkbG5SQP&HRGiJ}5UI~{r(S&68g6G+9(^>MDzaACtv#?n+4 zA53YMMkM2L`Je%=cZ7=z0gK^B0@ zbg_-(VyjZo`bdE{685!zF`QxJmNM{#xXa?77FH)j_s0=vz|^rS#h9 z5lJkfFZ4V<@z~>!AA2l7mE(^*dUW^hz5gKE>&M~B-J#3h!zxh&wr~HzSKoO1lh3~l zSmm8}ZqFy|NRetd-s$7m|VKG`P1K>f9H<@ zqFlW3)x~%J9M<_8Uj?j!1u<9vE$?8-d0h%x-W9}h5oF8u?Y~~U_3edszYefxR_{tnZ;Zb{rV?<>l^mdfc1E1_R*re8i&qnSRn;Zo2-O1YO#-deH_OhTX{ zb&Y~ma$dfIA)a7nqkH3F!xa{~tVaOJe6SSw?nquOc4w&gz)V+k7B2M)BrX@v7<@htSoCw>Li$6Ro*`Zh^jI1z?2eRP&@ezq8a*vh@?>lbQ*;L? zN|z3hY7&-c$%Y0aEirk>=mO0)pw~j)jsOW_VP&G#^*W;~|4^87rgN9MQcr+|lED?8 zJIqClR)dqLQQ`^&@YW}5>r-1?gpme2M^*m)o4rAE*9jNfw6vOjKJg|X1CnKuA z08|nL6APUZ#;h=B2%Rz>qAd$RrHOJc1yLpSU||uKV3)K-2XM}&$RXsy0*z7#h{H<} z9y*OXsjRUN=b1B`DT^XIXBk~9_-+zM+tQ7*0i_J!D4_9L8&qoy(VGHjHFPgaau?4Pa$X;+s zZX{(M03vi<$K_TE>4%9)#DzuiX1)|p5~v``7MMy=koSA=J^=&j@cK*iuzJlK<5+~p zUvYZX42f_B2o{I+~C()+x6GV$HoV z0n*fA46On<>55`ZM+(l1+{*)t&AYE%9~vlU3}BfN4gf8==r6`ccMk*9lJa*bgNfe9^|Pl!@9kf21{EVtW5AgVo*+O=X~ zU!KE3Uy5IWK!v|^=v@)2$2}@7YhjQ8*3z0XSMN&0^T0Dm9uqYh_G=3CIhN>bA;1t~ z*&aH>qf`n)5MoAe3>7Q}38<}{jC&otsgpE^|+L9NC0wU@0yGa z^v%3dbTQ9a-eR_y*!MyV)2>AprT@5QzAqUDVD=SNe~lNtqgphU?#0C9$?W_T{VL&f zwsvtE7O+ZyVbevxFc&6^izv=&&Au3o@?7}iLVThu3YOp#LX@*cq-e<_=oHKQIwf=p zPzrk<4X8YRM(3FW{6app^Ogg^#}Y_b-3*m-DZDGJD~Rt#|$yz{v%yi#Pv#;pW#D-c?@3 z85N)ug)RbAZUC!XC&%*jd9HWAIe+sn;dDz@ID-|g@b>RFUio5v>CMUdrEaeYb<>8xl)iIH|3R@;r2-SZKp$hUe}h4(?60-(}W?X+H&d)*OF?5>r* zamCtHbc0}iH@MRK9pWC9+Px@aPyI3gV}*~2sa$NQymcPHH`<|cQ4av8G-z5%(<9YP zTUv!)i}^?hZ*6q<+NMcuE{A8GiC(FZ?(??Zi@XTd$dyhLfTj2pS=OBGnuTSQMmqjVvPVoq-{Z(ZeKx5C|ql&oxGg5xWqK9$Glo4V6*)Y~f zqrC{pjChgKM&@+ozPDVyU7W^g&El5jc?kdvpc}L|Ed_*L^QNl~--s_N6<{z4MWAVE`)mAk9~k zOjfLbgqdViFwZy3EUT(l*kaiR75heXQaAMxqp@NGWAuLX9sKJ0K-OGM$zR&5jTeeT zF4R!C!ZPIw+NBJ`lz_exv{i@S@Z&W6I1Gy{Yl>VPDOI?Mj8j#7L@Ld4)2hM5uZlJN zMT9D{|Ium)9tz`d)U}^i)|b*~FOZ(^oOS6v&8F9>(#A9}iwnzkIAr=tFk`MpnTOpq zgpmd1XP|u;cSDqd{>PfYgEfnVbi+4dV$nuO^}2Y05;q;#I!_aP27eCysmh#`oqJK} z=_e%u4wjHZQ;f9^MTdqdn2j-!k5U#|v!k{5I)4Bck>9W{&5ui!*LYD{nWH9sNtFt< zUg}+8+^|N3brwl1Nm+}2=9Omq3e7?=pmE7+^zY{09ck=Gq%~+AiFwMZQj6RLV8g-@ zi(aQyDHl!oQrn_pEqT5O~;~Xj!uG!fY)I0+ zS(%Ks%bP=@honpEwaP$}y}%hacQUC;grA#Q#*T*InK7 zf0s_V6RR9Q@#O9IKl<`lzj^PYPp-Z2VwQ)$4nx}O1)-#A_-{0#hrSe~GCj9_^^N(t z7Y;vq>Y4|J;1z*Xu8QFxz@*Jl`CT=i_cN7ThwP=;{o^;Ea4r zu7Hazp}6a>@$#1)HD7{K#*?thW{kGSUN&*3Rxz!B+_CzZU~W+90UM`Zs=MTaSv8Y> z*R@`E#1jvr`k#QLrBTEt`=}!_2Sv4#6lX{IO87wI%8pf5Q=Zxc5fc8ykNW59BRh968Nlkq0G0JRn*x;(=nU z*G#DRG6PH`7BFffhbkZwR1kKvukV~sx^_o2(GY1LHOrDTQNTOw^C zdgp{gL}l8BnX#iLYUezYZK$ciwORJ5^;*-M1|Z3TSOrF-qLdBPFr6e;gyQ6hq9giM zc%KM5W1z6-cSV4msx4=vY1B1x%%)nx6MHl9${UC^0>^{G3?pd+k1Y!zW+5YGyF(~< zV_OT7!HCH-_49P>OM&{U5tIT(WhBz0@Hz_$vinr39ZnT8U?n5JM&`dytx1|&q2%Im z47A#ZV_;}(sqOO62n|I5Q;p263#h~WZ`m*#rIk10G0TN& zy(OCDTbco#mIEtANs;}hYjK?-@;^Bm_UmZkQ(&$(W5KfZ8i|6Q9|a# zNbY}D#NEMtQ@DTml0Il z!MoBJDC&q1!ujwXtpw~1!#(qYt?GW9c{NHjuC!W4sfHHADpkJ%1zVdHkxZKy>}0sG z1`U(708yORQ{OA4Zp~~hlm5PGboSSt1q8P}{rW~d?EM>*oH0|J1`=s8rZ`;GwVtv$?LOlUy5CS zS_`mYRZEQd8qd$q78k>su3f;wmm8Pn=Pv7pS0cL5oG3-Ja|*KbQqZg_uXJTKbs|N5 z^XPV@+)Ea$DH8VetcHC(L6q{u6YP0BrahO=X#$`E$P#{X>PgdW%-63xeLCIu;FAZA zrH7uJKk(#QScjk5c=+_?6Y1-#8=tf<{$}yh|J)4VYQ&=R1=bTXL=Y)^sd;z9Rq(r4^5YFyErPIVb*DC+1SH_$eJ~OV&ru=vzPueK$&EAw_D;3c|z^gc&VG zXFoeJGzykjMHOqtc#L$1en6(k>??7LUD5(949YZPBuuP9#b5(RYKV&l7Gf5?E6QEK z@)2G!beyx95p9FIg-_|%$Xwle7-?yhMd(W=-*y#(u(qJ{JHR^Re{ezw9lGP?nrnLUw|m`G%oz$asJp z%aM=7XIBJHkP8aa!g}Kj$1i`#b1{JwlKMSiMX9zuf|gNilUb!>RvXl*(ZQlNeS8M^ z2g7^o8`BM&tV`}KJk6ZlDQa24c6g#?_Tp?IXbG1OFiy#N!WiArFFBNqa0RW!VB31H zYA}mZ7E$ap;TMW}PGE)So;?lpr2-NLQc>TPmxz~zJPhel9QqrC$(nhfwN-5-0N%m}EUmJ%3@Zt*?I=%$#Yn`OjHEn|@_B~1HFW{$xu zE)2%^W@@~O+ghU4q^J$CFxkx77|HFpLBCkyXXb87DS0P~ujVM0u_UQBI!`lp@xYUKRb5Cy#?pIW9oucz`Hj9X)m|Mt=R1K;=gi zV$1lX4@G{ARN1vVAf=7Xi=Y1NmjS4J^zj$l7cXa7Hk72V5%Y)xN=Xp;)i6l2o*#PX zvAqwTJos37=*h*QlN(s;y|r=psm&v&E}lqV8*YBqzVQFn-uV5wxBhVcy7Da--~Aeb zF<_M&RE~x7-&|0*LLAQccV&VtkLK_m+cEWnsa+o#vi_VU@0H-J#c=Y_n1bof5`q z_$69OlEM%cE+JfrAynL)hCd$4pK z4?S^$<>7)g zN)4HMP1D9~@gN7e%gmrj(S+_I!<3pLVyJx-JA zn|IBRcHuQ6j=V4ur~{#5mXtaZShP}sdjVXrE&SdPZ`Ne(n`i$?qTXm`7K?#CXNs35 zca@3SR0Q5IcX+34jBWHNv)6Kxl&nb6bBe2Bn&WAq-R&R*>RuH|!H&xgAzGYwWxmWz z%{4M-IV)n+f|3=Eh3KM*N-Zlw!L%*QVv%d{LmTyxT4_Dsqr@>4=1U%7sZE6yk5dm1 zHHefvakx*tuf0%1xzFOuGrbP<)_kH>@k0O`P;FhUj(P25tSmIlF>Sw_#r^ai{uaGV zr51_W)f)A$XO%@T3s1=SX0mfJPzzEwSn4|yv~b)n2-{rJhD5Rm5e?+jIfrk4$XorB znOn7Dzi<=w66IlxW~nXN%ma(7XiCZ3GM|*$q{=33F_|{Ac{A_IX_b%jv`NE}j|8HO zhMAm6iWCqfyv5wi=1=ZO-!-9J&`&=F=`m((@$zC*N|%;eE(vy*%}XF&>~dvj(Hje; zS<713k|R5^%Z)8jB^d5}9An3vJZb2KV-|>7Txj7a3=%NuSbA(r%P@qly<(6$Uh0&H_Nb`(OWR1gHFM<8NS+9I zsHR$n0#L!kFuhw#I#%|hnOi}M27KXQ7Cq>)-U~9`5O$>myCT;l2@@dMdIiFSiDC74 zW7!M?pkmrE^sC4n*4?^3O$jgFXq83LWjV!L-CTNF;HQ@})AjB3t>?o}#Ofdm_&*OH zkt)FEQkav~083I?>Xpw3QJy=i_+%$a5r`tDlmJl-r5K%Zl0XG?ic~#5PL%TK(W5c) z>z_&e+CilJeGmQpcdR?LVar%$&-UeOAAk0w4y?bBCYaI&y0J@#K}&jZf?6f3x<6 zbVQ!NVOT|Q3(nk>EMp*+uhGGxmz>7}KG7?{)52@Y<)m3Iz4!g)_kOtc!GFK_(cfPE zj~%qQdNWDK>~*c4tuo+y5CcM5DST>vU|H*F_I zn?y@JTT^$&e=Ui-8pkRz$T%`By@JS{(ywKg%MxO-l(2goLinuQm-xqMudIs+xtT5w z+`jMb0w)`teGgvU5i7b0-Kkq0Re&_UbGw~qSd*eMAy`&aDOAk7vxW0$8tYJIR+K)^ zHEnrpr(>&YY+Rk?JcFsK7>KN08SD>1zo8Z&CT1enq%tj3F`{WyvHo(^isZeeerT!0 ztM1pg{M;S`13eXx1|Ma}q+No9o*c_c=;M0sJ230>;mUDZPs2(mVajQ^CsWZ3BZboSuu8*MKDFX``tKPDEn={eS<(S9 zOT16jF!D=*YhvvpX3;5$i#*4f`ShCB747wEL2+<6FnFfn!g;Jufo}vApOxBd;C`8d z3DJI-47VDp97{SDOLTsf_)8`+%8hlu2v!MyWy9aCHN-RvZb7bt1k+^&5%|Qb>zNFu z`Z3KTZiqVQn>Qs`ZMl$DkBZUn@(NUXP?8?z1HCeF3&dUg))2GZs?2Hy#;GyIOw3VU=ECecPpV2-1|oI&e;DRE61?D+#uBi|!7qq40%BT&tddWdi2o zwj?Kmjtfugawk1q#kvu$l#BXIXls$EVl7k==n~+~YLbR!HhKURQ-Z0SjA}7$WieZb z=XY6Y3E@`35($f7rZvK{0n8C$xJ9iX44wjNQbT{-(YKOEd$`OBWHPTmw}T;=adSnF z(ymT6bWl3m!ZF3b<#pvEXqaw1j*cWLWAhYZW)~&RxiZ4)I zni#;q{YTi-x3XAraZ3cHZhHfDkd4tm6@`>BMvOsT5l^Zy#y-`PCj6Et=WR8gHuIUF zlv$N_*|155)hHo*S!tZKJssYyfPeAXOVD`$7%-WP>5oT~V{507jfrGS!?nB@(wGpP z40v1KJXgcLl%L z-=a~T)Jp9USe|Z7W4hHf)<#ya?w#LqONkmzEXbJgzI1o=^R?Kw$eTz;umoq(_X52c zFhHfIlxV*9XkdpBwN$KVYa}DT)Y&4;#wNejcf%8G)FStqf$$06U8hf`7F-cihHK44 z-ehqKIhJ?~@TlM*%|I_zD(gE?3SIT6g^@gBEti~bPB$)v%jr284zp<(OY3ep%vS(W z&a4Q3a@NkDIjblIDzCwFSb|bc2b3ZKU()Tkgi@aE0hLInI8b@`;YTd;>z|7!L85_N z000mGNklod8e~9Aay8d7ZoCjIP5^pFeW?(y_sdrzf|otG`}* z*L7`Acg8Mv$AN{N(0;dxPd{1OH3oN zO6886;;v7s%*!a>>NTz`c~tzivpW_pbhFAGlCXRFl_oN_$~;GpN^CnB620)t3}1#J zmAWG+j!ermR)PfpU^TH`LPH2zB%;~G8bEAKg0UCUmip%DW67i}vIAhyINHpLU$nfX z&>|PDuta~0-y56SE&hvf!%h~Nl*`m^><^(4ykBFFX3QCE9ua=5Iz=PCd}CC4QmDS7 zOgWZVb{PYI4Fp;lh`hYrkn%8Df(gZxLUdsPR?>eWIag!8;IS347!RVyqwCdZ+WTlv znbb;>(_%g$OGb~1zR|h3V!}hi5+nIqX*JCPbg3qc{Z*Gv-xtJK0;6=G*HR}>452p6 z$<(jRGap<|Hv!n{K$Ed4EP zabaVOAy^srapmwcxly%?fKs$|6cmc|jY@N+fD^<9NE=MJT8Y4AEg@w~qvZKcSoBNp zN~-povabh27_rbMtE5NZLQ$H9zI;`7)h-Ehn+jd^u0(e&2U5HW(EUZ`o1La>XEG^8 zCRSHR%f9TkmKH24mgqutJG${Kd17XR#*iw+arhl)#VnkgokGEewC~V^d5HDuLcb96 zz#+hz?aGi$F^c>8HT6_U>{_YlXjj^#goi||ELx&Mxfkv7ge$PAw9tc9=vQe|YfhwS zEz|lDONgRV1&thvRWuB(E!>>#y)JGhF=oc2idk~M9A(R=W3P$Igq$`(Z^8}!`Qp3kMGvwf1jkKf_fq3Ls%X$Mvl z>$aC_qq>=g)im=~)}ktBWj@J9bt3G2CB=?)ms{Q!TVkp23JaFDS}W=sZJOt|kX&q7 zMSx1j3}U;c02q%X=GdXCs&)z()4BYqWIbiK=(>%z+i2wX*pznp!U`q(-3Yz8guaxC zZ8`^H79FP6Q!7Kx7XR?;lgH9i2+$_CBX*41M5&gN5rTuZF}?_ zVGSd8{7@n9xI(!k9sJE~18_w~f9q+%{Ttdbzlx=YA$d$*I@t6T)u5%(T3lQM7S&%> z3?EzPVetOzUad+!G1O#P#u^AVUN|TU*Bs9j6j>_^)4-(mULgvo71~<5`R4rGWyzu( zq^qk^)^l+-3qK*N0huT)83tscEXtYTbHFEIoe~N~9P>me&-S^LXYYbi)SzW|NYeu|Lf}e-(9)&_4Ygee&Mw*Hm_Y@Y+ji!&P`^E09}MgskB_O zs=M&R*aXl%o>KtU11C=fh&=vQj7tD%5YooMOc-mfA!If{-eS@jn>x8R;7i zQd=+anwW8=VM7Zy@(spRcx}}WqgQ5hua;5PLt5B-Dg>cr^(u~aMbns+he zN(EhV!>pNS^$ZAv{uV|go3zhlX)7wPDm`CIKthj5kpuAVNN0Eov?2nuIL;Xv^cv+$ zCUt%u1XWgwM))3;O&IrQN$z>a%6EC|JZlq)g1lU12(pX$Y``;PGAE30_S7$ za+zJz6%HmWa&b!H)!}c?nijlJ#Dyy`t}#g)AdNLSY=I~nAD#nR0`RG?nQY)rpQ5VhfYJ~F=JE+7Kpw;iFCAwQua+)LY8+G9!>mF1LtRA{0owDt>GMo{@%Rw*s=Q>MOpuDKQ|cyV|yb}8Ij zSlDsr7%KB0qlajg(5-6&7&aq8SxsmsQORwUB5E~suQ zEt6hUF*fgOE4ZW!sBv#-Noby%U2#ZewI1tbB8pD`G{!JYt^!#`wsOCraBv|@+NF28? zPOT*X77PsI3gIHPZ9tU@v4(K}qo}kXuG5U{BEl-NHzU?%8YprPPoiJyhqM2V`%edb z7Y1X>3>1fTl_*-kYQ<+oq=bjA#dtGq+i4S?PAi}#{5{TOw?cxv5=2@HFN)}4rV^to zmi?sgS!wyB;1ecfz*3JNYQQ-S_mg}uxu&oyon!$s`By|@m73|H)Ut#w;EHf6oAl!V zQP$g5xD+|OvUNj0N4Gi*G1G-O-N$nyXUxZ2nHafg96H_8B44PQi~L|H4?%wLZ9`nR>XkJ*kMmz?A97Scu@QkfoWkN5CSW|>rv2-sz z0{}z91e5@(KvloXIZ1+=Z=pLyisz;2MU`WEtJXNBaJAXSc01V^46=Yy2CGSUk2l8S z)dBQfXEeV{!O3&fcNr`>g#~=_?5Q*6n?Eh*{z!&UVrP>Y__PX@N24)jQFCi+Ub)|pSkb8 zUH9G_PWIiu`_S(FkL-EiiM@xO+I#q!y$?RO@5u1LvHF20<_|o%?$G7%Q(K1#TJBhf zghUdG51raRHhAgT@vZXeuNFW3&n<+B3DY9rgk%fXUu4~q)9qW|Y~T9JHZLUo zB9{~1s5Z=u_~cvuDpIk4TR!;V+QdRqMPoW9+S0l{ ze3))EEKBhT>?msLs8_V>L&=KOBn;M+-&9^8h_*iL{iE&P7>0@>D*_Mej;vY60v}5U z8$CTIp4BUa47FYv1HpuB5pRRdow_nqY5=0J&Rm@;YBKRQM%glN+`v7gA;+C6^osB} zN7cxhRXnnJsTICgA+LI{A)jD{aurjH*$@x5N{*aCmC18LD)@ycWPYxX^3_-T+>1JpI1i}$gi&gU`b9_AqJb(-g{fRWA6Q!(*eW-FjNi1vucjRX@Cwf$lP|G+%mcvN; zD6lhOIn|Q-3zUcgtrS#V_?FTp;ePzWYRVd) zOx28nXF#opLQG0XBUT|?nG&wh7B9u~ND1R&=rgKIRXL_WC2P#GQlNCQVWmCx1u~fL zHPOq$B@rG~aKOT7n6agfJMK{5Ny7$yFA$L6a+wCelgMaja<9s2whMMz_gu@s7@Y+c zQvg=EgB){tv_M16Gj?Zt?S>&$kNsa8!1kYR)_ZuIx=LY0=87aXiqOd4o<3P3NE%wcX} zX<5dl-jAjr7$O5-4_7}a4u?VG`Lu1xajV3$~!=SaXe#*`` z?KUyjyOS+SzbH@{7g_|lrfFaT9@ebE+Knhv$0Vh-)DuF`dE&9i(ZoVhMS+b45RbLV zkrtHN$`*~=QAz~n7GY6x>%1qxaB@Z!YwWD-;5mWUh2<&=Jk*iAu$N+L2x?lAUD2-k z$i`3uX6AT_^U`9+sYZc?nR2?eHD9~1wstFxX?O%sISx8S zbYG9tpmN~gA_+7eZ?}2;o4R^aItZ?Su09x+b9oBvK?+v(R*WO2V?SFjNffKtA zp4|PwGkYIeC2l$7xFwP;TZjG*dfA~CgHG)7W5bu9n|%PC*ne!k^VJsZE4P$Xxp@08 zf>QXU!*Y6ow!Zz^WU(DSIgQ8*!LDGFNYBV6x(u}3jTO23a^H(z-x3xrPyrQP?hoKX5w&`<-YIMtA3(?1FR#AINHynkT zuT4^k2-D12G9*2vnTeMeEj12jh-&z}Fns4{#ch@`!HgQD)n3$<;|G7;oQ$FfN?RbU z%w*6jGtpMTFcybtJD0M>l@Yrm)mGtgy-uD)`Id7L>LyaB{%TfhmT@Iqgv-*jys?WP^k8dvB-c@rB}N|>$wJ?p$jF|WCH0wk-Ec^`NlvqX1P$Lm|sg zs!1E6HPf|1o%XU_l58*d8`xT4{PoBYR@%xYSw{i6u%BbclDOG;663 zQTUUR5XHXCJ&dd0J%am;VZ<7_R%&acm`@n;YXnYT#N`>miM2y|#IS#WDeKBDMSvY9 zIdxNRl9Br)2J7gMNfda?XV?Of{oT|kVIv<}bB44Q;Hx?@e)jmF6fq7ZB|>uTj4Baq zBR8x;$b>T*y(O0r47kID9(sRXicDn04`*l^(GW0AA1-L1_xgFJFR)mR)tQ_ORC>W~ zM+F=8L>uDam}ht~dQJqf)RO=b!g(>qkQ732-&j+;EDIP4*eQ(o<5c2_6?NymHa}CkvB`u~czG$r+QG^(0&2 zwo$Z3?UpFb)Mu}UR6@tq^TxPG#l0>IYh}@5mP%A-8})SEZb(2P#+4Q&DzMjY)E4K( zruoTib8WtPeslZzOV?hx@_bm&UwZ!H)|Iuz#qsR?WKEjsXY1ROHEDhf5M^!iO87%_ z=Imf~C0RMUy55wVP!WV+AY?Z#+#LbN|4I7w4-<;}O)Pz&63#sK_=&gQeD}lm zKKuCgCpX`IcQOs{)LJ*&sKy(+_a3}Yj%}|16`&NH--pw^_wCwu?|pj!T>_HXfB)_W z@85fL*Z$+X0&Y3?N_niTi?+mL5&0MXS_w!53j;x9pS)VZ!dhRBPG?=GgY>JdB1C z-8(FMb8}7WJS9~loBp|*u4){!ZJ-QZ8_kC{!P_)nA-2dTdr^yV^yF4|uRTLXaLR8O z7e>oUZc8tLYX!3=EiP(nq01$~3<_WS1iSSZ=&k=KwuM;CfOge$T<(>4@O2y5`N6Uj zO65sz!0y=*EbJ&bmHSa}}YEWg@H11MT%NX0X1a2XfLn28*S&nLxl` zloNH{2WU}>O(g&)TIUljr}wnq#u3HHP=qgfSB75QDDE2Ve&Nj>g~rYqJ2nN$N_N%J z&y&VyVNnDYgkafptJYdqrAz=D1gM0++|r?mjK~3U ziq)5rnm=A#!t|MpQ;i6tLBAQrR5A6_TV^rqy%^iA!Nj0(VAV}oy;9mfFuP2F?7TK7V@mZ5yo#>9Z&(L+RaHBLo39CX1r&$s;i|1afE_YJUJulP; zWL>bDm>Jz7j4QvspcnX0Sn24*3kPDojv5=3f52VEW_PD72qb6%$;{)S`ZP1#kBoUR zv%5STGrkOQ5yCaf9of8V$80%T7+(4G4U9QMH7VTtQEPFp556`oJMz(2gC=rZQ-MuC z2AW`|S14Ke8!^f(?#y2HR2ykj=`kIFX*3Bb~lO{-HZYS10#MS-z|-j7z#Tj3@} zm~2rb*i*Gw4-m%mFhwb~_c+P(dP%gO zRNt3W3nJUxXd)O$pubZ2S*Oung*8-4z5)`rxtN_WM5`R=448AA5@wOJb z&v}%TL~NrLc`Zq#5g#>?wVB$T3CTTn43ab8lbBOP=EQ5@y%oOlE=X;05t1Y)@R0AW zyPAukVct3~cLi5WY3Qqxqm`xRe#y!i6fYj3^u z(v4SMx&F$luU>oU;`yud#rAaV;w%7_aAqMy%3|~C{M_Z!&#jy}v$}G2n2$!+*1Bvo zdiJzbG^&mZXyj?2kv=}@FR&=51fQHFK6(0ylaPCz1Vm9fg&umPQ_P@p^pQs&-LrRJ z1S@F`zwj-44n4c~$m;%M<-rqE=*0Sldx0f~ z`-nw=%K2pmM!hZp=R9<3`{?QmPt`Zl3tvuO{oUp}(23nL0hqEV-vWmauHeh}zO@U# z14{W;;R+E8;EJq(XKsIg<@OI(ZvT(0_x^U}{r|okkW9F%>;JL!>ZkLIuT9pkAfaJF zJs5RjJ)okiB4{ypKG_&HvAArk?g16O@*Th?TzJnQmwt3=x`ybcplLxgLdAG_j&zTQ z7JUpM&S5Y6N=)oEXklko%)FkU0Z=MR5+)RPyo6iYPLCw9ak5tbJ@asf7FiojAf@7& zQTQ{^a(6uG*|mvAw5KIp$V_CIUN}&upcHsDIe^pv7`*jvn_b8II%s0iWM`{Ow;GTp#y9t}ba7i_3AC-U+7Tm`+VPVu8M0d){U;P_4gDc3 z_G*xNys{kIS%5BOlThpFamG9~Lx{WxN#66-II3hefQ1%}dc+732#a5+*V5|7f?3km z@TfXH>xnnp4AZ)B{Ryd(UP-05{VXLnP>M#EzlSscnTY5M8Ij7QmCXVc`E?4k5HY9H zL1@y#MAGU93FZ?ZWJ4neBZi<0^b7L7c%9s+l}b1nk%TEl!wp3-gwPYxf!2^K7vY8~ z^b(UjKrEAjF?cD!sKAGad<)54Q(_)s7&o^7%+P9IQyel<^^u?*vFQ)9m0?Cz7~0K? zfi(lhq0@qolvFXGVx8h7Sm+a2YLp+Qg>91;rroNto-rdYxu+g!78{Q1CHh5CO$oER z4$WqcC#WcTa-9k$mVB6I?5!scgTu&sQCf|%G`(U+KNGG=0hQ1u0v?1&aathD>Vi%T zkS(4Eg>*8cKcf|XMO0zwFrgJB8!AAm3>7S4DgYq^Dmn9uGXND65QP)kcLeL%Sy3s$FUkLd_bl!;}`25?BK zN!G1qj+FS)%0Wz6n(4^&zOQtHU1AkJH^U-oi<`E010N$v~ zLvO9{N~%;Su$Q}Kni+5xlEGl`sM%9F)lNwvVPHFfH6^PkmYeAH6%UBo#4A=L7r4{7 zVPJ_lL(;hT=)f?VJmyJa&cGCwXe@fLnhN3Y=O#SvROM>pHHy|~eWAg<0TkhdSPH}B zLc6?gj49QNkH1+6G7e|L!INx}*7?Q7#m4sL=B4e6SFc@u{`spfUAg?y=GN7<^-BS$ zOy(En>zCF7O4+!yv2`u{#y)lG?AbFbD`!_Xnqs{vPn|M+qE?gMdS=HZ&z|hFC{CdW ziz15<#ZZdTDW>~6cI>f7j~;#a;YWAx+1tkKK0>ypMLhqFRs4$#)T`dTzc)j?RS1lzJhh~8*l!8{QNJ{jhm;6Ymc5R z4jnza|H!lZ9(;Q5p_6+aII;V{W4res-M#;jUHgvg+7qD5A;B&8@4NT@Jz<6O_wRXV z*Z#+LAADlZ15fQee0tx*!vl{&Cl-DATgxIb2QcT!!`vYr6G-#mvjMlfczXJNeeGW) zPwe_vBwH@s{`T^F0gZec&RlYK1-RuBfr@>J^WR+&`^xv%KK$YN5C2QnN3yPd@IzlP zwhfmHF8TJt+rQg*@ssI=*PGdS{IGBnZH(-kaV>wjCW2T5#t8S)PS(P@i*n2nOOH-b zkkdM&qH)7;#>*tFq9Z0gS%;%kYOuyp3nH~9yrXSkXk{JB18Da}G&Hl$oCqP+G7a>l zaOSgkV$?ngco=1I+uk?eom!!bo%WI;T3NW*d9OevCKOMJQbhVCYm6oBE9!pECqC$V ztDt#N6cvCxDNL5ux8p}mnzlx27foZ{{eDr2#TKh4r)Ghmb(lDKF!Q$^Mqebhlty5Z zr<93g<;>>z)>l&aOdn0~;)9QN^A=#zVh_^V`W#L2x9qx+7Xf%C(UN-$U(OnrX$?#< zIOnYfPyhfB07*naR4{W9jZCzNfErTL!@5c=>kM&f{=D2yqfI8%m@&s0<(_s0H_Wug z4oTKIh5U`CP?&mzR(ltNN$^*%t2;-(1ii?(f0oT- z_nlSfSp+JeCQ8B#_o8Svu?$bKfl;frO9TDge3%Cs#} zJ6qgL(pw&%RhX!(X5ui+6%~OB0|Tm-z7cdmW{r-~)`!?t&SJk|R{L*bIgZ_c(E|ZWIWSn=*}AxW2fm znptLaL5zd5yk;IJ^VT4s(pg_4xqyCUxYzfnP$XG!`q%b)_qtd?O5^y1k-@2!oVE<_ zCJBQd$!ciGf25V5d@&WVM4%Erz2c_|KZcoRCzvM$?I;~fOXZBkm#MqGY%HPJf{De5 z)o3i3`eX#CV7Q;ga(X7GnZE^za8058kIKU+l~=1;45=F(vW&sugmI)}WBs_$A{UY> zI_s0!pkD%~%iSM_^T0>jG3*V~;QZ+U48n25t0{+2%C%&|ehk z*V@JT`u5u5!uq*OVVypG_PNt%2P>=Lcf{pcbN0;H07Yz_UP38PNB@brP+&wk`HZnB zz$b=P6s0^7ttrbu<*}mxDi0lbc-QW|>P`U;F`#mf!WBm>_X=9M_rCjmEt^$#+xokH z`upxUy#ulA1S;XnUAuM<2I?4x1hhPJ;mGMrM+YyR ztga6?KcBt!yDh^lJyPXcMJe%1K{WxZTz>Db0j|Kc@;%q(558B3a{I5!#(;jI%SD!4 z;k)gdf8KiYH*3#-INp4@onJ)d5x|C6J+a*e;Gu{mVwHKLWXtRhtTK(^$N(w~@)BNU zB@C1pwFk_S$~A!4!iXCtP)Qh626dCo(;PZ|%G5lrdXAoWt#m{@*IX;fF;#nJ7okg* zE20?rP|oq4b3AL^GmQ=-Vj%+%?y4xmIJjo5f|??gp!*_MLC!Qr7?WKgIMHgtDXTUZ z9hR2n`DWjXxEJWFsoX7NetJ_GnJZ(}Q;&fGIE7mmO=I9;3{yM(C$;k`?lV#UdZLmr zxd&qyn5ngDQEBetNODJv`YN&FW{{b0-r7-!RmhX1NYmmWWx^q-Fpq)DVv_Or;{+Ce z1_@+RrGo6N8Il0(kk~s}bY3NCxxmJ~m_$t_XSsP==$ntY7taeiG^Un~VrM1ZT+LIS zppH}VD)ZCmq)$<~SdY)u-A3X4l86r`u81&0a79dCFjZj(2M9kuVjaP$DZ5k{CUgoq zSXx-#CrAksMOMHnSth3iTUNljB)FPDos7_ealmY786yX5Qi_39fDP;Iwc`a`3R_bs z`Xcbqx)m0lC?!(yo&jTOt%t)Icvon?*DqDdV)YdC9*|ejQV1PA%`9QQ2&-oAdb6^P zl8~lQ!DYM7wNKcbVx4~J2R-q8hMBT=SGCtlSQ;1}TC-&Z?et-WqaYJF^^GyPvjono zc-RyVo6j_7^}B}Qb{s&%V(f3gDm(@l$3=Qnz&@e!CxjRG4uC^{%0Mv3KdAu*MjkKC z?pfi0A+g?)zW>BihTU|o*~gf_{cVl=`h-tb6k|HNhS37h(ZNFx+`^rEijfRJ_8ujR zX$O8V{@P-c*XQChI*cgv4zV4`>@nsqD$yKTGvEbYsc;I5%S0o#a*bqQ2pIW2Q^a*H zCQM9u=^PIqC*jAt~l~VQiGvG8{*Zd=!iA;9h1I zrs@fh14K|T(6g2?Rj^_0^)+2C6L;II!mw7}Lme-)yqBGU96*S6+R#HnOwv;QrQk%c z#2AK+ZYBsFsa5F)1eCVUw#oS$!|TO@PH zx&WKa3?}I-AQqZ%!^yevd}{(9%Y5zp)`jPbygv2p>A~5v>0ofKtJm8K%C0^{dA7Gs zEkhLA<$H3kCk?1P+4H45(Wg_O`yx;|rd5xJ4?o0g;`qU(Vl) zGZC)r04h77%a0U6{y6cnXYaoCt*bA-e(T)TcedaD*NZo$MgDw%DA&IV$mG1NugI=& zS%ITmXY=hpuD$l#YWtICvMY}~SsZ-$)ZW7<18CWM2%u#@LCcZ*g=~3XNhBsZv4i*T zIkIctvE2tw?2fplICy-#9ED7I*aa-XCx>JOxZ=K-CCWv7`KK;Dy86P&>Ye2L7n4{1 zZT+o3gu~vx`OPJvT)w+xpryCIgOA0Q>BJ&R!Mb=W`dco&PnzWiSzur;hbz4O9~Y#; z<(=``Htb<&WszU?q**{Y$ubv_p&)ooH%i8?Q-6{`ID#aIrrXQH3&+ICo^yO{XTq+LUncZ zLg1b{+u)2ity4uQFv=IG>FBpbN7S`eL`}#QMNq;{y`EXa6)m7poWp$|F|T{|N{Zc7 z+9Y;)J-fb5I6-`Z%hH}N+>fjU(!13;xx(8JR7{>WtL}LugT+Rhc$EhgN)AzkHc6oW zDTa}O8RXu|>Y$y_u8R*j(T0YWq|ZnwwPk}z*n-?^r=eNm`Glqpi!-LlyQN84Xq2Z< zJ)y$NJU;|`jE4^8GX*UlZSNSNT#7Dvwk~4U-?l6=Q)Jb!p`w=0c`(NbZJ#)Cae0iS&c#f)++Ej1^M*;M;M1@S0P2oddBMk3$9CfBx2b1*iGw1}wX86!n)C4qoS}WBz-^3~a17Lk@6Akyt!#g0GM~d9;_gw0Repd3f1A zqQnR!L;aP79`)5wO-);!Wl(mo^73OqjnrxO@Bmg7Mh+FifPy;=GWOy%A62{(HVv@k6 zpo^wsZZ2hAc)O9V1`>(2Dg_;4yhgzPo7#4Qk`t1URKC%XUt?gD5s8`tPLldJTm5%g z7%3>eUBUq)n9b5sGHx4>Jccs&uT@n ztk?lr%tAUPo>lRL(e1F|2a- z?0E?YR@`#d2$mPoh??^MAL_*N?*30c`rF5E|Ml@t|MjTGC-43KK@CEF`tMl37hdJP zdgC9CYjE-&IF=7|J@VQUR58{&?Em;*^2dL^w)0@_@|~&q6|A+%xs{388xt3<$rAK( z`4s3fF(-V>g`1Oe_5Qa6w=8YVTuEkc4$wIHVBCm*yf6k-UQ8I%Eu>s7-+yB*`)T|5 zzYTx*+oR{7Xe`(mM5(cd+%Skjl!7oZEou4Ew$Rx^ZP?e3WqtKnn3fu`{GU&M@xNaE z#sBrjFaOu~e)&IN|M-i?@BHKbYajLwUKO)e!zf=?gOG--*<$s#lzyq*W@y&LBF2Pw>p_dJV(r1 zE4CvgABYpQCowVMlWM@UM3N48dPv-!P7t!)aUKR!yY!qC4v_4=Wy{jQcH$5(=JG9z zkPa50wm{QvQjL;wq~-K!)p*>(a795XgC7;^#wi_kT7H1hJ`6ifctblSB~mOVF{Onw zuEf*UdAy$Ycam)c$~6z$Hg61@AGfWt6RZof3fN5vNKmq(agOWU^@is$Pc z#KA&@gZr{&g&+a7jL=y{1p|mdoAh8f>0yhSRj3Uhc>*OEsg)9(Oi7$WyFXD4LyF0? zU^QeQB>(^r07*naR8Z)FZ`mziwNErE2l@wGem0{#LcJu4I%`FKZCl18h{13pVr;ag z*0zZgFh7xOY1!l#l=|j-rVWFUwNS{|JdMlnCd3N9kx1!<;CY}Kz`I167LmvVLV`)q z5!opDR>M8RYVC0oO2Q&r!Vh>sWTfttrXou;W->r`8Ir33xS5t(qVA&%N6;ShENUbU z2Y`gwVzhjq+ERSgZrs=C+cdA7K5B)8D?DXLzZ|q^@>L^&APO%L$Kos$P^zaQr_)+s z2CX}Zz7cIJzU)W>0USFDV@hHYPQ7i7#HDJ8oQybL>LYMc_XJ*k08G3Dhr0K`0Fog; zz#_$fSBWYu1gxLu15Yg*$00;A%3V-csEK6Mm<7we>&JozCYYeo!hcG+V8AL`M8lT% zT-Af{J5dH^$~RIy;-S(;NUNeJZ1$cRz)oY0_=s`nx!1Qq-UT>D1SS<`F6tu7n7#65 z>rbjI?Po&6mYsH%*6&Bruc}eW?hy+A@YZM~M(RAOr==At>&nzm&v;@0iH2vLo$;`| z!>0~bgtd&B5*-Dgf^Z#-4TU00m@dMG%GFjb)9R~NB=50-ge;B57Rw_JU;*FLY6Z>y z>Zg=`SqUSewCV>PG!npise4gE-0%=T8Zc{1%ND7~vTl>i>Q>Ch(uaae&_yg@X+S)a zGHzIjpb;%1lzY`nIui$)(A9)VA}|vn*&x-y;!`;0{omOZ2-o0dd zyX@>d?AOmEy{#4$r%i6ZbQz1Xx_av*L|IXYvTR(+c-WWfz6_{bnVy+F7fMtJR9^fy zuKpq{5wrW$x$~0>S7amCxfkVOW{$^txnJcQwu}y5eg5aaee~{cz@mIoFET31tNire zkDmYjh=}F+?**&8=NyY{c_=gsm=>*>R{&G*u=|65OpZQUYaK1!Y+r!6WqPUpx&f&e zEiPBiPm^+)hc$l&>%zH-h4WLFYuqw1yDGS4egn8AoxRzIPVBIOSWFUjN*eNsAIbiU z8;>q;zP6gZ6F>Uv8n+z&^bf3UdHkUfEP`9UXmTuWbb0)t3dV?J1i*awg+`5iWb!b< zFoIj2INwsQkG^{J-tUio{C9iL-m7*WBN81POVyxOf+-&!l~&q=nI02oxaM84f8A!y zD$M2dpQOyOt=Qe3)MH$^%Rnt+k~kZ}G1BMl7#;IzUQc_YPTUheNh%Y+Sw7q#F2m(k zqlA$YHluqnTZbM=F0brxpNy}LCFio4z8++m;3WUIbNz)FYg4!tWtjK6V};@ksh9?Z zwHvMH>X+>XrXd`*<&5-?_(F608Y)Q=<*zIOe0H|x@#Bv2tH3d+evXw)yZs5hy8 z-?JXQ^)m4TRH*UB!UjYHrQJ1@b@e9EMkxeXOarL*T zHcS(ZC!B_+khR0}g29~*6L2+FI$0q^cnDep+(@pK_J9z$?Zy?L0)$f?RFpH7G`FDp zm0p)}z|PrDtX6)KITV=D*Y1t=(tiR>LJEbgjsz+v{3_@h5oc{icE}RIEO|@|ShTl3 z7};rS;mKPo3)>+9B$yHg<4PBxXg9&rku4~fr03qiiLw&}SB<853E(oiW`rP?zD6qJ zlxUAh%aJ?mwX=nEP#11^W*ke+ycJEFQ>7g2iz*C~usj5dfo~C@0?pWf1Pf(myG|An zBWe0sg;->wl-^#qMk%8Q{TkJDcen4>C}nF9wMJR=cu?Khxaas}{U!LsLWeDSxB*d4 zK`G9qET2Fr1S;2%O?+i?YNqL=uaU-SK*hvg4N^ri!D^r~F&(hV+0(JgN!;>X6<^I} zmGjl`*|WEQbNKcr58nCW==qmN&p$tU{;9x~_dYoqgC$t+n+B|2A2_=rbP9j;P~~BS zE?P5x{Qjp8-u?H{Tfa_@e!kv$bZI4?yRtdGczX=AfO2W@Ewhb0u?w`Fb}}D5VyR$^Wd*~-~Y|wyOM*fzWk5BdiHZ!Pe1zd zsiGHIOGH@vw!tg%tdC%)LvXtwo6 zyj9tVd$M{*9rQDu^%I+_<%YQYx7w*(Tqh)GTMuI#`p9>zTE5X8KG5{oV(2dF#0J)= z;Y-1V{4V?Z%;>L~$c=0+G6qFR)YX7p_1w##EY1+k;BpOH2MHn4nj*jkmU=ad>#IlJ zrX7k|l)Y(d6c}5{d6I=2>)XK%QnVVGVCttQc(BZ3R;+Y@SU{C@$pQu6ix}~%wt8I( zO%EG0CXvU`C}9_{!8kOaxrKc-2r951daAipOV(|v@u}kZwDq+bOF07sl5Y64+@lQ% z)Cz*&@F8ru0FSo&nD!z2EWtMwk0BFeIYR6Pj#($;eq1SGEZKGEQtz7FZp(n{LjHrQ3K+9}HLepv(zSV+a!K|G2 zY;Y_XA=MO#ng1l1LvXoTt-3PVN1jk@9u=#PL=Ihz(|3Rs<|A@ zC{SVilpC_Mswh#H&YsHp;b@CDSy#ur{*fkAWo`s-HO%qm-YJ{nF{hI1GZFTw6$Nv7 zoZCqPm=%?jQA#SV){?XvQN?kCsUSF%EB%yUk()A8xcIFRt;=k*dPJ^K%62Cb!i3_k zTxCx6&O0^8NgAFa8>XGcF=EGzC`LM=+`UWP9X;;c9$6(~6nYYasa>IIoR0~&$QKz+ zXXc{NrW(^GRenJp(G? zGu)IWMybV2#|F_O?LQ&4#pV<3dCV&_mY`Rjw}aP6KL1gtOxjfu_XfrAL4W_*-r;Kp zN5{Jdy=s`GY&T*GA0xTS1~b#MI%k!K(e-YrTpG+pe#0?I6P6@)oYVrxa~2fVSf#tq ztMc^(jKGhQ$zTa5esz@uY9$J>#TG-XxyH8J*hBZn2^sBa@hTNOso{yNWMxi(omiiO z_(bU0T(0IQ1&Ad8o6=hvwQG`EDUvNI%se=Lqbj~@Z6|czmmV4>3luywMJ@t<9>H|)ZKkZ@&xMurj!Hm#_S$d^%AoRsFnT!mK?Jh9&|@Xz50%W2ZD>} z#xZ&)@pI_z!qZX@d4g4X^{1)6*XVe2b33}fIZC4kdHi%x)V~mKF~!R<$=a=xYkk~{ zV)$fb9G{%7_;QqT!%zx<%H_*fCMKu96HqyG5~zSwnGlq6Zo+WLS^H^Yy}0G<*>TXq z_~Xg*=O@p}nsA1P+j(wRZr;7!`QDxU!`9wkSI>UC`-4vo-~Qt0oiC4m`t|WozdU~a z#qoPzJba%LuTKM)(!BA01Dq&6!HxGmc_bT~P4k~&H@V}%PkviI`>WgWb1S2{;BT=N z)MCf;Cre!#YO#8|So5E*BtL0A_{;A1|LO4Ee}DABrw9{sg2gN@E)x4N`1N&ZT-cY7 z$hvTSi1jmLU5spb_KW{`_VHIwKK$hIPygw`n}4zU=tuqi$Gy>mGp4|yeWKe6q)KQK z#3kxR8#*RhFYIHPV`wZ<`a;xPOxbEjU5NuuXhf#cW|Yu3ABFxtzc9ZI)3%)kO)}!; z5N|_04EYIDuBMT}4o<96gSe$rSnEZwnkW)LI5I~}hR?P+g*UWhk**$~4(8zcrxsEi z{4jRAnPMDD)-js9%n6K&xI0);(r&nJNKZwGKI_Ci$#FLQ7T6{Xmv z*~oexEPkrqzSBvl_4lq1vM5br!7Si;L*NAMkd<;cHPL|bN!%w zeKr0BO_1iJCns!ruj4e-rSr^V&=;;~K(SDmTN zMuuRC*=;8h;bk6`oKeouHL*{o%ZG3ey~@2Zj{wfSg~mN6jn@`Zs8n0$n@%2&CD53J zILP@rign3lrG^h1fCN~vP=6Y=KfwN z59cVHrvLyD07*naREFu_vz{HOYdV~5j~bi||KkW-l2=+kFDTKZT=2dVnLt-oINVCu z0V>6#npYvuE-JFPTO>6~865O?ACLAQkM<7xd%b$Fmdg+fc&OYPgZD)lyd(evwe-Z= zpJ*B|$r7+``_L9|;Kc9)J@iO1?iZ9`N$JNfN~!lvku_u#A*l(H4BjrEG-_CknQ@E( zFp#UzD;an8*MJ0)uwm0?W7;rgxO?!RrG!;#Xi2^2#1Tko$e~cVL~e!jZt5T)Q4e|% zq+-~}2rt&En3jyU(M)f|1$YvRHdf<)@XRnx*nx_U#`n_~@+T+I8bQA)ioUAm0OF9Rx2 zaGfswIz|08&YUy>$*EZ7l~X~>7-FeUW@2LI{KRy9<2<0tc>tCA3@_cdcV**nab<5| zdAI($xO#Bo{+qXw_o9Qp>%acH{U3e);3r=lKmX&y_y73tgRdXH_vP_>pObHKpknys z(_<1W?|+6ZpDAMbz&hpwR-r=db5@|#t3J-~&0n=g@7%oGo4a&tYVL;MmbukN2(smg zIcSkSN-T*Kn>z=K%f#H;;1QX zcl`dR4L}lj72p=~Dq1cnDCNU1wC3e!4qCqO3^9)p3y9^H|MS&f{Ku=m_~WyWK0QOf z1YGhJT=gYXHz|0GjH$93xog+0cRJ;^mpR6)L+M3vUuJ6?@u`N|X-nH9013Y( z7ItDvM~JR$r=HJJz<{pBG_mic9tumFJJqkh?XQnEhiDT>Kn{-RwCFHXdr7L%WR6ly zenzESUkYa&|Hw;}Hs}=1I1at?Awr<=qyb>OY^RYzW|3KiSHg494>gPKb!i|j%PH=v z4viH$q35LQ{VL7yF7ic^C8Nf+Jt_egL`z3|O3C{h)N$ccWZ46%1JpP#W_hWu7hV*xqtt(%_X~W@B=SKq3G>0EfnNF?2Pu&W zH6SvZBief;>zkhUuQSQGi4ZbQf^8 z-~wvb>K%zR^595OUrOQ0#XB~HHB2d5!2z*K*=Yp4@~hfWG+CHl{6uT%LTO;5GR2xn zTHbvDbG#1OFevMypMlCT-&u{|C2QTGD+?wT1uB+$+_8(a_0r^&x5aAaX5B~TDlFPa+U8DyZvl6Yr9jX^hjG{%nN3MK+C2~diLA=Z`ot=_+;Fcq4z!8V z$|!3L(;YnM?H&&gp5D2$xpVjKgFJeDP`$a=?M9tjBKO)DYit}dpS-wEfhfyOh!RlB z@{JoOfyy-lDvOI3CGzV}11jWIH2dpi%!zG$kCYUA~+wISsW_je_x;9MHkUz)Q8okI} z9z6g3@Xde79{%!9_WHG3<@}X<(~IlU-?FeiHMcT3cN6r>4M@c<0lLh*qEX0`XU|R7 zTWE2aoL`?>xHEle`@+>^7JQ3Y^G^bj2IUe&VoksP!um1zmM1I8PwwyiGI{zp!ymz# z|H0=7KK?S8Thys9WDBs$=MA_bHvn(G8iOkzef3n6#J+y^bBRkngV5m$<@6FSE0z^{ ztZS4aOS?goqp;hmYYKMu!qTo(0;HGTA=Pok4Sr6WAPTPu4_;83P7H|>^ zsiHP?+nHUE*1Z^t7u{;#q2eeZKpLWS5(Br`j?EupgvquwexT+8V>ioHc#!(JSQm=7 zr>nXNSL>$Tz8A1Kk9eYh2HrNvh?KDmhfyHuZ zR!cQ?0kqglYL5Jnr|g+v+V`U5RVPybqoKe(d*3xs!Mf@tjbJMjedeLM#ij2)x&z^D|>Iz}5+j~Y{^A#Xq|npfssE6Tti8O$)G)jkR*V=ueDo;*V$76vCI zbfmz4l8L2$OI8)iCTJ)GtgzpaU^#I9b_8qZ)sg67fzre z2#Ksv#d5c_BPGHPoFTA77f4mn4x+%Q`^pX8WDO5}-mxuoq_D%GE&p-E5>2zOPF)EN zDmsdF>`FGP8%^{gI>f8sDW$vOc;sL!4+`=--EN6YY02P`dU`OfEf0Cf}qTTU+47>18DVJv)$L2gS z;Z177C)&PoCf0r4Nl(}|%o4-8$#lCF?+7uKn!{UOiu&oBheV&dyGP(TK}7~6TOGBa z+uy$?TUwuXUiO@@aYrK64fXe0H(6`#-KSgcS>p~ zu`s~UU}-YeBZ8c=@euY7y!ROK)*$XjXU_1wzYpVb@@_|REV&XO;5~W{et;#0W?|O~c_If&vm)bgoT$lY*|!6_1tkS1 z!!uF)J{Xc3sHk5>`CgJmJ@e=jIaILlRd@)P{qWn8-)i}zpJ6fJ6(8?up=ZV|E^L{k zc2}`S5!7M{@=hZ5u`u`;9V|p9jyH^u0m8v4#k`^Stb?!AE9;@J-TDgEC{j=+q;s$pnOizjs1Pw}nT8HqaeJc*5T!0&65>a;M=4;z zPib1V(nebv-3@ft!`ZDhT78y)=m2l}z4|;=o81eI@`a z&2et6^KoV3E`*GQ^yI2#(kZpd7>T58h5{8$Pd2S!;ZiZ?m^p$asEfd6c%m_5zIF_# zM3z-xos!+Q9YXT>~0afMl2qHXyJ;=(eI102OVi7qxeVtebKqe~{|Gb1;^;9YwNk;8>;H zi%kp65F-?6!^*Nl85j)rD3zBqWL_+^3ylacja9@&{)A#MQa5czqrwssc?c5YgVOr{ zj>wH#kq5k5(>4_LZL|UE#D?tJ7Tq8^L8fiffLWhL&OM1nFmxt6QS8XX4BWX>3iNij zloQZlT|534rx(FP zg-=VIhT=<8<%u_4-XlokjOf#vpseWG#e3|mtJ0!$7~;JxzO4;5{4i)tvlVn&j9v|0 z612yDN|wr#VH8?71{xGM8Wb$>4;>3 z32w`fEi?kR7p!EV=B;ZyMlaak*(D!DTx!tgo_K8)X*2+g3D4BNVg)Ipr&30O8$FN9 z>??iZ2f3yLCw(4%#5R?oo|5d@uXm{WnrO_xV(c)&!|l1n+`R9@Ore5icG;dvgQ%^= zq*=7104E%JnpT4YvHlhlcbPnlk}xe{l-nBorEH0JLDqlr*l@z_-ofH}uzKZ^m8`4? z${Zz`rZAj8@rd*cb8W8mR1k1-hJ&(@lp2h*Sl|i^5C7XRVViT zA0NH{)uZ>n6p7dSpEZDrpqEFU5BA}hgQecH{?X&`?WdqwK6?z@f)QU_E{E^@TmO51 zw>|jD%GUnkwXJDEENhc?pZTn+g$bD#@O;{HP)C~I_z25j+wY?l^5<7 z=ql-fqW2vQkhKxt=6?DdV0GxR&rt+XqWS3q&+tK2s-%pK4gDk;+MKx~5;$EFIF>lv z4~BI!tC%B2w*+PY+OUrMaMX=GFvQYRKM6)^GvaGbu7`02KFai9P$>Dgh4&%0EXK;H z52K+HM{bSMm6#k6hio~GnArgJlw9p+-TnG@I{?ed;*WzI%PQV`X0`8n`WAzaJLYNe zmU=`IE7rn$p{gq2gub4Dyh_>~NfUg%EOo%zPLLR;*~9?JR{q%dbj8|Ful^YY7*?98 zlupmkSE)~swiZLh76Dgy5L~3mzl_)##1^aV&ifXFhBF%F2~?~^MNKIqU5Z#Vz?$5P z@h#HcsI~OkrlPVjJ}C3M;C>kafk=W7R%!+n8F6Y@f~?!zTI%VQuL|fE`~oNHAbEMU zqT<%5W#s^LvkLmCnSIIN|3sh-MA6gUmGPe9_;bwr_} zFJuiPMT!7skTVpOWh|w%U#mzAH|R--tt*g~jx2u!)aldN-?0E_9Vld+$>^W+rl|HFRIS0ce{MeXBCAI>17sb(V z!e{}0oWLw~Q`Ee~mJ{I0w?w0#i^#&e+_tV*&M=aJlhVH>o6(E`Feg$;hkcwm;Xp46 z0E2QAc$x-X06##sXitl(u&5}j|07#Kmi$*@Bv--&1qUr@mFN8`A6EIO%65x%C@6)G zm8+e@9p|}~Jmx)gm4K086{FixxT5+ztyU$iknvUYBCBn|vtR&v&j3RJDjK5YtcdBy z7&u0Cnc|s3e+5~$y_)FAgdm5oex^NRHilZjk_S?y1`q63uLL0gPE?~1XDoe`^?%Y{ zftrO}>*04D_6W{V6ani<#r~5KW4j>nqSa(zaK(1xJ^(6Bs|m_g_7s88l%l>AGuBtT zX`@gjRrCEmx1-1fJ(U0|u#pUyrz{WD-2-XU^@QcT+9NChsRGnQ+61Uc${(Tcg1O}g zU66;yw9t!x*rn+OzLzdA%;?~0ef(SN8~OcvPm0#_UbGe`Okrw=0lu! z&~koadSYsB`ohwMxyy44*XEaQEL>b(y0UiZ`o`6jtsA$~oA>(5_xEmY?%lk%d;QMn z%B|7G)zQ-G{*{fVYn^vH`+w7aqv>z?kB1-p$D}lZ^V_Hx- z&t;>3z2)fnANp_n?PmGrrImR0(uVX-3T}ZfUlNcnTlJ*)Tfn!RmvwHk{`UOTQoUQf z|MbGf^y1x_pcC6|_*;&?^9t^l8n;~C`TpJE$N6i&8i~J!L<+Ny>8dA6f#~b!vPAbK zK;`Q*>hM+_PS@gVSw&wdhOz9a9rI^H#uNHIP*Nd8gw5a9p&^4Qi#u{gU193Y1*o(# z0+q_l@iu%8J7!_0VvP%UU^lkOm7SFeS2W?uYUYjkX}jrMS)j6oMXN*0 zglZnsOkzsyI!TDYB1Pj-zs%L`5Z4*=BaBKh0vM<|A@WOklAS2uv62%nT~Tl)TD6Mw z2p*+p_L728$QlX226_XoM1xYWDBOawI^=U;lF1!H5M3AvQ@N8~J|~KcwAqG|yf%7W z1n0z-lwiTo+KZxQ7>B7PW1V-RhT<;7E%-hPeklElG|IWlujt;;1Nb+J2D^=_$8Ma^zS46>#SJj^Uvvl&Q)`7Ult=-WQZMax%(YO3UKdP1 z6{LMS52;N*((AY#v*C8^HViGwnK02yq*;kY6;rxnaYmYTn-;MT#Gw>IF#`vwNWI0n zPY^PhDyc>$1TEC?2!)cQQbFjJ14_KaXc2>25$;7(bkT**`O!S!rnEYY+p`p6@x(TLqU(J>vD5)^Wh zDTe8KWt~uL0)mM1H$Cf_UG*0ed`>x>%DPA+I7yP_{;JXhPtYQ-FyM_f$~yz2kE`N3 ztJ-K(#cK?7;t9fV^|07+8-lb>!u;Y)LlGi1T0~~Jj~j5&jVL^gjy;RWqPoDVG+P&y zj%#FFr&{QaJAx+a^5wD&05_#%-U*|HFm~8T;k4|)hoES!ZGHCS%>W~FH?hE~QAXo3 zEuhLOYJeO?HC95euJpX}6kt|2de0`FJ565(dn^i!wifzoI;@$`K$v8`H0h>kFO%dz z`P|~jmF=t#mm+HzMT&qWrMl$#Sb`9UtjeBMsj%2_h~mYb}`8Ujb5g#8wk8ym#Ffp^NIi3bXh-X`_eol1e;KH~3gk{>WCxBeFVdgp|x; z0WEu_>AOy@p(K4l1Ex3ndyj^P&u(wryS;wvFxve7ApYg6`_cB+ty_0iSJzj+kwsY! z9ge3$lrcu-AEYpkA7cO3yyE40QZEoqt{L;;ZOUp}_S1(`RxVE;ld?#Po9IV{mU*3Fh(eA=Q)beae$(hM1%w|Kg9fYM?@7B0wc)>@V&097*Y2*@={O+3A!f;|XYlZ71+5 zD6_F+13J7V<+hd^nz6y8V)WL>)@WfEKr+ENbCbL9K;cAh57{QEetjKY3-oM^h-ZXe zFjWs72=IOkw46SdT-uOgjG0*NF5$K`>H zQD2HB`AV-M`YxlmCpiR3ztgC+-=W8*Q?lJM^rFN)h9=vK(xHhKW3oRP%T9BNA@@&mlFzL9uVP|p@vKAew!s1i*uYr8tpx>f@4QypCi7Nu(e z4}ipES_o4SbIdHmH?faXqD`8yVkEfUQ58a#!(Pvp zp1;8rxcq6?X+;WdDUK4>sHpTwHDZXjB{o1lAt`n+2Bu4jsYAISk#iE-PC|4T&WC;T zqF7S^Un<>XAL(Xv7>gPs?96T=5;z1HFt=D7uzZSy*&gc=oaQOf+t|YtOx_Tu{)eRZ!mM8*2U&uu z>f@lorGhn_LCKX^!}rmvu$!0e$Y&r}&uS;#jyd-@cO3}*Tpx)A>vP%JpFG#=Ctn#2ck|WfBZEfw=_Qu9*#rE@~>fNK^?OS(5 z@%2L7@L2TlNr+;KujNxw%1eRDH3uqJRriH#;;C_B>)cD( zm~Sg%X#y1|Sj^2bbAIB&`N`Rd$+^ktg{hgPnb}Jh=C95zUY}pOv3P0q;?-N1Z){#$ zi*Mc;)QIKg{YTgD9$noyxLkvl_1(pl-G$}di|fbBJ8!l2{-XEVKkWVBUypwBhllTe z^6Zit z0u7QXYSc!<#0UTY5CBO;K~#og!6aVeP7)6_h9XP6)M!$d&|&Z}0bp=XO9QjE6XKJ; zR>gy?h?p!aSFHPRR12fBZFq^a0OUG!Mei~@t0MrQ$4lKwqk}wA)&-t*#{OD7fShDK zSTj&dn@kw_HLf2EOM7FO9@eqh-DhGTjXVTl!HZHqizFK*Dcuo0x3!p+#RY6kLoRAO zlPKJxwu*A2fj!Nx$0hSMQEPjYd+&|f;uEWc2TowZ+x>bPj@6gKNG@9HmAinVkMxe4 z@7=sk(U--Jc}0E6yklYm?dv4o04;+g;(=C1T4Xa5ievbrJnz_LZx*Qx?U|8#Z3RUL zX)$@2y72V_*>l}mM{8Azz^h2qZ;$*Kq+C`9nMvRyO)nbT73=;u0yC_M94K<=s|z1_ zk*sBOIz=gzgwZ4Dnq&;;m`$0IBr^u6Nm^S$tklA=M9X5q zfN-)fa5sPoMp6<8sts#54AX8=;Gp01b&#@fbI6WH-{u+zoU!1%V$*qMLAnGO+>_;l zY05T|kPrn^ieQx(UG)enQ|mlK!#q+SieW^Il_LP5|!4D)o+t%ia;#fh`QTp zrRaFWJ?l`w9vF~Q07Ev$j34@X+Qs;$n$A^rsj?8Ehom5-pxBH~8|t0RbK>@rL^G_4 z5Tcdob`JDo263~_w=E077^F;PVAMNlxLjPrjCPVMJ(8$gP3$ymaZ#5Joi?uWLP7vO zFe<1^bAvSMfVcCMg8<=l`K=E6TED}5h{8K1Y18Vk2L`ajBZ;+L5jaQwzu>%36r!tr5Y%C0!$JapF`D|{Iu+8Z++48@)H)%($>Ze{R zV)ha?HhNh~D3vboUh z00ELP{cA+n&=g|E%s@*fdN4s92YNI)2%t-ESGyhYwYz&doGN>ddb^LrF2A}y*uM9pLG+hz?02@e*Tt9O5Jjuy$MDH15M^yVi8uh2 zag?%r(wf4mN262fwRG{4I_b|(oNe@|0I2-uvC1jv;zh*rU9iW0b^)l!$d0MKhAZc$ zbe*r)w5}S!7<8GwaIr=&GjmsF=daB#EiYZ!xUw4Gytliu^Z4fW)9ag$uG~4ie0%TG z`e<==xUf82THC*V|JAL*&#I@t+OKiTJAXKS{)uEx7vmWjFZlS_hI z=2oWWZ%r-U5nul0_JwQN?9KlC>OM;(Lv&apOl&MzEWj0C$EcosbuD|hbNt^&Km6?( zK`Hg{XX-C=QLE9}n*OSwK8i|rSzs>g+V$*;J&EeoPQ~Kf_F{6K-E$>j+PR^fhBAjy zNE%^0&~BD|sENIxQveK*R10j->XdCi0Y^I~6%#<>kxk%Okg()%NE-mz@z<^o)v4rw zf_bzMF!TdT<}4{4#z2AC(Gu})%PQf8{Xo#LKT2eJHnc?Xv3gO|OOpET5VO@ULOZ{& zWgrF6S^z?kep^40q10uj3bB;BVHb^er?4Wkom(6kOHL|F{VK?P(Iu~W38_oD*lMC> z^FGZt>LxY7z}U8m%o~`Nt{X)(pDihwGRzY*@IaVUSpz0oCbO;+cdf+0Kr^|)Iw-wL zG6ca9p5fr`PSKJUfb9rjksX&~Szhpt_y5pQ!|oIyNj;h_?W++5nrh5KiDX-?ECb65 zlRtzBeJR$)SmO|amQ-pY>%V^}dzLM|1Q3M zfW?F&g*sTqi4=P1Tb#nhE}xp)o+SVN=N&7(iJU zkt8)~_L|Vw!T(=gjY1Z#7{_lav~!H1AOX2yEWNRBoiM5QsfaoR&!~N+U|Wko5wag; z-!X<)IuZgkx1{i;WXhim6rYsb-p{+kyeBS~v~aM|i#7F2BZdh8h%^!}YRt3-Uh{v= zxB^{VnKX}J6b;Pxy1h{}sZn5r7Tx|Kzk*uXr|zrj9h68@RHg+f$r7T7BWK0HefDXt zrS&{oZ*<%r9e3*~AMl29veGD;++|n6l(Op^)dUZ_3joP3fl4``YsD@2HFVi!9B|&- zPkC0wfT70q=}Ah8nes!-7s^s{p`5>@Y&v)RHih6%31&_rz4Yw;_IaD>)YuR6w)hD zxdNcFu&`v2UlXqT`VK&)DFS;Luky|I`V(FCL>lM+WUS&xz_SR5<>iY2Mz|LtUZ#Lv zW-nh@xUq0){nCxCYqzrH&E4g#qnlfguWvrSa_9Kct;5B&-TCF=!pi9K#>3UlThZZP z4!-}J!*_l!J(3?ZOex?=j8UO+MM;&wv@l$&>0^N&jBb`E*3~HHkdMB2{LvTn-4A~9 z+x{DW+u3_>Wout`VuD*XrWS5Z&Mi+$`{X6|PGT(ry-W*w5$LjTei}K*b8Eu4EN#wQ z?p#ov*unhzA)reQP97RQX(E3zQ@*a_UQToMJc#XI%>1D^HnAwGY zadMMb(2~b`<`d7!&;oWV3$Uu%$!(|$_wK;tZQi_Tq|_IOD0%U z2E`OWi)~b|2@~?Hcu@Bu}7X+FmhP zc-ojI6gP99)ox_(1pulb2Sg@Vb))({*!Q9&276!(%}SHKvu#P~yD83z@jywX?h#Lm zj*12?lW`w3&_j&|0hq=IGcF7+7sPW}{Ff&R7(@xpn++tU;mG6(!PT_%_=tS4$`=6` zlFGH07P}t#)k$2JShiK}n5#%k)pU4$u#tR7tgZI_M13qMjITfTow6fr7(XpNbI%V` zZCM#3SR|3GBMGoHpU@DGcw*@gAkCurUF1(hxS=*ziQ*UPUGxS_(S8Z`)TH?Y#xsC=fK*mGCRAs|pCE!`+(Ox2CD!Umh^=_FnJ2J$LknZvEDt(&(|kvBx)Vz|Q?SwTEIVlD)PKTzX**=8?vzlmqTGuIqdDN= zPg6c?3l<~K(^AH)^ykaG3P}4joSLvr-VSxbSYQ|MOl+DrVPoi$SGO1fGwLS}tP*Je zK#$rjs~k}nLoF5rX_T(@G+~Suc}*NPprT9rPrT=mjrF`Tq7^P#&#PLv5y$Uo)huuz zL(J+&ufa*L#uC6JH8K&bQp1vZ_#PgCV3F-z^Q$NglDl_b8Y)wjmI*miid0OFQ{NqG zw8IL%bCkgBl~PM`=%Hb`6*xAcjm=v^Nmv(I1>IpPx9Mq-0()VH@KbPBSk^n~$6@dn z`s_yo(mZOvgP`PsD+U(rC zMSh(ER9Vu6!=FCyHCXeDd*^Pe1yq zK46VodawWW_TcU1`+G|_S{E+enYnm(X6cRui!H28)zD@3`ox8+6M|kA&rQxN>ms0K z7JSPUIu3aCnX3}G8j~%%JpVsQ92g zzA^i2$I2^Zo~~~r5V;Ln$(Rc7l{dP{jwu)83z~MiaQSDJXlw&|2O~IfBOX`#OrpYH zme5iktLRSOZZKwI4#~ReKk;)QSOsAM_lUGEQmF+1Q_#+Y{*#J`g>)P#8lnQL({X^L z9WRYS%z|PtKnP7*^PORFNw%eOzR#4fPGD4Wch5WhLfKm@Y*FVGTX$M~E-ETRghM`n zQ(qvZ`=<0>2ALXB3MCV4DAjAb3k6C-%{8XbD@R-ydRJm4RYbUq5U_!KMJyQVBXKzp zItu=2mx?!NuOl(dON9bW@HN2LvgG0#qEJvlxtCWx(yv0@7pafX1<4SwSnIcU%wVmB z47iZowr--CECB!j5CBO;K~#0B`9rE~T6$j!yad)dXB~f#9_2cRCE9)A-~^OHkbzm| ztXxY*^+aORd>U)Kl?OxV!X;QY-B|LF>7|3@25X*=67aK*eAT#9c+I6iSaI ziE6qKAeMw4`ignP9qsg>1WPaQ(_F!lM6bD25QV$==x-Q6%tLDoN6i>KXqNB^tTbX_jxI zO{LLssf0}1TuDex(C4Cpt~7)r10bbsYu8aZoY`{Ryrju*D93acz9Zfi-vT}^&PQ-m zK;60ePe36iCil`3My3LQ3(_Pp3xtT&sb9q=4kVdIC%v{_#(_mq+mdb300v}sc_J`j zkJ(yE*LS%izkWK-D7sCE31+g75hk(8x75U6Y9ukf?eY?XrIYoTKW4=yy%1#vBMST* z176es2_U+!O!w@&WXsFuJ^o8&SI)Osma%oJ^wdLwE|e$@2K^YJg?Jg|UWEXagPwU* z%IYs5o!h0gowe|OoiW~zLZfx9s42ouk7xSje(6(qDHot7Di0@BYEFL%DTv;T|U_kX?j_P;%N{`=$SpB&SX4-391zL*2{ zvvE?z%S^1&Bp?>8Xyom$#MI9IlaE9s_V9yG4&VK^!T0|@I{08^>)_&YG<$jTLXBG% zH_$$5xJ81+E}oxUG|&RF<@^Qs@)fsC8{Z5St3C)WJH|v@uHn%iXs_lg!qR`eZz2fOzb_lt`v}VxgPG ziZwm8xuY#E4q6-~ZXs+<&&P7Wz=TxW&UE2p0N$feGEGe^Ju1G?C@)1QTlLf{k}dT$ zLCr-_!8GJh3qy;}j^SQaxmnjfD@%*(@fX7UnT@q)8g>X(IjN2Nl_0 z@Y4t53b>S1t05@~8)eu5tPAms{9Pv>wY3x13-%Hzd944;v#)^j1dJ>2-=kuMLBlL| zgp><~j}V5z#8Rkk-aN$ZMp1pUeIk@&cyETK>ryu#0*>qwcsm3x73#b2EA$px)hH1! z#ZZCTb&ri|6XdAv1VtPcFvVU%Eh@43Sfhj$SClEu^e~o?Gs%(kWy!^mbu~p_!psyX zA}kZ@^iVj&n#;H;>ta=iXJ9@ES8_l&pBvaA=Z?{az79du* zpS!J<@FhdKVrz}uZbdGsQZgY82?1U=$*^OV;f$-KQh!AKJcOJyg-KRO8WNwipTH}6Vl^jlhWeV z0&T@lf=jvF;;+puMKAQIfF9wKjq?Ca9me2oHI-MXaE1OKs=_1+41+Szf=4iSEi$-E z#KNWN$&NPwHs~SI;A7M#YCJLfOf*ExflA2TO6(UAYi&haD;HKOI?H1Kl|=hhlzGq$ zF$w$Ht!jKyF)?gZsFP$58Dii-x~|ufA2AlJ5*<{HI`|{GZ@tLIP(M}@_OOrySfw;Y znX1d6$a0iIHw$rvCVi<9WjB|#m#aE!AMA&G3llu{ku3*=E7+oZi1p2*Rx+RtjrOXM zNiJ9gDzD*TwRh$Iq?o%^Gf5SRGy2e0;a} ze)jZlc7Oca2S5G8L*ZLKdom{dY5*0v{rMA5*#bV%jl!rXN_nb3lHG9ot7jj7_3Fox z8eDIA^uee5KmO;={s-{4Y|kvN&n#`sT)Z>0cv~nJ$1PJeZdriKWlj*w#H{#RBnSE0 zRE=9Cg6xjqmJo$Jvfjz#g>}IyXLcImnO0EOaOSQ&ysHuLRZ(8Wu`Jff-b0qgVuIDz zp&oPwc0tWWRwNljWKrT?EGVU(;fD26_Cs4ifK_-910HIumn+$-H*0k)56Z67)8cHz zizStjY$3VfjqLDsbnLlAxMttL7)49lRJzuZF(5d>6VYL}(PgRik)ae)Qn?CGj%r1f z^`E%hh@*Me+T3Zl=W95m&{Fl{`n}mj4^=Rp4;wtnuyrNGJ zG&=8s?#Qh6N-1!JCHp}&jyIxotjRG1Ls^Oxr8~)qq2DgU`@InJRY4V&kyqhLnFAIV zX;R5D{v>rnylTL5myl@)FWT-MsCd6*laz4NIavz=s&y>wlrr~H|bXcInYYU9M3`($2o2DvBc&?kD3*VS?F!_hD4`aEFeL!<^Pf;^mBAvPnp}#S(%3=E*ixX9nV((_^j_0mvQfV89h9I=`f8wO zSG^vm=P1QPdp(#~vw+Es;Mbs_m);&NC1hH1+n+X)5`GV}*Y9^F6HI6m`dEtYJ}>kj zOj*DQ;L1M2#FU>Yu^1`F*e>Nj)O>=dV##zKfkheBaHSaRm1e9bbdl7r?k;E$^{8m^ zJiI70y0FCs9VrLoMi?;+3guAT^YEYG#-W<&J#LsyDQHd6-g+K^&j__DC1i5L{YRty z$2D9T9z3ak|KGiRr@gT;ynp-0z0OD{DmPC6 zl}nc{PfX67%BcLArhfew0F_g43qi|wv#)%M*6SoV`39geG4|7!QZW-RSX`!bo#zU; z1#8xqV3-S+E-bBHT#2u5J-puj-j(e)F5Z3WxJ8WYE2GPIAFic8jE?_m@a8`szWwhH zpMU!3{Th}i6j6`LM_)9ElrI|P@eLD;_ArJWo+3L~;)8$w6>nh4j|8{8{V&nM`&U-u z*-N))mTtkCAIO%;3)f`fMgzdi*BIvf^rgw!8#TO{S-LZG@!rfu@#W7-@8n=^b?*$# z36jbcWmpX9QqljmSG?0kVwc-3Xe$&Zwv2u;T@4u(K$Jv%9mFk?$s6@*l#(REq7bME zRuP~=aaYjN&{9H>LyHI>i)QEuj*%Va>JqD1**q_+hcXjsnM_bLsffiRgL!csCA2O zD#Ed>Ed_qa2MabBV=96RU#{+#z)__;;E7(?cF(uT+9WHFVbDZlJ756JaYz+wt5i&% z>~=tCtf$l}NrUCV+|n`d){2n~hB#)r=e6vG82|z6DriB+ixdU46O;vs{wfFEF(lK1 z56r+6i~}0r%GPYP!aj14EN1A}i8WwT3RE!58K1pq2K;dI) ze24%Q7S*$r#Ty=tU?G1}c$JE#4@aJ2Y`19~x1yDLn%ru2=%&y*3z918j?e%x^D~eP zNpP7ti&Q0L!D2NgaW#=RK$5mOyoqOOEYUXTqG`oU8|%dAIbmH!$`o2sC8S4$SE&ce zSR`R$K1tcH0-6QA^}>W8Uzn%Svg*bT;1K*LQ5%mNVY+p=(uu|l^pYi2bm>d6S0I7; zq$JDXEin!QT;qhKUG;D1&Z(=Ow0m9|6|0n_McEnXEckdsuZ!L9t9SF z3RV`;G9EFStgwdoQkzUvhy~&Z+?N-;xEf8v+zgqxorbB(sK+ry6TzFMgb|$3t<;E8 zFlQ8XfP`Ll=6su?l&@Q*JP#dH;4##oqM(H_T&X#oN`-3tB-igU;`Qvq39k!H@T!3% zJ7EM1GndK32;vp8B0|xsG8jfrq2#ADD#nSO4E`1YHruV}{&t7-OVjS+vBgAUNm>62 zs+u}Xwz8lC1^tPFI#4h#{*Z3UMik<3BTrnfm6}FNafNg>4)k`W2SGXlIbom_5i$+j z6R%w>3T*nc(er``o@^PgiXH?YM-3_>41+ag2ok1owFQ@28g2L5ZBIQIG-F+YA9Lz! zS&BN~g}B(Ya{3VsIb3p(9)XI=$;hq%=RAP*MOz&MgM_CnejdoZvfd~+#DWO5z65zO zOsu!3s;xl@-u(ao5CBO;K~&yB-rY;honIi}OI`2g&d*A1tS_w0k%p7pBw-+G;JNSb z2Q*Tn6!`T~wDN#aV9j`9R@C<-#aJNC?OS(RcklM@-+2<?d=G^AfI z;Zl}gAX1ha5asm9uQ8zFB`Ql7FHKBNp9WOU{=WfHUItr&NbFA`SWe|qUT#x4iCbR8 zFfZ=*QagSF!%Ux8Oc=ZUtHU4vuEs5oKM-U6lQBy@vy9QcIS)^VYC_G>vqiU6q+A+{g?w{-s%L&eoJG8S*f8l;k1LRQY)h~ zM1YEMCiQQnQIrg{-UK09YZMc9dG_s{~Gg zN@a$4m4VUOQtu$$lv4T9hhnYE$$JYB%dhK)J`j)qJIW)~K(V4qN&tpcRT9Z;w}Ez) zlqd|OsV>W~TyE9x-HwNeg+3W2D7FnC8FwT=MbpNJ@gmK$Zg%qMl&?as;90UFl&Wpo zGyA+%PW1V1swD%?(Q3$8gSpV6(|5nS+fjBbW59cCh)Hy}Xk6l@F#}$)(o8=KA14K| z^(>OrXia*};sSrR+3F~u)csLiYPG?+Nli)=m+F!)&=dyT0tC{7afL-L?1k(B&j@1i ztgskyVPH;jH`B9*1qooP@*<g4%}6K|N|?R{nZ@XDvDo5Pz$Yn7%E2D6 zg55)+NSkP=vF5#k+)$lQN2OEj8$qjvxfHA}UHJ{Idp9ruY#?BOc}?s~NlU8-(Vmcc z&l-W0)qtiHRez=6QnGGldax`DsxWcAkNY=ZI8h*EQZ9lbY>)F!Lk7n4wEo130yZOO zuur0aW|Sv6ZxGx3O*4s62ar_3COXpJH?&^P!BHQdAM6KgnFrqze3yF?Ow+*BD4 zPQ%KjP|Fvo&&P{jny!5a{OY6CzbB)XW5QVFWYW9hG!88#3UcJMV=JHx;x|4iCWC42 za2B)oF!2mxay2OgW(`w*BNadZWGk}N=G@Hm0&(GAN*(0_TYAGOVKd%YpwL!mfJqeL zKpVytX;)*V6iPBlT%$s) zYrYO8h=?Ur?p3fn9@q)ws>ajtK?P!U4HYPDLYshjf^iaFhZM1Pe|Tl zAO}%UBgISUT*jT#nqo9BfQqs)3RGeM7538`fH6V_UYg7*BO};axI>&-5;0H7j;YaM z1XM-v+1EcUsu68|^>%2z+^llQ;A3G|q_bt;dmiPtu}a-W8K9JwLU!>jy(kg@tqEhs zme?Zd08*0tF=agV6>QWGR3b5~0aUQVVcyjgFU2;LhoN#_4>Ay}Qez?Ys9>?)u`(fC zjsnWf&7C{9Zs+&!9=GrQxZHk}?yRlzOn ztt%V78#_;~x4w5}>-8n!TaM>f_ve;}vp4#4HwQ~=2RC=V*WUXJ$rJm@?~k8<3Jd-h zPa5`>;ILPd{TGj6R#9HXP>L+$Se||a^dbfGvV-)>|NJX{t@nBO!6$n^{!MiF;p+Cm zrImQ@3j8fg8&eBwk|#EMLw)%*Xqmnw78k)SbAnsW&z@Plcy(p{?!E13yPkVwtW=0n zW0kG8$%;5Ws_j0v2-j|;g^X$-iLz0Q+LLj)mlT`G4amAu?9gOL1hgc^LgYiVD2^;N z7QPf{{W;*8izCtN<#cNxG0fCor4QV}z7pwRM>`7FmOQo0=l&gpaamV=<>*V`lja4p zhklfV(-Q=hKo4dvf7OGbgt73ldA|(bFp-DBSAm2?8y5;=D3sE;;gkh0Dnd)lp>B-O zwqXFZq=VBS%S3#l&71UlG@EcV5?UK?=djnvB6SIC#mRgcBGR~xH%_m)Vo+$GJj)Dvr z;cMFx$%6kR>00iYrWkh==NKWARzkU?C2wrQ)ZVJ+8wp~r+TJ8X?hB9rZ)gr6B#1}| zC8CQ6#EaFfppjqz(4wL*{H5>5#+0?j&O2UHMo<)$}qvD*2Tj$#iOww*# zMQ*D+EJNd@cB-`0fi08>J6OzuVjcjM{Xh@K;y+gGCG3gsGE)oAfuvbv&N0ziMDoCm zaDv1riRUF*RW-CA4MLv@y(<<4#;fBZ{YsrPDSCN5#1Nv05e3>XK{#?3L={&8X$t+R zP2nD9mCu6e2b_C&u?wZFh=zn1#`joj;M-Pi=#}u6FIPi3yPQx{Vp zRf^DjV(=zz_Ee_1w>Ktg|0tQb0DTvMR+;9dgnGds&Nya;H#E)1sk-C?fOLJGB4mGG z7?vqz79Xi_lScO9R1j4YiGAl^sdBF%g$VpB%sv!&Y&?5$#C65{7 z;|fj6qN$z9S`>^)T@o5s33Y^}LC|m}%_>%AkCfzyO0&GDX9YOn{0Y$rxd?NB^wntI zaMEjhuob6d+9+bxhh`x0fG<;sX=x)sg4*YV$EbFx!a}CjE^6t~CBn2~HPF}7zgYYM z(ooWZs4;nH>oZaJOV(9}3enG4rTq3ovWNvA^ZE7zHN<>mu+zj#sbprzlj85myci-k z?2vQ{Q^h0}4B^EZc?=H>o94Zbk~l;wRuB=E_tbX6xMA6cMJtem75%0=WzVsPFLEiw z7;u=d;Sm*$xIrmbIn+WG11*P)CoW-{m%Bjy*8e8h z^2$lXa`vSV<(sj} zExUgI_(uDC*LJ>tW%IR*cOEa^da$s%H@`fbXP(&Rv(3SW-8X*4Jh6xGvn1ZFDZnRg zyZ`75wXZ<&_2i>*&|*I#uhN7x&p@`kD$wQY`e+Al|8p$3Wq z942>dwMg24;*i^k@Hysj-?lTiai8c5M)@}Hl9K<~a_|t8TDfm&n@1djGJ|*Pzo~<^ zmykpr+N+Kc&(v}Q5@NObJ5gaGv7$+abW%#Z`qpZq1vq>PXn;wSa}vG(~`j+xm`5^G;kjUuh^4O9~cprWwH zSe>S8#dFmFu4;%zll_hvdACGNqNpP@u2|$3;76)WDIM0QsCNYes19xFu8vu4 zZbgtg5ix_i?K|pbp^HPVC=-6vCwwYLugy3qQi4+?*307JM2>WN;XCf=rF80!4fJmnz_0zjn zinFzPDl#ntaba8iI=Bg5X*A8DswgBwepJzvsA(h@8MDaBE5Y6*I4cI}Ms`ZURFi5J zToewaG|Ntu5DP_tHgU&1I5+xL084<74V)k+ns_5bkh$V3EqOAOEXpgRx7nD(2Ha~tSV=6S5xa-6P@c{72 zuIa3x%c2bfC=l7Q##7hbRCKq&!a*z z#*`HhdO5h&8NHhctiZb!;OO~<4f`MA0%Y}zJ03tf* zjgIz?U*Fu^-dJBx?%g?T-TiT~{Y2=LTc;8!-wsq>AX2`Q?yKpfzea<~^{dw;o_PAg zT#Zs3sK`R?;|oCL3>zND*8g8{%L%OVZ4^oqkc=;R5zbGYwx&-kh`YWSv@|G}^Dm=Z z-0pJW{N()P%*C0-<)s^2H|~wr;x|^3A6{?2b$RpErP~kZ*TA=kzh!uF<7hSdL45q* zcYpA&$3Ok#(FXu0BuqYPKoQLZ`$`jyKWc)OuLEfLxM5}aIj{=+EwBFKYguGnKK}CY zhoA2L;Mc9u+gI1q`O6#A3(M1s>-4wOxaCX@QHWJ0CZ~K&PS4COEUm2H*%EzGrB}*R z527u@qzikxb{YbQ0ITdlzce&h;#w)Kaje0N_Qz{~qDB%kK#Ul`E7-+_(#|Q?3Ntz+ zXFDBc6X)$t!DL@(=c|emz6wP&0?d;QE^d4 zVg~}VBp5;(lP)2L@*7}3z)qy*KcQZWKP78{6_AWF32Gd&R!629>{v>#7ihNK+8%jL zducY3+^7%HjFB-DD3WVx7AslQrDGkA0e%=Gfo$Ktn_KkN=4-2Jv_^bz$|d2&#?@5L z%XO?vk!K4S(`Sjwwiv-nbNrX)9WtPzFK_0yljh5Au9cJrX(70In>hsI@~~zGLpykQ zV5VmR7`-bN2u7|-qma4)01yC4L_t)kdG{8XMiOPP^jM8Osu=Yoa{?9WwW8ox5ulPm z+@*oV`GD$e6*J__2cs}d{)(G4T!dR57#dSD&L);bdsGlc%nrwVheh=r9hALFr71yi zKRr#xrm)YNA{kykrkjORU`(5}jw|Zu7kj#`b!5mL2M$GO7by;Jr zj%EwVIFD+^7YrvwIUv~*9P_$L0u}V1B)mJHNOQooB9SB#C{pF3AL~PAhA=UOLj@6z zks;pNVbUqLxER=@$PI3a3Kz&rEsz0J0GkBsOG+aL96^$f+o5=$a3JwMGe zd;v4m0^W)k$SbulF^v9xVr@^FmN{My?v?PCxRn_AaXpxs{>_5o0yCZ6)51@WY>#CC zlo~dj277&5<$|Uki_@)kbbqI{-Hw`lpt?dNL&!5QffkE>5`t&Pe)W^uu3uq5)0Vw8 z8iWP;mX>4cTb8M?3ZF&-6$@^r?;`J5Q4B)@m~;;L)IQ_;MA%Q8bgJjBBeRHTM_hr3sXaCK0EVMHP1)%$O=nyG`&4 zP=Km{#{D!HN0l6=pCZIW*i4 zV6Pt)UCTu*_-jz;9hTO^a8EZ2%<&RiVi^E%(O8j7H&G7^j!+ETb6+Cuc+GZ>?vW8&BfbBt#i|}pKoM`F_kXY0`m;YV$oRhmw?HBG zP4y|Kk}3h5d^1*IrM)lO=wFUlP8W%NgMMsWT_%q>bAEDmVtQ$2;pXD?`&T!5H`~va zlOJ4by?J@_wWWj_OEZfy1o1H;H`f?dj84d4?cftt`)E)gezY> z1SE+%DpcEn&!!57=;*a${$IpL1`oTZ6_nzPCJY2YTPk@T8 z>4~vrpk-=iW_ID)&9!@5(T;^Cbv&TSyyHqZ&}*T^?N*0EZlr)>3XShc0jwf$g;FtP zT_~W^P8IXIpc|x-oU2ZHC1D~IR(KRxJ-d7K6K}xdF6t&fpf$%c7I|P*Fe7%PFo?|M zUX214xcuSWZ&@I6Bg@PDFh-K>SYJxVuG2SAR@F2-_@Hd9g}L)B4tvZUo;y%6s-bHI z?UhaZfjkN6Sh1?Sc=t9N6}62qZwFajMyp9M*@pu7%Gweo+X@8Qz|#m=3b_-q*Ba18 zIwfr>pekF+gjhU7V28RE3DAR^ALeyaXL;>o+aXiT^sSyE#>h{&B)Xs+j)aLxvw|1J zQ@reGr7|sEVd;>@ZVRK>aINS@yhM@qKLUGGqoox$hzSN(>8Pm&sxZcXDTN}!38E3I z!qk+Ks&_>$Jtpd2RcQqE6}$_sEJkMG^=A53hX$wE8|d3;@8(r!j!oNb9k@GovEM8! zrME_K(L+p4mm2`237(^#Q7ba5#iE~y0Vg;JS|NI-V8i8Ju>+Wx>K&&w$aRqJkipLU z_hedXT!FUrKI>C5UIv!0qU25(~ic93t^{L&V)~pPe+IvZCTN5F^wV$j+OTC6lvlhc^k%Z@@~o{V)j z=F`xN`fk@ww2-k-(~){zJl9wqGH!A45*U@4HFCQAA^0>S`&`jLqG+n|ksvB_Q&Ssf z4y%2v33A@g7`aL?x74+qAcQ#65Q}~iS_-!1hGlc7%mUU3V+|SO>7j+jF*ZKoO)NJa zHc*)JM8k!t$P&27*H-Dr9FLoX*y3=UlGh8!;dtJ$gRA;~bnKCO>LV&OYAh=()VON4 zplD+W0SVy>USwB!5sm1Xb4)iAd|LF_&loF%q(*%6yZeO???36&LvQJV?$I}LLkJD^ zmhhJbW&=j3bwT9j-dk@9w7z2%l$L0viFTWqCXGgUHP*YBs-EwWFCtcxzGiu`6jA|3 zN$u7ybBdMlDOItSDW9W88U&f>w>4HNy7a#9o9K&*xDmQg&=d)*!Z6|tiZA2{v!4-2 zNwCex)twiDFRg1nC>MwWaVY}WO ze*8rZR4zv!+(BwmBa%81Ae;^HArLQ+-=J(vKMVuVbj zVd5LjvJ&gpfqCn-B;GyN?gfW+q}L>BJhNDA_puZ>>Sg%YDjLyDpO_5>@Md6fUJg`5 z>y>09geLbITCc9%Yrw{g?N)-`3uOgA#}^Q z2))sw|Zo$dj#G+B<8_rOSl;Yie9=!n!HW4iXG2&LOJlkiN;(a~)fW0b=ISjx=z zQdr7XwMrd0;U`QyRJz94xH>DrC1UtAg>wFd9xm5;5b*n0l$ z0pUu=YjgDN;9V*^xbaJ|OT|hHFD{ya#mbyS3^-=~fl9ng$JO-Zi?#}XeKnO>j4pB6 z4m&eM&bb%a&eShNf%S}9!L>{UCj-wcmaBsj=0;a~{(n>VroWM9S$bbpW^!M0-KiH4~!;k=3^2|T&d(QKmbMG4w%&hLN z9>Ikd2_`cb3=_Kev^ZGq=CS0!%2S>4~XgK(|Q%)n$ zA_T%m+4(qEk*XMt3lk_PgTSVx!5*&HBi}Ni^{dRR(HK8<@az?^U@_#4OVjH56v3Be z&B%m=oXQE*2_pw>J17MVIt#RP=?e{(b?H32Z}rNpTk>)=7n`gBiNGfm5rt1O9y5~* z7S_Ji7lRL(+s#tym|4NsMf?zjnUb52?A!qXe*XZxE;EGJhYlW9K5idURX53D> zM!Frp^|ZYOSH5Lcv9%KSiXWg6$SJL=EA~h7r@h?_*Hmi0WZ`js_2c!ozgf($j$gg{ z=H%qVH{X29&;O>+-fD(ENvAB6$=_;x)}Y77psbjo4y22o+XqbzAu8xvxg5K zYd~cyl~eS9j`8xtTX(jESX3~p@rPC9!@q>G70=P zytej!(Z#~zgkClcUA~al-@IiobK@m9Q*PN}F8}V+506f}=cAurm3<312z4TupUsMg< zUvz@0y22#A7{Cyqmvg6|dht?vj3vkj<1{DO%(9%r_8HX@Dmj*ip?!*M(fk@n{W4Y$z_V=GY8c8b>j5$o0|E>xEQ_K6ut1=;2;p36q|*x)1*f zURUzcm%O3FJBzUc3HjDa>LAD@_ASDR?Wo`t!SJ4Q>}F0XZ6cN-OKKwq8{-HbEoO!A zv5F|-_|h@^jy1swKgc7ErMaQg#QdC0(k{&B9aHsY$(X7*BheHb*Tz{*=NJ?AV&m4~ z7rZpv@bF~r5zQ#qdNmWS0k$|lL21!Dbh`^?>^cwZA{MNqv4E{hYDRTSp%$as47(*b z`lD_*9KIW=-;tIcc|(5{$T4D3vz8=S04>S#V5JL;ttL-;*+QXC(nH*+^e|`jU;0O? zdjx1{!zXIy4O_o-;Zcldv6#5hD|{{CfoZy+#mG889fl);ANsb81D3t^0kqzstX{IF z)aoQu!G=dZwzdF3)I@|P?3OfMhUUG-sx0|(yxfcf?Uqp2>S4($H2u8NJrnYSM&30U zqF)+qNWBdgT71QZS*$3GW9M*ljj{suz62XFo>5ofr#3Sf$I+E!*ackCeug!7PZoKx zn6n(J-pnB912~jNQcwX&rb9Xa5`(mNpU&${5B`&T3jLFA8W={ub2LxrCT?WEy3IhfCmwd=-4!! z0nFo-T!`aqwtilD+N7NgKZ;ddXdEuBu*=JZt;Tmz`pkT{z+GY3(FuneDY2oe%MsZp z`OG|NqmP`^D5We$*_=M?_OP5{UV@ zu=8jH0@*}7U9$EFAkhrH;tJ^{+?64XP>qJ^7gs-i{QfV`-o1NuB%m^W`{u9v{4zT` zIX-!H5<%tV4WM$gUQ`L7vL>MNVoj1hHGMsk>*+I4iZ7@HP`P*i;i{YhA5=h;JKOg* z_nvO-KEAVk|IX&Y?K?Xvv)p#Cmjlc&weq#^(yv>lzxC&vXZbR=z$GT!!m!E!R}54( zwCslSY^Y0^=ohg<*ps*E!+E#_aB}< zJg%Rg|NLU|yC>(rd+_!z?wx)wePTzK2QL@0-}2_Clj6TGzWZM;e)oTV`>+1vkN?Ae z{`uej7i|MmX!(0b5uq2cftiHDoc-Vb<8S{y!4-Q$L1jzjlx?8MGL1w~*|uxo4O5=k z-rL;3U zo~e`pOI2=aiEci2dYacUN)7QFpJ?h`cOhk;L_J`TYZPf_I$APAU;B5EPuZHLI_(2Mnx#csan63Q8Ej5CJ;OW}?o%b83w6Gs z@tInIhB~;~Zk8|s0F@UmR7(-PC=q8mUq=BH>FK|=u3^o@r`z_JVl+FJmr!R`X@GA8 zs*M3KZfrZVaY>KgI4W3YKt0O6=b%-5nrG3<)v?`p=mc|WQgp!su56q>_Bv2qQGo^f zkN8AFLDFc~xqRfEV3D%o9*a5~t0@*Il}ju6{_3#fg!)Nu3JL1GXaziS82&&soKAmawS+&C!>t9$~|C+%{Zv9}kAJ39~c7`&_DcQV>dadUHPWF>R!9j#ftHFr(<|B)v^)FOo~Nc{iBmN0syk~x&X+ub}m zyDI!WV5L~dmL!|>=tLHX^1iQz)Zqe4Mf<43&%(NZ=RW3W6R~{#0xaQeV7^<=^%c&N z#7Bkq!|o+RkVY&v1i2_}(lO~DTfkQqum=rYqM2NLH(z`^pUVYGf%OZN0w-9Hy_D(4 zme)1nG!&p%Gq6CWUn+?;Cu5QhlY!_dMofjx!cH<%ojrkIGQIX(3Q4BF415w;MOukf zMFEWnv@AZFeajV+Z#8`K2~r8(RyaXS<)c!{C(O2lU;72`N=2cM^Q#}H^XrpJe&8Xa@oZKL)ENdw*1EK&_UJQL-%b)^E;Tue+gkKNu-rLyR z+K6(>hF;+We1~Uyks~&4hjZ}G!*B(332#d(FuVRAC-~HFGD5uD^HxyOKAzOpXZn>T6Fv~aC**&;-|MAJ|58()yGI96kAnC|oNR!iy1gn@ zeqp$R0dWRPH0{7@0pH4mT%*pe3zu#{G83-dOF+U3wYXVnEdtm-=@3mB=Pr^ou4ecI zN|dc~U(3;FTXtY(iQ~)U2sUWZ`6g_T)gJ^87%PI63knH7Hsgo0j4K&G>0Nu_EfK7@ zEl~q7n&pz(VnR#2L)AvcR;Z22W;7Of1jXyZq?kL43e`kydzrP~DVk#5UL4q@AeMT? z%CE6OOA^o*@uyS=SB_13EhZLMcMWKtLl}wC6#2*U!gio%5M*>X1$AM)xz7=9nU@ue z;B4`twZ!`HUkAtXg!1p#;pc^=_4!WImjydW$Cb`3f{n{8sG> zFIHKZ3kz%zjy{z%bcNdxAqX3Iy9Ywx0^~p&5j&P{%nOYWOXLH?4Z3g+Gku1vQZrRw ztL&5F3VmD+Fb2ehj^iY(l2?*)(OWjwT4s4=k_tJUAzh9^M+wV6cRhHXm77RWaDZ7Vca7k?>IzUvz2h z@i5muMon-<;~6$9Rimp3HWoVKFpG*O{ygaMz2#3@X8SWRgJHOHQhqqk&PEwbToY}L zE0G(?teDv3sS*`!o&W*5)Ul#XK9|K>Ak=V*Cq7XCEh<7|-O6NEN5eBHbhc_^?P7+1 zQU_jWz{|ZHxyy+q93#^4$KKBO2~%0Tgq&{zOJU_&aD35TRV~90)DnKW+QacgWmqv; zMx1!D@ZthkML!F@Uf4=P6PiWO4G%=zxWJxCX~F$y9;=hUJ;j{4iYIf+z|1i@@ArmM zF9hgK?O8Z8xS1`t#9f(KzbY1XUBPxGg=`nAPp?WZgpX^!z1D%n;Kz+L6bc1m%%YksBk6a$#@+ z!9>$3{TZ00VljMzxwI@yZ1){I9{E6bNc#+I-89tr(iopz~faS zmB=-ul$YU(?_VpVB-a-~<(c`uo;s-P?H{Ic3R<%SR5+rt;_M2?`i-59?Ry)$4>orn zZtgw`zi#YEL9OnW%c{ohjool)PKB3cw^)iTsn~McB$q!j-|{D^H2R2chc7QY8v-hd zD4XHh-cKN8Ke>i{vU3=(?cp_ee+n~17h1(Ulpj06mWV~yV{ZA3%qp}DejCLWftDxx z5039WKY#Lis@(F=pHF`G-LsuOf0du59lvxxY4VV3qBi{q602dBd(1)nzBV zy?=1;*^A?MAM%WIcr#@2QL&{)7af$~(M=s$vQm%YuL~Pf_xMMLswjvdG%|U~tXJro z6i~>{iMYr78D zJuWqmvC&JH=7|=_u-c5HkjA+Vs&Gn04q+{iXfZ`C7f)Jx&W4>VqeZP|EvzP0e8Yai zE~%IXkup#S8jUI&P(u7qDU9?ude(f6w#R-v=J2?A;(>U*+IG=TF9U(J{i4PT$(U}` z0cw^n2ind%aLuW-Bf{6U(H%I?XKN(sOPSZDaOz z+59E46H*-(?h3J-aD$wgqS&JAUMYM>^vv27+&}4k#vY=IU2PU?f4e*ZkKA?@tpUtZ zC2TY{x00d={uDBcscHf|Sh#1Smwha6&tb~i+peOu43?^@N$RaSZ{W37H~vv+)eD*a zqn&89C^6w#%o-!-ckw#`R2ZqZZ7`^4=(`o@W9N2sc3WaAC z2t*%wZrhPo(c9rA23rw96EUO-lT*B0Sf!QRAO~&5Sad-*pxWm|6+~to7n19#q05u> zuB%vonyN8ek>`F3-We#RocHolv2v`G)8ZZ zFHfgDe)2?qef(s5XOD7!L_;nbaU^C z$S+%m1}%5i5lgCUtmrLEKCypdy#hVaJT*3z}LUM(>W*ftHhZXL(=@4 zp$2!vut(2{l7v9Up532#r7w+7U_aAFT7LrFPgSU#67SY!}(9UykWRtbPgjTiPf@`&I{ zMukLh!4aD3!K;l3RM?@hj8O? zpQVZ?6$B7{!B|HdkuXs~Bd|mT8&$vndW}%hg^YePwa?P=j4Q{ornK_061Tyn3{zQ} z1$*{#O9dgVT2|sB0?7l0+G9#f?VB(**udH6u&oyN!W7R`3@uQb5c?a@g}j@Hr4=^&~VlH-IkVVae|^z zt#qVuHt^@99Fki8QI7yP2I%?7l-d4s?sbr3<3+~p;X#f}+%%EDx)n8E_Y15R;oP}W2 zJrG@ExIdcNd?r#Al{PM}Xo;{4m0at~G_lT@dHQPVWCFJv>bY>}S8}sidMzC;trHn! zh22SLER<8Ct`a_Ps;*oiQg4ZWzFip+w`_H3UMyZPs+m`vg7>W#)76FxbgS$g|84`=x z!|qt7CGmza#4=2?e34S(mGp^4ZrR$szax6ftNW_Ayd3}LW%j%0z|Lezp^Pi=~SOk`T{O!O0$FKJG4gx(0tL!9< zk@SGAWK{N+ab@{W9t`&*90-p4NN$K4;EN?@6Pg<8fOe+mTxqOuPBr^etl^3Y2R$z5 z##b1A$@$3PE1M!Sld+>L4#jXUvW%4C-OADyRiHeL113o{&r zY?CYPv+7I*nz^@XV$E0HBTO3@qZ%5FsaeX*fDMVLQHjR4M>^k0#6Ze~k)Fm!8#7qH z005?zoX1QHE1L(FGo5fTi<(zCTbM*-kXhVXDY|rw#k2dImob6m+~pUsz#*feoz9|} zw1zhnRdje>?n)jczEYJ&G3A03 zN-Pw@ISemXJ4BMTp*kRYKKHBEI8QYyKt zw#3WKzyOFIc)i?PhP5lK;3XfPYiL_%Qo>i7OT#Zf!gd3#fVlHSjX(%#ij$EIU#^bI zPh<6E3Ld3}QYzn6nIr>?a=MMxx15pjNqD?+4;z(L{WjQ6iAQHG;6fvoy&kU)G%h?g z+(|TeC~UFbS;8%3CKTATJRt|hGl{Yw4}(t^XXfLMO6)Htt5{1iVl%mqb!PZkioD_W zLU`d~n5XbRsyty&p3(1}eSaxQIgt2q-<)6^g3Q=qz{pUtD9Y(fpP+8~1>Ab`7(m6oQm80#kUm5GaS06v|3}N6Xlow>Zzw8E$KdmrPT^Bsa%g1^ z?N`Bxff?UmfmSRq(D_;aS`+OH!>0?Q8u@KI#Zt-zHz=VLF^+Fk21cTDOh2l>k`Wb2 z>T`QuyT87hU;Ho&2Ysvm5->m}UNGze^C>EZET~$0c10~)D$884$XISOL+6-!z{L9H z<;Cj1`iS908pFUX%&uJ5swu^)Vo`oR4O!(Q?m?O4TWvA^s1bZ4li|EQct$DZ=kUW5 zSVYO>X5ruEBg33#WlL4i;oMOH&_I*WAf$!@{xE?%w z6b^ePr!0X=3Il7~y#}qsD<&@CmT=DAI(WAG;LYCS^SvjNy(d|?b{~zl@4pdl*}8X2 z()pWdzGY=cGWChw_K`=uej&FEL2Fgd`D3{ye1RgiY=?vXA-P3xYUk zpa1Uh+rM~l^26PuPlqqB509?ypMLv%^v}+({(kYh|L~Pqwsv?qfvAsb|8r`|DzvQJ zU-YD>Z_&Adc!_^U-jKi55eZ+FC$Cfz{xK=rpS#Ow+ zY~Tg>bdXW)Qpl0dFbz5Om+jM;b6ANyx~*(eXrY-fl|2mY2fLuD%-W3=NM?nF`{@Dm zofZkP%qIDCVsU@ldtqh0~(ZOFECIkq-1{Q^6$ zT{iC)Acs>kWBCVFLoZT4Kv~8YUHq%|&M}ZuoMc7O7P575j(=2E@M#Zq6 zS7;SOb|u^`hY|F(NwJ;qh1-^Os6{hQ0w*Z@@)*5G>bc=4$H&%p-*C77DlZPBInG|0 zyxOfw1twE8`++(y7dKj$Q+U>wRDAG8B@KFtBwo`%9Fr}|TO=y||&(*(#x~k&b zmQvzM3_vBXGak*G%o>M%JYHVr$9WecEqOIqcx=47*EjD=x|Jw{aJ}B<7ZFLSZNrgv zCRalAmK_56*rA8OG8mYrU{YC_zMWXmr7*vlOEK~xfCuiAc!^w{dESHL@k9tHDu}gY znB7byDNn{{T2gEZ11s0;xowtMC9{cl!cSJ&wvV2$+1bS;55Yj6f7!9gxeMvvAYhEP z1J82pCCgYS9D9~w{L4&WfzM3{0o`nAX@^P7CmMc7Qe zBMXK5E5u!_OicV;r56A&PNVXOiXo|ZQff7shE=ZM7VEH!GDYJsEnc;n&=}6K!N(@H ziYff)o%aw=beSF>+eM}2BTL4#XRPkEZ7dOFv_tH>+0~D;>z`h|IzA3ua(sGva@vp2 z&)>X@a>`mzd3lsF$q-bI)`1FJiC=sUR1#M4zOSdGlyKd<|3G&hS3u?Vo#gcYnI__! zL1igEB?R(#B?!UOR{qXkY!L4Dp*nlol zXo&~$Rc=|!AyE`s5p(_&Zt+CEn#97f{048>091AjX(F_&^@F7)V|!)^+rIOKamkce z?yOv2vWmqL=WUZ*9_-&cdhmSo;@!vN{GY$d|K;=1zj*xauOEK%n+K;qKyUfV`@Y=D z#lQ(HVTmhj+kRc+6I=Cwd3)FjtE9N1*Uqp;OeQ7|?>#&|dHdnKOtwU{>q&>kxhs+g z9b?-i^hrMJ(rrdeoA9XZ7BXK^1eutwf`VZDX$5e4fBq>mC!(V^b44iO_W`YoT6q`6}wMxXYNQuOvr)Ap`b}gz2HcSpUzQJ8PO_NfY)6oWME5 z#JpX2kK4&SOrLX+ur`~SS#I>m$dVB_6f((f>K7Z0O(SNMB6RlP+~}=v??n0deMr8H;`=c3*#^z-JaMN?mB=~ z8&@=*Y52s>$MOWq;aD4xU23ogW}sruCG{5}ZLbmYT=N(NWU;DG^>)1N9q$RjDN``Dj4{a$nhp{R|mcV29OU^-&=W}<^> z4bdPx52e;X8#mT4HqmMnMHv5ur=;)=Pl^3FSw}x7>n~k^rU!{nNhx@s6!fAGA2^T9+L#9G|HR;7 zVY~Gg2&ib<{(7plVAG55G+6&hLoAo(;PUB4B8Sb!+b$$Uk}Fmf$JrC&fqJpRJ&S-7 znL*8FP)^(crc`sKAEG6~;lV7#!6oc8H{<}i%s%O5LzZ3!V3G?|7c?OIBME;ryZR}d z0ghy3{`mO#^uyb?v&rc7@$2J(?`zF`N~|G9D8)c!-41%>8 zf0X_HD@)9;)_Dm(#FTuDtRyhA?HIE=6kC=P`777%kX4qi6lwMk?md5b`u0O+D})V3 z2}i>q!8nf&F>ZPc>!BP-Yv8K2+HHtciXdP$#1JCZ!}zSCMBHv&mb8kO}j2H z(EVkU!dXUS;?Zd~t_;THtRLPfrZT8c`W$Ae6{u0FwxQ;qeKm91IcFuBZlTB$ zVizD}Ty{DkFPmu6lEKzD~@Wd&ZkUmY-g&e`YELq5#QR8dAeX8(}v{= zu_6t(R2Z<=Z$UY67nz3gD}B{dCnHc#T*B@pwyT&J;cl9EJXjb|X`bG+5lSL3m?6Pj zLUPepSgkMB4rEexY!M5*wyo{4lF?r=v9x?;ml8x*admNuIf$sPpaBfGLHm~0c~z!k zIJao`*W9awRi&As<5*s+mQ!itu#~)n4>=-_JWy2JJgIG)f?0^xAA?~MAHM*MM&SW4 zCF9+VBal3G<2>X2dt|)vNpkqp3g*9COV78it_(6Ntj3}e0w#g)$YhPS@ZAzLfH8j} z3m(Xm&={uG!^m9#R4{jFAroEvu(3#FS1`kpTD-s{^n>wp)ojGlU<`y8Mqg@56Zl%Z zYT2ey7@!ZA%%qeUxa{q)-oz3DADFjMlpKTcEHE&W73+yx?m0rSL9>E=3kJ!4R3IcN zfA?;&C#~XBhv%y9=iv%Zsr4Lm5?+$o%?@LoT3g)e3Er7u3Ue2Q3v2q~K)cpzp=--7 z#J#LDz*it;bfqI$HW5NN_g=Id|sAl_kNCyO=1d%+D)vDUfL4gxb)%v zt1FnV{2owMZ1r5+kxVMW2uLhD#-n1)hBJ{KuW$5jF(Qd+6kUPT_{yBSiZb`!Uo_JT zE!0Auusa(eo@8=s%Od3*fo^wq1>_q8repNJ4pi<8L1o>s{_AAk@g`8YH6W9<*KPat&c^oE z{?pxu@Asb-fmjZn*Za?^{int5E+>+=mw{aPL-HIQ3 zGqkKj9e)Rh7hx|;F4Ac$GCgd~%lM?1Z_-g$mrn<)kgWZVc zPi)1UCrHaeoLhpzTZv^K#@dY$j8W4UH(QiFW~mKKLn#Y!0cpRm?#9f_*@cMw%<-nI z=1Z)i(UhKjxY7MZ-x>osT4N4;Wm58A>0c1T`u1Ozqp+Mc7~p z1(jSgSih7UdUuy42+_s+w6bYDKbVsrJvy~cU>2Qq;{J^OE zqS{@qBb#Nj&?+{`&1*fE&c@4jkYEG&8RL&ODldi#aqbDfsU0iFfJtvHH_|YJ0KNX& z7q)Y+wic8HQ((!MD$$L$img7#sNnTfSjMXaSCS5*aPFC5XQ0-9$RXeq;ocaDsTs;o zu8~gi!95l3Q>Bj!@AxWloYF(T6@t;$r53Llg7QqN5KF`i28OW*l)9qV}_9WIf$o zZZ^)Xw~B3G8pLM1=yKX&EE8q8EVK^CZrKDPpS8??z) zHblcQ69(M*TaH3nJiROVO=0Azpyx2gIdW4rycjykhnNDEyA3R{X|1H~p$bk4$X`7Z zEQ5glph*nV>@hA68qq%PwC=3npSJ#(?UQnh2nAo^F9iPN$Tv$^rR)a5_tN^l@U+-{ zc0O$c;U$7i>>!5w3zLq06@K#jg>FX*vADoO2+^<#(;!M3@*0i)r9o(66Ia~4Z*+d% z=11nJvxO0CkW6)>Dns-*r4i;A(MEZ~j&5o(&z zgN$7lZSHF3xIH?%7(0Q&13p2j9-z`)_#BF-?b|8)zLGKfOJ^7;EsKvoFRsJ&Q@|44 zZ_&(*f{L(;fXa{k#rM!p(C8)RG(nN?un&XsWtViilvPx;QIgSu=J5WD@6X1?3n7W4 zS4St4_wU-#nV7x+Ckdt8VEsz2qc4KWilXv-EvUS3MdiuUXOAtQ^62qntNa>*%I%v# zW%)qACQ|=7QRQ>El3W{lrr+7zd%XML?f%nz{~4gA3~w{va`5ub_Jdn@cE7&0p}G8< zp@p;bzD&Q7L_0Qa=9U$c*s@#fPv@46z%AkvgUkXrNuXtWz%2?Y2M=}+9^%>wS0ond zFq@{gB$CViI&RsyvuqpNyrETWb2*M5-%b+wDz<#3l(J1b)>Z_PsI+V?i6jm-tRdbO zgoq}w8}lveA}cptYh;$C_wVfO9o&2R{P>%9lL#%DJHA>70;&l#Ug9)1u(6l$V^@`? z?OUak8Zm1(IO&m8yTW|Tv1E+iha)}y4qR~*5l30x15`*dF|sn}EGGJh(exEzhU8P! z*uk8SZSw?Ic>BY+{BT}~UrTGdu%2$MXugpWsRk{>y5=7K{=Jfpf$(K!(LM+HMJ!Jm z3!#od`K7Z(`NpI3&^j!nPGA6;FtE;~2MfNO0CCnITlRfSupvn3r;}E1(DAG>8&Y3)9QnGK!-6^ymab>|Pqf0^mu(7p-6NYP5{d_W>tZ1dJDqnHXWC5qBYL zbQVo@JU%?zjD?hy23HDog%uWA$t;nAzOOcJs*q0b9FoIqWHmnqY_TUG$eig^IQKtI2a@sqr!`3gjVD9Bi*33Dl} z(!wsF3)UgWMI;k!&tv3~UN5T!Q&SU`B`a?m^PQF$ueV$Zy2Y~61{`bmU$q>ZVWU?E z-k?%spyHZ|8wj%^Vo4HWY4Ge3=Sr`@Vr85#fLiaoo;ZfuW7Qbjw>VO%EIrO*q(_^$ zkw2+f8ElD;vDJcMr6g_Wa^GO?9<63-)KRD1Uz~JAC@a85C-yCEAdsVfUbPnPYGH(v zd3YMMIu1wb1SeU-v+4EBU2}Ya11%9O7Ds-43Z=r>W9Ad}Ui>B9U;SJk*gUY1&`86f zdXK4i+8zThcK=h?UKC?yiREl=A(oEmeCQ`V-B|zzzYSiUA@gV%fG$eSZ94^O&jn0n zC{3U{FyC{v~M-W_sB=cqj<~qBPfj4K=3WSPC;LzT1dm zu3Gz=D}MN;4?#AXl9w?PQBHok`1te1_0OW57^q05f1#OvEAP@eFDNa6YJOT={kXXL zVR89AqW=OyQ@5PO^t|Q!udyQ;e*5z1_@$7?tJ7Df^U2w}Z{CQOvH~gtIpyW5v_gP+ zv9^`?4*`|u0F}7&NZ;3^M~}jpEdiB{6jZ)c2fTtStDv&Z@%2ltA+!iugah~X;q$%6 zBk_qzxCQx^a_`Ax=fS(JgChyIZ0wod@})Ad+qZ#3LPT+ve(RRt*)QgnU&SqECCK5> zPqBq2u_&#?Ykz<%;f_nb4BXO)jPLH`iV+(IlX?V-A|1Tcd;jMsLmpU zYsI*Qmt0vg+k8P|zVsgc37_l82@84Q(^MWVoXm_i7`RFjl8s9`$J{1SXaRqrtd_yUbi3_ zdvMu=Tk;mGKmoC<2PMJ}TA_m7l#w@q!3x8Tj0>Ujm(5X>! z?SQsAuef1vm0ua>lF;(kEbZ}+000mGNklvZ^s&9thGqJSm2Ft>D%AUE>uei?@?AkSOr0UTme@Cs9aqCcoA47Tq?+5 z&t79K9&Y&-03+~o`TgSKZ{D9x0!18280BR0_Dy^KUZW`|LrS5d@|jus6k1fCSs|9s z22^mpNEMY8Pel9=vGf zdfx6otM;B`I}bl>9ljF1<@VN~1bG=PQoUsfTI4dl<+Hi`UpwEjT!Os4FFnW>&3ARh zpe0`6w+XJ?U6XQI*DYqtE&I#Rvg!~^(Phwa9Ivkw719Oz>XP*?gpQg2h=$F2hXmeo4to z$5#ivrLYevOsE-W9jcQ@ljdwxY7KFr?a8$_?V?%$ft;AeQ9{Thax=HB%x+>K9PVsJ zR=Er}qYP!fEYWI4CBrHufq{ht(Jj`yz+!)j9K1CkTc4Pw(ivGXUsh`>me0(`?HUih zL!1G2j@`Y+&+9|-t$o#K)nv@$BYQ6P|T7%I7@f(zXY zl$%N~CidbuvDgJXKGTu|(@%VivO@EL@Qmh^&$Sizz;DpFBlZuPuF>o)3JXU_G!>3@=m+ZkKL(e6aMnJ{)A32c1 zme|%`&eu|}0vVEU{MBemrvvBme!L_f4zF0IFB5$Rke2B)R+tu)Swn8_D;%(I8GGbO zsvDok6BH8)DYf6&8g;AKRF-HdzVe=@SfWM1%&usi7tX#|Z=4i?Kq~WtakMex?n+Zo zjfwUx5R2CqOHw=#k$x1pNRuj-T|v)S?VX2w>obR^zFYtzD0@^R4J zg`nQH8f&w(wYlq5C=cMMqLc{tNRXTP%BsNT*lTCP#FpLts?D!up&Bm7yI;#QD!zGA zdovsN8?>Fa}iq zG*F3omCv!tuL3F?gA01g_Tl#3SE9E(uMb{yfm_5a_O#f2bgpG$&+lyB)kOa7mC?y1 zMxfGS8q4;b&25M+a#;VT=9aGq+_Jf|A)}LqE@Bu%H2+=_(;rgGmoBr49Xhcr%}uU2 z#Fj2gtq@e=ihLriYzwqY(Z2JR~zpe!_gBkYrA=Ly4BT;7s4(BcIs>p_G*&ppWumxTGw7PAvkvjp;y&S-jvbL&+FI^R-5U zDvBh=5>PqLtciG{vnL=Z&&G_cgc?&hMk$SDhpffe_8-IDie@slrFlMv)P%+`{Tb8z z4L+TXt8{b&7R<_zELA@)j9#p^IJ26s4C5ynScj-0x3_S-8s3^rg_oR3`W{+}F^TYr zNGNJ@nE0P+ZXQmsoJsmAYyN9&m|zrjhBE~3M9pT1z95nB8JKWyg?YCW7^d#&!rRGC z97FJave5w+v6ETTE`#;qFz0Px9xA}9J)4)zmxaPEY}I>qrH*^>F&P2}mw-wIi33aX z(+WZdllQ%4U1nT`kFRQX?h`Al4j6kQwr3~ddpNjogHl_DH6%bG2<^$hr|<|pA>(Is zcYpaP2dzp;e4M!mD<6Z>QnHOoj>TBm^bheA3MNq4@JnbZ5es1vFf#6?k1DnrwYL=% zy_3Qx>bc}dIYr0Rxo;TWk7BE;1L{=%;TS{A*gmXvUe+IwR4pqQXvLW;32?_HFxFW3 zWFVgS>ZLtb@-w6L40!@79v}fC@=7*UxK|T?3q2$+8qSzx)tD9A4CPN^shus;tA7lA z+!GTX=?$}^Jeb74eRvpguw?)$aoEw~@;#>&MXx}O0Xn05*t%lPn!ZOh4Sjz%rTn6o zH5|Z#)#G)6oXu%`Ex=_TQ(f$TWEL1SHpk@I7}IAOeK;<^;>U3W1_}d5G1-bn_ti7A9X=Ry;U*F(py%?^_eMst@@|#B5S4yt9Vn{B3*3s zX0R%W_*=GK>fIGot{~2!`wJz=SeNJf5lzt>OR3lN%Fty|#juKKDTYZvpwh55;HpUmB4G?;$ue<8xCND8;g9AYe}4P^TufiG zMSq&TeY2Q+I6XN9*FhKNTQ;`AErTB9YZe^zlx#+QuQ9JAGo|cXOjN*CRSM%Tha<+>MbU=Y_F&>Mm4M4vX)YIqhTzyiba8l zSGY~!mb>>Ky*hpSepL93{UmnUsxJucEZaGuRHDL2Ms2|*1+5#VmcpG!J^d?$$PDGw z`J}8k{HHY1>*ODD^Q<{$UPT>cfs5+)QX|?kkB0k(jJv0j5Paj3h^Z@ln^rCiq)eo1 zOE%`k)i%$$BZ)J&xW)WSGana2{89AqeUg}0shM#vnPWNE&--DFbdY9g!XaKMLb;*} z41nB-%MSdySU#qhVeuICU(NJusjSkV@s$`g_0?yA5VVYIM2}W=rZ?8+E)Cq{=!u*6=rLoLaY>=Wj-hhm z*F!%x@5|Uz+;Nb;ow+q=Oj*VP`X!$eKVDC~xj3(&6nX73PPC<$P`SKd%kI8#;$6cE zV5AW(fX1ns^N9FZw1hEO@6mZ<4=m7+5v)gKg|)>E_vXvSS5_FE*P(i!Iw@i+Kr93B zezQd7Ah>$Y#C4A`gCd4=F;L!Ou?G!lkq4jva092Buyn3=)G^anI(*3mQeRE+2S9ZN zpiTf*D^UEK@h2)7A8DlI(y24~~ULx|CrPojj_E>c9 zrRjax9)-mp(T&zEWOPN!zQpx)^3Cb%`S|^ZH*X-Mn4I!z$SUgoI*P1v^d(jyv8)Fb z(O42tdASZ$ps2`ykDff%uK}*V@Bq5+l7Bz-(ZNQm(I(75N=!l_am?u#xnDEf;^q4E?sHT-3yc z_v~*)_F%!W-#Bq|`E?K(CfRbikx~d(+vZgp*v3*&8FR4N>7ZtII98=iEAuld@y>6vIQ6 zSd}EUA@lGYRg4oTPF&iQH9bdQ-J}fd@I|Pjk#Z`FrcBgS1~$-5l6fY{jX4KQSb02W zC6~PuCCGSSXwza?rPSMd_QUp4tl}9GR*x>PxWS?~U@i#ijiCrWFVZHyH}9onF{vV$ z(T7U_DW{3cfTXilG1ecCq~I%{h{REZYHSgbMP86rs_9r$D;iLlj$MQ)VmBJ%EBLL& zbvj2B_`B?zJJTRL{++(iH?EjVsaX^Tv88S=-1%U2Itxv}%MD-*t6_?0Y=uByVr2AI zo#H|xg3b@T`^SIoPo1NJP3)wOt?k5u4k?A}bSneq&(JzYv! z1<%u2!4QjhelqyP3UhElr2#&{Z{z3h+`hCD4Z>w(#xp)q8cpfKW9X)ru(WXmMjqrs z-sl&#J;K^BW}*$bsNQ1jFAH|nx~9#)eEA<8RwkjSNzAuRdYgUh3nsaHo`4&gyD|=~ z)be`29Ef#OrNK&|JXczQ3Crlt000mGNklcy9Yq89RC)d#gfUe$=*L=kvhM4CW z+Zui5-N8mUG38S7-oRjTpNbH<*~#!x0K!QWJvg~onEfoTz!(jJB5VOxM_()^u2>O< zg6~N};CL`;Mg@0nn}a;o=Wd;9re?mfkrwDGs-=YISv9L!a?Lr70=2~aV4}TLwm@(1 z7ij6{>f)m$Laig&NI}y8bQQouTzj;JuG*`Fd$&c+V|g*S!aQ0w5V?qjU3!`?nKqf1 zcDEc$Y34SbxL}e*|3<3XBpKgc^xf6G`!sDocJ*abU35L0g-aV|!Hw~k81pB6lu7^( z{A{npo7Fc3NaG`;-(?Ypyp zAF}g!ayohUruy(Uyn)rnSL;k)gX_z>N&+o6Cso#fN*Yj+O?oN&5>R>kczbv6PX?8B zvnrpfD*n}2<&x*sB;FhU!i{@J%ypP;+XY1CjI6C?D zfLp@Xn9ii&+S?QrA{^hh|8Sq!uZS%x`Ie!&q77q6whXfPD|0UCa?1+35Ma`X%idSp zDX8Gw?~#>NsbtIbiq+uCN<;CI-jYfjo8j6G*Y;Mxj{||0G=H))9G(2kJ^N$c+)l17Bv}6NlQJhd9k@_qheDr4FO6cW+LW=pj zCgG%xN-@231VaZ{5;aCe7`BSxXl_U076UTpnOWCNWAQ>^M#ilXI1QH*GiCLyfXc zc8(|(fraF&Htgvp#z-JHxZMIX6pi*WD~CvbS2N43-ODJVR|%ySVI-XFn>lU}yUPe9 zOxj2rSOqAx$i88THX~1SemHu)TJElxkWxvx@C-Kq7$9My1Ibq?3S-9?v+^_jnORgS zn^eXps>Z0y($G03cVYn*oM7yDVJ|>hU06;PWsd4$_{7vIhIIusk?V!Py=+J7{B}&I z`1%K4HP`bZe$9rU;;*ddwrJtKPqq;6{Yw;HP|I62v4Bg}7`UxkWZ9h`DjQLuAfwrp zMvwl^1E3UXFw^+H-(iev(-g!@=_NlKP0mMIDwyQ@3Vnv9yqa=KW=WL^s=WYmaE0m1 z^Uyui?u8n$x%$eITsSHOl*$6eH2TP;W`L(;f2y_7%cKJ!gcnTNq7H{#zT1VylQWA$97ttmYMs9=t=YGy__8HpeYMhB`|Y_EPIm0 zf-HbTJoN`G3wb64+bMRRzRaWX5(O0nKTXC@mwaE9X({rPZzJ(4Xf(dUi9k!PjxYW3 zlhb@d(4E`v`xLDJrU>=k{L4wDhF#kn_du>L|=Hfo4b)%wj!VG>>ljy9qjGx@9pmI2)FDGWt?4)tPDM1+e0kb zUU`G8VpA;xVu9yvcmM9=XGd?|k0-LcF&iiziW6Xxyqq(sVlqgj7B4+@Arti#8h%m5YpXIiXUh`exqd8o zgF}!Ljg;8g+TRe{n}^p?oCTqvoZ0GpV|FiQRK~9MK;O{33Ww)yv5{59W*Y;XSPk)% z1CfLlv$t6U-}Q>~%rfx`Jm;3fq1g@iNcq8J%g&Ahm4n3a2G;h%qj&_b+Cx zLb=7AY@VXgAdS^6nAOc|#JDtCsKBD36ivD>)Xmki?+XzZ{dCG0`tKZslxBfWOkjzE z1gnIZQE}5(Ox4eI0usk@-7hnYQ5u0MVnI-ei;%)0mfA-B&L#iF2D9c z6xK1w-9Keq6R zN$oqHv9DR3Ikp`+L+F`Z0*X?VM$ge9hNo!6OgUN##IB|dS}k`-@Fm!SJNmUef+EX| zQtG9GNyU8?sW4WyG`zIBFy$FJ<WTs2FDiTY9ofCNSX2SuZ zBlwYsqL2-+r1t}o!IfL9RZ2<-(3=;lMi~HD1@ZLU!aQl=SniLr;7Wbk43;HJre`Bq z!f5&;bXAODOkfnWK#1GJ;vG61)?CKS&6S5HXed|um|K#Sx1zPkxFbN)+XuFBe%^Es zDkq7GVt%k05+>>xBpD@1i?S3}#3yyY7l^s*OE5hQu9KqWM`59|!rx;M zZ#fv>cpe>}n(jDlOcsS%Os7q=n9jpd{!`052255L?ewynYbL~a!{2Dp#}!qS%T!vi zVEw}OD{1ROARYx@!d>hryYO8XCmgi>wzlWkBG6A(xo&On(F7LqPt9DuZ>{(Pn_IsB z8dTHk%)C3q8lSEQDMh+Qm8T!5yR31C1JbbvhwF)YCsHoif6RcmK5mo+N9PdE3T-PyW#>&{N(mIPgrppL{y$So>sY-?wi zAi>_jv2<5S;ZfWanaJO5piB%q zjI7~OQ1J{pV<8ByXKjRva_Y^<@`)yu-Vd6BsgRu82#kFQGb%XZW77tY2PA-xwdobx zthBg1q$De7HgA|NIp(s`ID?x@A54v0im*y}PaYn8Z4{)#@X9%+kR;D@8B57x;{v}0 zc;xu!9Lp*lEMbLDwXmm{Dh<{l%c0#1x{50&jwLp$BJVHQ);0Acy&SW`Ph&YX%;1NB zL)die(N#M-0CkjhramQ!7f@k^pA1Iy>KsuV3`**;2GlXa)?{8K!TQQx6!)Nl8^EX% zz}|zkc+XXbBfcKf<5Igf-a@HB?;?)aLu@uv$1S%#xQ(PsWH2)FCU&%Khy!uDn~p zR7|(y8IV)E7k}9gjQ;?*>ksUgWLE~mi&WuC+li-)gQtS`X3q>*(bM+Pe3;QP*Q*K< z{>LmeY!jDN+>@3J0o7g(p#^aTp3eX(h-Trk2C&{}+(dw|h0RsnSftai0#csD?p-v1Lh2#OM$6l<9K|otqO9wyvp3xIm5$>PG=5 zBsWreHFMTh4~VJR8B2<5>*vs|Clj@_^g?rG@P;+mb3`GbhTx;*Qkwi?^N!(`0ZSl^ zD5405xaeUWU(wA~akO!Y(MEuQ52Sd0#(IqxXbJ|ygiis>R&CW`e2QA+VdhF%zVmnq zE0-ZN;&eU58LBSuXd%18PbA4hdBeJlahs1|As21XSLV?a&)Fxn$e#>d zWaK_Puw#`|!msbXdEK4AfBVhrV*!<8>-#z(qWHDW{pHsm2r4gEKxHsX&%Q5$$`kc{ zJ$mrqK{(@)Qqsz=KNVCyE2{i@sd~K624_da~ahLd#G{SzlNjwHJE2B&M>v zjvTg=ivWO z%<5!R#G?EP4>L(+MB5deTH!rs$|~|(Gk{GhU8AQ4q5#8azN-zrMpHCJqmW=5Bm7t_ z%#e|E?$DRujVL^b(rXzPo4D`>229}c8d=HOuzXA2dckz`YZZ_z z7$5SD6mLux%}2amv$DG=+PN2bK`QZ{v<#an(FxX%jX);84yljRv1FXM!r)#*;NGJ4 ztT4`18_~o(f+afNS$Rr+fy})IMqMFs=l7g%)DTsupCEq^jRks0_~|OsT)sKJ@PVc6 zOzV{7g8K~ggv?Mg$`hotcvWg6k&T6LUV(uGA`D)Lg|1>}%90xlMd%`P{ULk^N{uzmqOX?|ay&RuXDloZ zpQVD)XcEh&m>}0r#I<|gh7Um70%{MD*JfU7CmPWX?-x+%HHczC0>~-2#Ooyu%a@Jh zh2~ft@j(@pCafh4+aUJJN0M0q000mGNkl_>UC}=SL z7vF+l<~ zcY$ZP;xfgG{8cK*DA-(@RqT>`ES`aJfJr{2HM(#~gf++N5VNfn0M8{qJI7eAb-iBv zVCFqD6@}8xHOL2z##79#NafdePAM0ke)G+ncPD{S0-Bth*5~i@5ARM-6;zH@R5@Wi zadI8QC$?$;nxxFEhOOkJKgbSZ{b9--R@7}IVHwv!oP}T@X|AVAY?6Xh!pCy#P zamP!L+a+_Tq%Y2^+aw})N$c8b2PKl8l4ocoPKJPLvqFJ+0Thv{u9ajF%- z&=3qHD$R{}vzg}O>C{R)e+7hQzBa>!6HJ;ki@aWNw;u0QNyI>cd_Claa7g)1Yn4fJ&;#@Xn=?N4-cym{O7NzRcLOFTo-x3)`e$ zSgQU~*Y0OFf40y$=Q-?Ns<8k$kVqi~;ceYZ;YbVGa+0pFDYCGdzvl~|nQX#Tesp8S z5JNRncfE6bT3@L}ydLKpoSVnVv2|jj0Y`M>{%8F!7Ke(flPuHzM;W>Gm};zxlF=u!`)UofLN&9 zQE}R$)l;?TLM4iowYt|*-BVw~)ad-2dP_I=-YT$D9Ug!9sBnA?4_l%dwH0$UrkTXf zE@ZN)ij z>p*En#Z8a+Zn{0PYR$H1Ml!_o;YmX}$U^YL4g-}|;`n2aRd~##prKf;YbvwAJZ8OD47E|?Uf*(&p z?FoJvst2q1+$3Tr%#B#=L=pWm*>jvTZ(|>bUtLjQP`^{D;b@%qhE`4HO zr}-9p#x&frxwW^odl+!VFF;H9?BP%4$^56}7Ej&W-q3u@F7+jT?FYs%hf52R%Ndo` z1~Bg|UPcQyh9!iv98*b}f|tVjYoKN6Gj47z-PpWm{B?V0Z+B-uAj;i94R;^hKfHGs z$VKIs@JDIG@(tJW*RswVwnl78J!abhT6Xpi4(~sIe*Er3h8cS_4g*@O#S7hE)LU2% zR?|FIR&#GRrod7m!9TP8L?Vjrfy5T9FGEZF{gv zlVt8yRN+RX?u``QeAOdZqV_G+e|7v=Sh*;1bpAFBZ)iF;rn2QkAFaW*pP8xB z!ggcSG2=r(9Z*z8q!BF#_JMwQ3C+u zVR}j^XcEHJW+pCbS{l2SYT{3&iqxn zIN7qQSXWS#9J2KOvz&!pt`+Dfs<_X{$W5G|Pm&lspu;o;^~2BK?k>Yu?X9@x6q3A( zt!<)77O*!3ae0SWc%w8{DZ^(EXYmTTa2+|Zy%c0iJOzSBsN96?5FVtZKH>@r z%yN@R1)Ur1Qq?6Z;yd}&#h3FN*HSzUKQLKom#%r(EZCr*5#(czFZ&QPQ^`Zsieya3 z#PE^|OH}+>a5Bt3S_X#g zXR@|g6*`_+_cMTsvdR=423j^Pd^l$F0mCZ(rxVKyku&wf5pjXp65v-_1u&jfuP0`6 z7iUj;6BT0)9%X^M+ZPdD0318KUwe zP(=T;}IjC&k04m9S z>Sx>-&`}CGgHL4P`@P-WgZ;h304@8wd*L^`Y7$$ut_=&p(l23~>dIP^*s?urZz;#` zPg;2A{=vN$N3Y+X71)l{xfM<1m)Z&i3sB|fPU$Z$t^Qa%W1hm-D=TIbuk^Jn3YX~_ zwfF6jDL?`W$rzPfIYahiTdYS_7iz?ee=I=HHOyp)y|9!#L-w5wlJq#i1E0o32p`aF z&5?z9#5h(%-xphzy9AbM5Anwgj0zWHF3q!8%(5$zbs1~Z7eEDBmy9WP+_;-a*Kn^* z#0?t=SjXH+n53T5pk)FJ*o>c-oaRd}4L^}q7$*}e?O4YjjZ#pV070hD`3$JK5L ze05&1q`OqV7BVDrvurIep~IgC{h2AbWS8bSCb)LALU5}HVL(!*AqKH zWL#>q?QKyEJja6GDn2CoG@4m%VHKOmsGUjT7`B0mk4xwa{KY7fOH&XHPR|oN9TFG{ ztRiycBxlIJUP6ulFm=nZNNonI-Ah*LM`15z%+@Tfr(33_je!$9Y@a}}1#~<%q{#~% zATM=F#bf;?Dg;Wgzy%^=Qib@;@>P?OC2Hg_%2D|)d1gGn-rD)N^DgFL33Zbu=^fJW zvw`Qq>bBefW{3VHDB?;N=Uk|Y5L#5Pi)0%SLVi^E6D0sFoy&SNy|53X>Jy(y(4E|K zQCoF#Uj)`3(O+x5;IfllH9+MaGQ@BVAFO_63~>)0~YM z)zsF7KWb=tL=zf1!7SNQ#I~_%gcJSAZ<^V(zwFgXSXy%fQu3k&SwsZOj5+pL?Eih% znweS~{!j0_iwyrLV%o=Hh~c)8TXBuNS)hG;Kn<0GxPL zR}wyv6Ms9a`bCZL`2GT)*F!AK?yIU?Vi-!DX4pk>1;%z)_ywAD!{!ncA}dsIj0I%? zY9aH`JV^;(DV$nVVCL6&Y^tFKFY(GTU6W2-8T7Zdi&a!RVG(EYV0xH1USlUP<2 zmE|zK$SDFUQu(!c^;AnrGb~7v`Lv!{wJXwwPA0soPCp&}Y$Hxv zwquLzTR&L%H%U|@jp9_YA|HUEo@$ja(<#~~)?4+NRt#6XfqKJ~LzF#ypB@U;B&tRV zCwY^KIf5DCfF&C&Q+UZ#S7}uas&L5~v;&OlGd2g=v$1&*{UZy{8k=7#)?>#JLKh9&Y>iLp(n70 zKCZIUSOs?>OO3vWoT3e5ibFm9IeNg=-}=xB5B>h4`y{s@hjzAxL*9r9^)j`7O)Nt) zPR{Na$^fF+);NWe4oCktf>samKItQ000mGNklJ790V!ek+nMCI;KZDuFs*VGW zc)} zH1R!Z?vf37Jb=uMd45+#+k2pepwiCxw!$RiWTwS|3YvC)G5`4S^1I86?`HFlVywc; z3ieV0#^!T_6P=@#|M8I?DcJ~ofu6wW^zwS~@p69Mhhubq)oCRcQz9a6=+!UeGPwgS z!zd8r97;{4!naLe-vWW?PCSDjwQb2aJeT|8JMLd7rJY|10HBq)v-AioTT|8+TN^t7 z=F&ZpfbO%XYvT!`N!_!Q-ASk zdiia*It-`iMv8B+DCD4S*eZ;5Tm_>mkWl)?C$u5Awo)Ii+12;2Pv5*b`R4WM>kn_< zehfcx=kF<}q>9Qas|*2VFadeAB(uCq{%D9RFIQP*Q2F(2Sy6fR&D7UQfi6v!X;hP0bBNa3bH@5e~!w%00 z%E!J0Ex+D>Uq^2F3TN@H7?j^xC5>g>W0@;bMP=ITO_)(4X77@5|;0`!S;JJR6k`12(XT%H5v#X7^)lTCGJ* zKpj&bS4pQE-A*=6JX*LDt0eBnh1y}Q-I45}d16h5jD zi0xqF(sJQW<1XwhMjtL9C;Yn@{^bk*vgYD|s#s)%bH83A1(h*DrA|7IW*%QDty|3I z>gA0|T=Ao~Cq2coZngAuN}a6Q)0KpzijQWDEvoLdU>=oDNk*%gVg<04hLst>z#=H) z%$m5gu#37!&Eq@Esc3nsv4%bf*(cE#IKf6qRWX9yBNiEFBB2-y=?Hy`iebJ}JZ4d< z!VHV!kQ|Q9XT=^fJ%2lmxQb%)Ab2ONUkIy2>ybRSwws6nF2>+C{P*EE( zuHYS3Z4?2(W~@kVJir4>mA8%A`k@YGD(z&x3h$F9hKNZNMB1MxB%e}Iq{itVDWC#X zzn0d(z-3~A6S|Z56S@RIP1<(AU^=2ix(csC2kyiYG9}B$3W8Z**$AU3Fv*@Ns~t4j zUW>nrgN(?TRONeNlF^;T%er>4Oh%OLV@QiL{9R14XY1t$nTXO!%9JprWkO zCq-V;XWdUP`{{+X#w}*E>-qfSeDP_vkePFhzxVbt&;e}1olK~mS_gn-TJU!cHcZ-X z(S?J4yXd-$z86-}^L_Z+at1_^ui98e)D>HL(Z;Yg-l2CE11fZc1i6&(P8|}t(n!6V zvXFO&9C4|xDpe<@SeJ({i(SS(rBZqvKU%^TRt|Oz_mr+H%6bU{E#0Nl3Aay&qn^{n#c zX-G&efmK#ZiU+8{ej}^=X^67y`1-Pb@(Wqz zCQ#WMn!XM`cijjoN%~w1f4BD^?%q4ve{!xR$nBwUOMAdRv9s-ar&=ayPCu{1M}RunOnX}h-DQ-|ET}eYIRc-WNo%S4Up`ta6&8u8;A_NpQV!G^~j;9 zuzb+}EUT;`sIBFD-X1>k;hufc1I97M3mVPh)$SK1+dcs{ShAN}v>JDAtL3AsiHW zS@?cpa*9`e6*3Vp1*jm@A>2Z9mxM|)>mLK#Bu&SSbr!4LtDIq7-r9cA4ccKQ5*Kt5 zPsw9bMPnf%(V$j1$FT!tV$eORnYq7ao|pAG6rH)GWo4|nkr9iz8pDa!i^Aaks#}Y- z3|m>p*iqrj%-q>X55##qM%S5jM)^jbpV8gTg`==D3APkHcnuB5$-(M_E#|Iod$v9u zbAw=tU7qV0%*8x$ghFdF{hV?yM zfsi`*!C^_Dd*PTzXTbmi==gf|nSo4!XotGm8rxyi^S4*e!5b+bq6!HtWL_MM6?r|u z&p;+MvhrMY;SZhF~iah8^6 z2#r+rv{fq5CSn*k*}#UV3GyelKtJ_`6U(&Fh@}gpSN-&=?Jnd}<5Qu+n$W^5ki$YK zP90fgH`gKu(Tbtlq@Yr9O zKxM`(zJ}}6X(AgA0Ut=u50D5%^S zn7+jKwQ-Z8vaGJGQp#QXbx`kX!;ANK4j%8_KRz&Sai5rqEpIK9f6utZ^DTpCe&7?+ zVMqHcchM)d`8l81%6IdRBbGrLBzfCAh=<4h-Qg|QmKUeH^~0G+bx(a6JXX^w18u}j zT&vp1Q0>`XA(Qr45(TkWqj3lmf{k`cua0|uJ4_~BhFDW?_*$wt;Y>Cmb}&hds$OyMkYz=M&!(S#(Rx%ZzDQi06szhK4ysEio^n?Pj0*`NBjN@0dBEY>6ZIudaveCuo0k`|iF zX8;DTm9n&SA_`ljK*iQqar5g4pxR^PY~=QFxJ1m9-z)H7JEF75>XOJr2{{7CZU`5=vE(cIT2D)A&QWP4gB^8m3(rHevpnBA2**;Vq2=hIkl067AwU{ZD6q+%tAZW#8x{f z^;qqg1}bvhnyg(bz_DB7F34Jxs#lb-LKXF38if$WvfvmKG6wT?cA4g=2!}RQQnJ=e zZf{u;OGmU!zZCqWa9*+L*lbk#fSWwJ(R@VGh)AC|bHvsD{QKKzfy2YL<}os`@okj0 zfY}peC%C+DpvQA!R8yXZ=Z(>k%EcdpoJQH8bq$JIO+y}lDG@)bDh+YbGPi9+tMP3u z-CmFF$6Jyl(uv$#K*ii&X!>I5aPRSYubBpG;rs42Ul^~%lV8=du30Slt66{5){Ck_ z9|lW@8A4MQZKRHjy~%jA-ckTpo4ml0n{taT;AWmQbj@_$XFi)=EEb~AOlQ}t=@C!_h9)aMq$#;Y8qyZ1E9BoEs`KcU%gi@e*idY1jKCG!jui0t|7ttW z=f;vHyH7VA0J`>OOn1|h0IoMM1j#n@X5M?Njg=JIjn#(SM}uLqM9oCpki3}?-0u_Jhf`ywSDjHka4 zQ>J|^tO7wo!B`i7K~3;-n~3GMzW(A^g$*jJ9O{anC6fZjibW)t@=;OmKxMHaR+;hK z*W&uya`o$i0Ol)Vi?zvK6Tlp$tbSd6`J4M0KKgik_Tl*K=y;V*|2oeQ_Qk8|Q3?L~ zA!vc@u!`V{t$RS_=Ps!H6oSf6jd`Ty;q-)>^bI#K1m(XS?`>;Y6BW|bzW2%mH+6s=k@D2acrJ@GGw2d_uZf7yFY zVPXd`<-gM~vF9f{2fu8M-ZHne!o=F5)>G<88Mw2hJh8p!6oq{7a_{i<-m^Emhpz^s z1NTkua7!P8KAu<_iC&HEdjnwl-0iIgHg@DYp~f<3NHp7nmZN?B2v+YJv^9!*g_re4 z`H;KXvtD9Kk>(a9mhiR(F^7H?Q(7K^v7UFz*t5HT@chT0k1ob3lBv@2Iw5yQt&zCC zzL3X|c$@(Qp`m#v*HW8Tu$~Pd{sEL=&HnJV%|2n)~$)YH_4Mky-y!)Mb{chj>k9s)@-O z=uAS8YDD=|y(j=7ujGgw-*h4RD}J}dKIyBn2mzf^tH2(nbdANYVny{O+ltuJ!eWRT zLj_cDj}}J~cpyL-yMf@TXsJv=AiV>n_W%G807*naR778O2uJTrf`+F|CR3aH(w4?Z z`?iB9VFH*~REQG(7RV`6Z4DnYEKT?-J|{St_rSMOs98no5t$8AwS8k_s?4C%U)a~u zm3YnzW^c*N*hU>MKHAtM6dS?P6t6=4$EsH-GiWViDy@Q=g=JQ6OZW2b5v$kGuZG&l zL~%vDF+r_iC*3#(#a)c27vqUz74c)Fg+%Mpo>el4*%U660#h}r1CbmhI+f;YnSK)M zn3iUIm16e~T<>O{k%V7Vok-GDsaA^8_RaLVC5$2^6cwJKLe)B*Eu8B-wN)sa<&hse zGtv;r1$?3%J7STFf*h?#dr|b~gu#qW@Ts&PVi>Fhu8<-TrBu|2kwW!28Lg$c7}L`N zqhBoGWI|%N$KY+^ZTcqoa00T(I1+!15jfrubBwP=5A(85PdqBXn4;#9OFGFbcfykh z9~ys^533|DEanX2G@0eeqDYr{x=7<$MAta@MMdP4+%44dz<&j|J&{Z$i*06=jM&9K z%FhfWMRi@xR(CgFe*g08Z(n}>eEZdbO6|7(-0YEa$4jOLm86()hB8gH@$>r_E?8Ma z>17;(qHBR*Tzk*Hc|@hFNu9}r%2i4+k>8k0K0$((c0QupF_UC1wwgfV;8b69z>}j- z>Ri+_KqFUfNf#;bzeWdg73ZC%CME5vKM+{VH@1TT?LWC9ZnF2}>fC1a`p_ zFQZ&=%r92IIrf-y`Y=s=x!V*^{6*r4@z~{;`HJYp{ds-&+x6$ae)@EL^6}*I_$0nK z`;W^!pIm)9I{t|I6b2GqpNw8?t>dIPB@%dnZO{E7^ehPY2-nSH$-Q9g1{W4J5 zejKRu>}Wgxtzi{f8`9?f$D!pxO4;s^$(qrx9#9!}M!$ql+GcuTlJ?M72Tq=~`rQ>v zo)7jIOV+J@w_XnSUyTmm?h?0L?7vG;-lFINU;d}<-FE@EcvF6ZTeQ4ouzLt-**$o* zOTCk?-0i{WaBF+`RObC2^tY`4!C14`A9y%v4@&h-ZLJL*R&MF1i+h-4&@Xy44%lf> z7K2AqX3=M8xJ~pS_n*>WNQIKS`?N;;o<_KXGIXT0JSt6l%&5Jbb5sWB)8O$L08AebcL_L?MZ@wFTI4%CiTeb8nvaKJ?nGxoInFn*SI&FfE+B6 z*v7gfnU|-Lu-Gol zfk?HyTp-7&m`4<>iD)2Fv}amMl#0p3iW)4!OBp7!oL2W&YVt-mg--HBI`kij#=@7k zg;Ee8D_OddB+G)sch;f)sIg?UTb^<1Qmb~MwA z+umdfo!9dI6ZNZTafdx^0X-z)OYBptm9%4^9xxa2^JK~r6enP#T)3u=h+C~x&sDF$ zXBFH|&2ysS08fHpgb5WNY7WV#k)N!yd;-iku{>4ST%6Hh3T**gBs@^*3>BZMRC)0I z;_`))1pH)(&X`Q8WM=X*1-*x0e8u;5S15r7N z6~Q>Ar&hd+xsCZjUBRrE4oxlV`OW9MU;pX%zyGJd{{8R2{(g7!ZSFSBZvR~^VUl5u z$RfXHoCm=7l+t;R|8+nc!9*G2jKkU%tf0eDsvhBt$K03orHG?l(!&-(Y5k%QzFKC; zWUp5>`9Qv)c*NasykW2FZ{P%^fl)KK03Ol`e-pS<#G?XhO3<$Ysb|IJnH3LxF~dN9 zk-;s&8@!5pa?w(*fhFWc;l31-SwJb2N<3GpVfQ>E&9~+4Z#Q56!TrDe=;QIl(dp#$ z>~l8#`z$#aK!6bcX3;QoEv4g+Z)be1Thc~PYqU2U?m1i; z?H=y#J=@(o9PJ*2sb4$&x{5MM|LL_UWoIWyFf%iczDM`WuipH4ewD;I7Z(;wHi*lL zuzMy)CaOisD^VM&iROlRX+mpBuA;(RBGRJ*`+F?Mds7NVxzy}#9&~qP%#q4jdE-#T zaR`sD&5^B4!DZYG?rWg#zs*${o_w_!a;r6DhQ~170r4cJmTrpp)NAAwwnuDG z5#J4Xh#|T?s?Y+#ubA1+JCQUeHX*R8GjV%& z-Z)}h2isipU9ep~Gg_D;V@h|ljrd?M7x;KwffL;>Tkx?)Y&9C|(a|vtqP-+9IFY^- zx=mre1ut$;k)1S*6OyMKjExn6-90!^n{F2nmVB$-$M4BA=$;Xi|oUt@B zd242omy-KZ<)aisQ!+2d`*<^mn~BK42IEpWaiDsvL#`rmXxKKO=arC zWfW9(XvG8#0MEe)~GQ$1)GXPi?Y7HyZ!c;FTZ{J{Ojua z%Zvp9c;9*Ai-x{(Hl>KVf_th3>QJb1DlKIituVQR`%Jal5t<3u10!2zf=4E@PX{iw zV4kI(Jh0AN>7KG0udnDeUq`95l zDyz?bJw7@)Jvxm}&+@Cwe_j>w<;C&wiK7$`DxduN=+;O6^HURAJ_1fY1*;7zYmF-p zD=J-3K{Nf^m#<#CtxEtZVpIA49+i7$z%CvK1~i&2O1?e%hWC7{xhT$Ed09x1n!8iG6- zzTgmKDR0?lb4$nB@Mebqr4B-@BZ@YbG{?P+ zRRX-}GD(9CIxNvfrru|0A&NLX*6_(XN?FG_trPPBXg1J$ zhNA@;V^(}~x-~TnCtguO9b6+sGun0pLVp??%Y)FF6nG```5=6$9eYZg=MSe-eqvB2Z zQe96|TX4p6mpqzPsz|>oFBHK9pM$%uq@glrz#!DQnx>a-Q^g5OpcKwi_Mie3!R!`` zEv3xO6JJ#dD&l{D7iFqoAe9Ixa7=;#s7PnY;7`claxTzoF%=M^U7ED-iTgQK)3$6w zxiKTR@)ODUlKrtIQ;A3-26q9Q+(NEwJWNNMD&K1k+qxG|GM#{eL?byenJATvFe1nRt zqzDWxqA8&gKGGgW`Vzih7BvQDL;6CjUKE*y%PsZGT>gs3&RTz;*`O*tJY$q$E zlipK`*WB`2pH|x;t0l#7afy4uNxrOR)RJ0O%{A!@-6jMo@VF;3MYiAwuWM^_g27#- z2^i&f-FjiW*qy5M}9)z8p*B_ z4?jE%#-K7c3d+@dpXqISl>2k;_yi02$P%esnEzfK`KYL`=AW5TzMyixW<{mcfia#a zyFp({y`*g7`Sll``});U$~4MOPtGn*&a#V(zt6J&vMNp;wiu{@O!&60Pp#F^TRw!h z15qU)mbIYL5>=YPUjP+LD0Y;B_41`;6E{Ew%7dnUxxMS-oC$xHRh~9xz_#6%`sv2d zttT4;zP-(BExJ531Gbh@dg@cU9+mBNpmJZ{myh}CGpKZDz(P@l;pA1HoFaVUsD{?= z^AK|Qw)@hn;o7GmmyTPucHgqt!bxHsrKM}iH)z2Nxf|5q z(kPv5W`*6SY}{vR?{2Tx?ZO{?;Jq94hmUE#?x631-C?jcA+)HbanH_9r)81_79o~{ z7kdXU-Hp-iL1Rd4um?o(xykEiz3y3!WP;AaDfRf-%a2Frmy?vQ`tyZQo)`4UdWtOJQY~cq1mHa6A_)EwS0?OGT!d)5F9`F9net z2!?ulv{;h!gm95(%=k&h@+2jC5*pVRJpcd@07*naR5*!PPz3P63InJKjhM}XxKpJd-i_F*pCf~sZYby0~>&$T%dA}@qO?EW`sh!e~KP) zWw3@Cqa8AlT5kN7I(9$I7+uRW@=IpYSTV?yD?C$)(VtVQq#i^9h|R7o_5-QZ$SVs^ zv#LZ(m$rm76L3H*X>OxdmcUl!^*o;~@I5lOH-ddL4#q_$fp^1m?+UkoWBRBTQ#eA& z(n6C1GP}Uf!Uz(#7+Tc0qElX4g&BaE(Tl++9gOp5jx{Ge2Gt@lrk61MOw=%=$5bZx zNKUb|niLXf#y|YNoRRenjbFo#jcx$)Gs;!kb-te^7XH-HIArCl=gb=QBh#HPNAPDrKAWO zwC-9JH;ggBHa19>nipScTUO;v%qk@W5UE4CwpMublLR4MP;B6aXGi{QrNuAS(nxj` zPU5K?)2f?o3K{&QM1RJoGdJ_LaxI$T1HCeM!Fg&yrls zNy?l`{Do4&R^QGXrJP<&&o5?^@&Ej`sK!@EpN>Br(fW9Fg!M^pG>Ju5cRVcQmJgpA zlVA!ezx0Ai$5sCjsJwgr;^o#_P}xH27w<&x8UE>j%9HiR`lk;_sM({kr%sheAPRt_lkwGSsaFHNfyxU3m4kjtvDIJ|S@di> zyMb=w(Bi|{tXbO<80h7DsNIgp6XUnZ<{bC2jNJVQ zL8b?G6q4@HSMSdL`!xX^(`dVY4FB})-PaYo>@vF7@HjTM9-!h}^V0xI?-fD@92kW|KJo4K>S znijr#65-B~s4ipem1l(nr7`tbN4)${nPQ$g=9v%U0u~oB)|(!pGiGqecq6{lLCWHw zo63yxm=KGZmmtOP#6`vZ(=e9EvH>t!X(=*GBE%vF6^;L*8{%%FJFm1S1Q0@=c(>Pg z8$RCHO*Ie+`@(6{{VcBNq`)R`tSL;fUdE2q{Vd};xYR9f0fCk=m)1`r@41gv(69hs zHOGtjM5{0|L<1x9m#H9SHAba_AhDOXl8dq?4>3oXl{%f}>oU+H0`17sis8-BiJD4} z*BD}Rvq(@?VxD`OB@?@UoLK|Cs1OvzR=_Tg%0kR~k<5|>pw+H*FO)i}a9D@|M(3}D zOel+)o!@h40*Th2gB}qDMhzh)Dnz7Ne9DE4i0BPpTHdS)g#P3d8N5LKKlBOl?BAU&GIVg6;;N^0g)vUoPX@yG1Ie3-EsJP9E z64m*!cnSAsIlr!#cNIkmQ`B#2K$6c8Ywk6cN=C?}CPisMs^7($qK);MK}=EU0q|H^ z3LlRembvDbdHI4mNf=ZDX(cGC$0_N6R_Kx!yNFC};q>S*xJRn_ot2zux|gI0!_DvL zLXwDAzhW(J{<6INjVe>Fe?_Y!7B#5c{Kefs#R^A$fmQCfJLT8q-S3lWdVYF-b$XtU zul{jS{PU_j{>YSa%!?7_)A5nqpaq6G`t(We@iI#jQ9gbQ?rB?AI#FO2{N+LAmxid~ zK}G!Z1}cZoUaSX|O#>CZYW`?Z%xB}#`DqIl%kPw6u%NH`xg*2@Qdc}f3D0Anz1Lt{G(@F<~~j(vQABb!Es5Sj4JSEMP__qe*MxJOK>2HXw5V?p4pSQg0>IGMjv?s6Z2FyqowusLTnhSFY7mR* zHsNp}62>$YfVGjra&^_Oud!oHTs183yx5hFE|gLPlSCvGXlS(#o}rSGYf`w|v6x*` zKYe*q6|~BHmFJxHTQ0Pjo{n+H$CL(EJIJB9UV*fQlvHBei|sD%U~YC7)qD?n2-h;U$cNra9eTZD+CtzJnY54W$gv^jwH6s zZ+>;RPfyOyPtK=j=eODPe|=kwPtGBy9G#pTF{>Q$5;ze`vA^mHSmk2?DjjL1Wv>rd z<>wELqWb0l7;Gxwxar9I*Lhdt+mrhhEl$qhFhSqhCRxN`ETxPH3rb(L%UJ9bEAOizkx+Q39md z4ev2f@hsHWJE;~Lfvdxv-B-hBKkvT2Aae^r%Zwx!DxI7TU!3n8exNY1!3*-Y$f%eq zY^`rGaSQw{dp~sc#2uT;_Mo{_4|enI<97%@db}upda4mj+@ss{ZrOWHEL|mM&_2G+ zD(f(yI|H`vQKSB8F=Yb?+ZvXSEN~`*)l6ghvBtS zhdmqUV}9mj&amV??wSQG@yB*s)4giSS!NB#y?Rl*-w>Z^_KdkDqGv7zRPvRP64V>E z(+HkTv;k(~GnXr+olkt>aSBpiQBcQA>Ek&*H zVu&9Hx1p0v0UI19CPQ3=6O=Zl1uUfwcNLf`Bf8+iX*_nKIt&i8pfE%mLjyrG{aDQ^ zygl_j(?0hWn^&6Af|Z5#c?v2SH>L-hKMW4hIQz^t-{MX&TnS1H-9xf!mgp#s+k=8t z#!&+EfPs)rb_o^45|ztBhr2U}r^1>I;~-Gj9PO#L`(<)1JP$zy$+KypmImf1Z#z*V z`xR1&^?rGBdA3xixl)s2#HTJ^m&`|av5x`)Xi=7%zGNXfs;$Nb) z>@S-}Lrht!pRHsw1U7L|T1+)cb1_`0|0L4DE^#+0w5Sd+L*kYwDv+w!$S$9`J_L6% zU?WLF)GeQNrgr><|8b!$(X1mTWCb3T($9DK9568&Nhmh|`&BEBmtw4VQCQmD;D$xjxJa}5oXv@W}ip)#&O@uil0z}$*oVj-5AawHjalSsYe9xsU@yVQ8GJj} z<;+ilY0?)pKQgqiod!Y1d`ty#hE*34TO^l^!-!FnqG}9VQ@(&w2vk;I7dL-7znnNw znVek|6u{qih zZrS+`UFAMdS+A~m9%!3Ywp)mDAF&(;KKo#`E%=QuH9)1GQVt}btpA)3w@1%+_8hl- z*n4xu+)^F(!?oc-ZNxAKgfR2O^%E zWtyHcKe&Ol#B!h2~|-FBNh-yAh6hLSs1Y-gH7aB=*C#}6Y=!xk(Nh16)}ggq!C3` zXb!S zVsTiwk-^5GQD6)(azA~E_wKxJ=1sHome}=pyah=ui=`(-SZErwJz9^7l%-6y)J2KK z*T3ix7jlQGQo}ujLj^edD}%YhT2pMqD?t)5w_f52;pPwO-Ydfuu~YMY(Ra&eWNoA# zeI0Ys^j?TLI^tymW+I63_sb8z!3-k_UA&X#xiY_T$Ap+~ddbDkp-E*b;}ljm$ylpx zC#ke9;`^TZ=0wOKwf|Aka(en9VGhADnT*oekElVQjM(8G^uV~=W)#s@nacL`=E)Jk z$9KB3W&#>%=K_NmCAQcm$29H0W1b31l`oGsqr3v4s5goJ!^Fs+`}Tnr)b%dvN-c^9 zA&Rkz#vd~+ETr|NLUp6ejDcGOe%Lhcz+*PQSuSpGuD@I_DYiJ1u6lejbEJWli<|oT zZnpYNV_@Q{*8t)&AOxipIM=Prq%oisN+0e+DB-PNu_I`KdVBt?YC}5`ZHh%fL18Nv zGzbRjxm%=El7I2UO*J37cNfWTw}y5vq6Qb=-sQFoSa>pooD0mmm>Z@|8SRHO-_zZ zk55mJPfm^*RE|%2q2;u1E}xD;EVdY3S|%1BWZbaVuVa;aLFLu!x5Lri_khZG z){}Rj<$GA=&lf>%6P#=vJl%f&bo&)A$1Ts@JsVrQ8=J!>x9r&JrhYk4*<1@MyFIS@ z4jVvDvD@295pAn|D7^GL?>iXN8uZd}ueFrY3o895e@Nz*{WqiMANF2d?7xln-(?(v zobA1dcVCT%&rf&ueD>v}W(Bc8Jyjn3M@jMvkl|@mVrpt`g z6$ULnlHLO8D#$1UR>kC{!8jJ8irM#~27Lew7qiU7e~%e6_bW4>i;5xyqLuTE84~J+ zBY7eV8jH56(2+4$rU3a=^pHR>ncd1|X>6DDpXih0b#?AbK&5CTiAje&GQsFkj7-Q4 zqZ^V?Y$0G!T5RcJ0zEFA;$YCstU zV`E8>s0%NLjYyf~VyVrdN2OL0kDho@KOz;ll-y?MOaxXziDF-h%{z&A#XnD>&J(^r z(>{|7m60;{RpUn*${yc?VKB*aOCZi<8k3cKn~DT{Arip5>QP5C$JSZ4;NsR;WZnm_ zkP~D)_*5>wEmiIqfm62RAY)HxrexaZkeF1Whywe&@k#hsC@0*JB8&ONzA8r+L@cD1 zV9?9ATUK+L=9;g{h1=P$c&sZ3*An~tyrE{0k41uvw}TIq;_`J4&FVF~LhU#I|n@uiyGT5U_8Uw@JLbTWzUvH-DCxkWowu9471xS|Qg z93X}^m>V&!c&9#TLDqI(O3c>{v>KrlO(hni|Ke_b``g9U^z8IJy1c5R>A!xN{p**- z*~$6o>Dh^bN^5~tj*pdCKq<$z~Ey?5{I+WTGd z-EI1y^`d?HN$=jhOC}u+q$LXZ)9x}@FelbnEnw@KVb-ixKy%OVn2vsRpQiG8@e(AG1u@F<%)`;%iA&NN!Xt}Dh_L+!@MbdI;v>UOX;RsojKL-emQB&G`wk97}<;h#t(g_7Z+v}r5t7?GhY?Q^Vx zWZq^miyp(fc{xC)j)!fT<9lyKCOeSeQ!e+*hWxbmk(mGw%Cam#RH>yV&j=M61Fmw;Qg1{*#E zY#693L9L4Sl) zfP-f*KOJ8pmDp@8k#r;0(;%ly61rG?n5amC0v5;-my;B(cQm(1J~I6|$DM5VzfOxA zg%I~^gLnfeVT2qEDtUEV&OYbWjm>?riN*c0Bx!{dA*v$b@vxliGzpvdDSs?oWD-7h zjUJduR5^CS*^1kqP^5^h-d<`k3owkiP>=&QQ7?O>&5!O+`u$gvIR__$@iu+G^ntjC zLlL40Y)bK8SOQqYsb5o6wQzSN)tit@q?&L~;*|GHXnuraUUij13@R$NToP8LQ;#un zzt~iGvqdn&8Ve_naY(To#=){Y%_Wug825_vpg0BQnIe(Ha`b_qzXCQ4RYD4B=P=|j z0^!VA_?X?s-A{sH;!F1NrFA31w1DgV0xCht3C=1CmfM$Sg?B{663>O=4tNGq-qU*l zgp4Q?AKOJbiKL2!MemtvIE{u4Gqh+5aBYS6>?%nRo)$AO=(tfOYJeDH%v>%tk(MjK zX<(^!VlrsinRr9Ln6NeOEaVfj+(>kCDkFTp2`8wd5hu4^>E|>88S64Y!uZ*QD?~sk zRQAXDyk_Ju$g!9#=>{o0S4628*%1ndPa|s1iQy*-4ikwF7Sj~kQLs%fZQKig0#u;Y z0A~1@T#YFHXH|>j=QazMjre3OIGb?74P>dKc)?O&G8I6tTIZ63P!sHY>hP0P!YopIBk&I`?Ag$oBeDaGh zo6E;`l}igCcI*LjU8bNS6IPrTOgCn?%mbf^8A`2{6o_&scFnTP3}uu!0Ss}m;3HKh zj4L)&wz#MO2fWl&|<9eslzHgnZ@^m3O7Fzu4wAlyB9BCyS2f7U$R9e{|<<coXvG#V8BBs(7?HtLb4l(Dg$1_?ub`7*<}-7gU;wzKrH(MtEvni9=!Bu_u%dO z4;SM+&fTx8m0Rvcq{CgJkode~SUa={F;V-6WCufg3ESlfB+|_7a&DMa$Pk~`?iawF zV-hR5r#_a7>4i4>w;ACI`TN~rZe^^r@PwHAjdI718oDQ9RzYML4Uf^IR){#0J{ADU z6j)VKy<^5LU}T72=unne{P9(!d#Q_tZ7m$h<-mh~|GC#%a{r8o8|K>QVK&JnlZmxN zl8XUIRJURyVNy3Kx{4GyYPy$o)th5pg{;z13P~kwj;BGdX*T8RNio**FxZtPeQAYu ztQTeVcF8{Y%%;M|;wCSdmn8U@i6z&^O2@Vh-h^a=VH?&@3d4`^NU6xs9&zX`)1aQ5 zT@#tG3LGYKmD>90Wn>5~B4T9`BUmA#@&Eu307*naRCp;y3*IP4T1Ti?%5!b{%b>Ab zj^oR5Bt{e-_abd%lJKlof{%hf9J7Nflc;o~K6v*1H?N~d>cL1PC9&{E5hwOBzI66#wNy^FxI z5P%`YgY_;D1%Sa9s?;l3^Ql&ahQ4mZKu?r%OBux1Un{PTp>v+Yud=F^cW%-1%x-Ml zi}^&-z^rN}7q>m#iF@d28C2Xi*HOyl#nm#6?~3IA{?k`SERIrWot>SWk*IQVdcuGD z1xh*LMYnkj>^N}St9`|m;fnXGgp*)41J+XnDXj8yuch9tx9=RNyyFs;SKA~D40#QL z@T(nTm8S$Me*irG9VWyArRDo%mW>vQtY3{R<4uE>0gEaJ%qp)oc8pt|Id0k5VrZdZ zu%XU|VKA&~tnb2ude%d*=1bR#+!IyeTrhEP&!nMBIu<0AN zq3vWZ2q130-|dD@vL2IKpTj^D74LW%>}(sTc=iZx1fyT;*3kFB2&fGAC`Ng>yEAM? zA)AIn6Jq-WEt5Rq9eK>9kmmH5qAf9%a(8Ktk0t-t!ja)t_civ(j5CNA7yu)vsPTK^ z7Pk1zS|iz`54}Y|r6vgl_La0)nUBTYUlOt0U;r$oi)t$oRVWH<7Ri7ar;stUAa_`N zIGFoNN~kW@m6_y{A^&P_Z(B zW`aGU`sO*THnU7&2^XS2-6m$HX|_;@YQzmA=_}H#p-9~+?AND4725sq1~F1UF=c#- zQc-A=dTwc8x{IRpWOc@)N3xJQn&<+b7iBL+F6uxyt z#>V7Ah6MC!%on2w%O)(X%mFYo@x>EdNofuA3uX`6f-{QDfi5NL*x5H6m=W_u7iF1o`` zYo(ZgaGH%M*zxbQ+6)Kny?*~lRZ9%%+G)C{)O-6etNWbR%s5(CZ_+2HbTpjavjUdv zjCPuzi5FFRnk9WTWMks1e+EoJWzZZOqdPmv4?vF0Z1T_VjLXedIW) z#&B}0FNHCnLRAY16pi~!%#U&SScwh2t~mq>cRQ70mL=RKzz@1X4i+-agIjX8+9R^8 zV1U6xFh535l`Z(M^gui_8%PRUQz=NPFaxGMLkCm+C8;zp$5#^{u~ll87t2aAFh-^k zy8g#SnQV*^6N^~q%|p+lU&1Gx?`iFj8gVn1Mo5mnfe+q+%H=qjL_W4S;tVKe6&_Wm zTw|%=aED>G9+$)Q5-hE0=C?RfoQM68ZANjaKJ^i|vX|wWLR|2c6ebugls(j2pGE3g zvCLvgi4}uNhCUWebE1^66jMML(@F|V=EPDP&s+SgwDBrXL9A_z^t9&N<^_=fI!uzQ zT?AbmnGIZ(Fyg~_%`;b2e<=ki-V=MIJ6PS1Ll5X-b$bbN7&MTXVe-D~v{Yk=IJ2mjVfFYJz78 zMO`J5=By)L_AY{2MxRCyL??^UUs9=`&$;Ngn(HcBvXeprLc8`3nk-BTYb_}j6jT!$e$}vM^?$C7E2!-eMTu> zY^E=@jHTweGLo=-`j%yUv8RQ*?KQJlqqJ5!MnztBvF0OWjMW(T47J<+lcgS6z@HHi z%u!&UsmX*RjFEke(n)(x6KbHDX!J1KX4q$dNBQeM%xX!GAAk zB%@-4Qk?WPyZ*Yo`^|l?FD@=GFUDmY|HEqbKmK|<9Z#U8^n;4PN&8L{RT`F+a2cqG zYsI|v-OOS()>~BZFJY(3&mBB0y{bnD%)lvM;w-d%Afi#tW|j$ ztS8M_+wXf7<(9V6($67(5L8+PEGD+>v%1213rQ|79k*=mJlotk+}t_X+S%6yIbvV_ z?5GxxK#0bX@au04y8SKv58n#b9~}VO_oB*!02Z9g4}Cl4L1%m2(p6e=M7JUS-gUnp z9Rh-Gl>60^`?Q*M$KU_eb?k8upL7_5S2K^R^NJzzZMS?Zz4X%m{B2eV%_@Q=jd8Dz znLYrOCbxh?n#|*)jXkuqe=4Z0<>1-NpFSL4xZQKGcV|~U)x)D=5|xfu)56M3psNJ# z7bFOqg299!gIl*m9f@O%Wl`QZ4&eahC8Cg-Se88Tb)9IuGIGQ8uPd*mBsIHV=0U-H zfw*MEbNOBrIt_)K95Y>7zZke#Ce|QH0bhym(pVx9J*-g0%qRGj zNi-5$kQWB(*r2bz+em91@hW2}h}5)Vw*lg7##1qct0zJG=_0xQT1l{&gNKyW6-n-r z0VJOCDy%`mM(stP5f_R~eBs74BS(s6dFjxXjD)G`!momUmAFt9Mfz2w2P2jaL{9fg z3p9L-H6ntpQiSFZt5U%|Tq<88j9A;{$+aRwR&0=fIb^DHU&01vaF={!p|42w5c8;r ztid~P8xo@f0;|l_W0Idw({gv)Lra&X3NOBs-W=7||Cj_YBPRPevoNkyE7A)|omya& z2!X=d8ImCqfa1&Ge!(1?52W&zdW06p^c00GEwB;+SP`f0`uWV!6~oQ+*1=MUg~oek z3M1Y?1#-|>7i^qBS{E}(ApvMI4yTnS0xG4L=Fy#E{*zp%ymS@|pG=_n__7!B==&Uk zCY&HQPr?jPpHhdE5_sP$PzSrTXQ;Des=@b2B}auKO%^VplT77$L178S_^xTy`Vwip z!El$ckR%|3aPU0x{G4#0qhgdlM$f}MV`E@|98ED^BDt#&Vp(tr5)Fn~oqXvw--{dG zk<*u0SGk3UKA(9|v4JrRIBDgg7*3LGrk)U)+Dho$kovX2AXZ*qYw`RtVWo(G0oIfX zR(mq0SFAz+5(2lqWQ8I1m{loqr;ubZyPYk*%(-R}Hyj?&bJ9N$>XFpWmK!dczlAtc zax;Cw30>!d&{E$}RDL$6ij00iQF)7U`geN=&s(6f)drPqj{Mq`*x7%F{{&Z_1nY-(5yu}< zZ&Ysa(9#xFdT}NE>iR~=9+`!RWx!&~t_+FoyxthR-5k8x9K708ZrLJkIcRc=?D!wr z{r5u1-dgL(eW3D~S6K%td*1^p4=XAy1AT9Hux^(J!!=%!M_|vIE|GAMsegv5=dV`h za?kPio?(yt(tVyaYD{yS%LcoA4ZCAY* z@f#A&djEanlm?@{{b%of{BV95NjrN!hr0t=SP}?E-g+7j!{`?q-)lfiT9S(~Wy=Sf zQf}0SeuH)wRHq$QT$quroN_H7`|A zBq^;^r9j-USXM-_5lR_zxYHy~rfG)j;&HBJ7jiYdhD8DXkOX}ldI>q!)^Hiw((K^a z&wYs^0#%|e+%4Ans>I2Q!o>}~W z3M_e~BUNEBi{gqe5Y(&eHDn+2;pnEnk@^+s(~6TMDKfe|FS-h=A3OoY9(4Otq^%^s zoW|$mx`{!9u|%b%P~o(ep42KD;v#WIj3}z}%+T=y7|3{ZDp3m)it1VI(Sw72dfk7Q z`h!I04`EF>Hpw-pTF5r>dW(c*(YM$X3|9CC9RL6T4K_(cK~y~4l8G-Xb)gT9uIq=1 zz}^S1O(~jA#-<&%qsU=_8$>6vBp86N=+8y4;z2BHQ_+{u4jC_nLH=S2N=kL)OUsQa zJTjsNQ3%CHma!%kE(qS>qSz?)5S2>Y)>5wbi=7?ATFZRKNnfRz=G8Z%qDrnqTx^#o z5rAQ4rGsHBVG^YDl3c*Yo4v$5CprUW6anu$79?W0*wI*z270I*Nb&`cFc>f_@sq1@ zg*r(%MvQ43AW^d(HPd4pe(3SUEmQ<~3kwR};Mp+tsL%%`^JA19j5-vIZ{2aH%B@&s zFa}oR#BSux&Th56UOMaxty>|0ncsv?9`0bd#YCBV=GE7~7xnV;@@hJslv(!sqWs_g z_$7-I29*ndiX)aTtF-hM@!UgE32-HVmf&_rX<64~&!UQU)%%d*pG{OTP$4rtB^1AZ z{pQ{7{^7%*;&$^i;52eKP?YiHQA)@ok>g2IZ~0RiONUx|2gACQ@*tD!H1BEmt^^Ze zyJTV^3;uJKSzfudxdU!_-sG0O&T42N>)}BZl`VsWbzx#nMdgoz%6`8`#a83hDRvn) zl^=rK;Zes}-@CK_P8xY&tKAV>wCXXuqThP#PM^X3ds<4mR2KTy?j z1G8{sc;$oRbLI^{yiTaV=mJm?fC0=fW2EIQVv&T^l^n6KdKq5b!#P_NbmNX)yJ;q} zi7WzRePLfU*NPO1TT=?=!5phti3$`IjiW6j-8J)*PO)@uRN8kT>V$MtLRw+iP-lz# z*^4Ggs#qm97>-n?Y-RDEP|R1vg6>YQz~?>Np{0HnT*E0=5FN$su-ca>$|eOCx>I1Umn^T9b)iT#iV(}v zdQlLurAvv9nTjxmQ7INy zAx|S=*Iz(8fy+h9=I6c_MU%(yS0NvcEoS;+LqCYf%47gDD20TtCaVeRObBvcny%@(!RvweX)^W(gprRg?#^~Dcp!w>DLMcP(rVQhWyLrU-5J>OF{c%(vB15ZDBzU+_ zxZ3SwkFas&K{i9fE0b68u$vgT1ivCLNg1DmL3gJ+GP;k{;@ zm$1rkw70u^u(x}-w|6*lxB@Q+cXf!?$Hw%ZF_r+-WWExWjjbc9_=1DZu#-& zqqEEC>@vQ%a+|`65JDT})nvl%`SO;7zZP7Zhz62dWKEKG&-jsweT8%tM>RQJ$%D@x z2725tV^;}}a=AxY#xd;mD3oMh{tZgy!(vM5r0195G8PF2fae5C3;9^sj>7$tRtgEI zsH7J%a{C)mT9E4%xjhX{lhaZ%ktRo6E0yc(1w1OEx`f3k6YY8AI4>z$Q3E|!H%d8) z!!7$WgOg+`J69<@k!&dTocMtp6whKN+g;{07_<<%$bh&T|4BbD~39hSg z6v5&`ZzuJq2VaeKo`67tE*JM$F|KGgiQ-!8gO6>NN(u=JnM z8>CYekSG$jOOrZtH9ydXwMsJ*KoW9Cp!e6_&)%q6@cJd? za6Y~iG;{MzZQk|8msA#<-f5LO?r%$CLl6@>gr>~!4U!N^D&Yw+jUo0UXDK>f&`D3B1=rHU$Z5jdYI0Xf z%?l%$R8mU03JfYnH4H6UqEh)17|$UJDkP!nz#AhCGQn2$^7G>MH}}Btc;Y~%NRrR9 z^54F#qN~Zp#U+FktW!}`P9+3P{%cZ704fenS~*~$qH^30FFt~t!mE{5{IN0pb&sO* z-azG@g34$;sO)SIlVF#>y-8ZZ9*YJnuxt*VZtMiy0(5!$q&?Qv1dJ#B|2+a)`kwP) z^65OM_vCMgz|v0|j~oMgh;w8-Y^Zw6ftXuJZ+T7JGI+T)c(FBjwlz518XVBt=|_vs z6xLo3s5EE5?pIW{lzbiyDr+qDJ&FoIrGLh$3o7emfmQ@o_o+H#POWsWc92)EoU)Ea z?nRD=ztm){ZfQ&}v!(71)YaKP$tt?9d9`@fe>Tq)l9)25V3-rBACDE%QRU}_<5AhvR2^OK70S zG!Z=&!-;mur(FM%YYSwdV`g+b4|kP71tuAX5Samwn(t9WXoZ7Xc!C>2*P*&lY7@C~PLcK~O96BHHqyr{q*Lx<-3uq;cNJ<_f7; zBMs~v#5W(SYd?scD1%4Y)S1a30&*~e`N#o9Qe`gDfdLlp^I%+Hi z8}_nm*cVDvprYWmW)s)qxo3@~nA1+3w%7b6*y{l*NcxgW7MT!RsDXZoj4x>^QRM`p zF;NrOIP#CyT=U9EG}gFh9}@!Y%laHGiI|y3*u&z^D<4SW)SZ{Az&8X>;_eC84xDD&D5zQ@?~# zK81ezrkwIi!=Un$Q3@lLkQXDC_LulFC+&*CQ{d4lP3|=v= zJXc(4qeauy-pv+UgDB>QF*T+=MfM0*3DBtj$fl%&sN(+z(?td!!9klJ00000NkvXX Hu0mjfHHsZd literal 0 HcmV?d00001 diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 43ee461e124a..452cadfebcc4 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -83,6 +83,7 @@ The following modules are available in the ``isaaclab_contrib`` extension: actuators assets controllers + deformable mdp rl sensors @@ -129,17 +130,10 @@ The following modules are available in the ``isaaclab_physx`` extension: physics renderers sensors - sim.schemas - sim.spawners .. toctree:: :hidden: - lab_physx/isaaclab_physx.assets - lab_physx/isaaclab_physx.cloner - lab_physx/isaaclab_physx.physics - lab_physx/isaaclab_physx.renderers - lab_physx/isaaclab_physx.sensors lab_physx/isaaclab_physx.sim.schemas lab_physx/isaaclab_physx.sim.spawners @@ -163,12 +157,7 @@ The following modules are available in the ``isaaclab_newton`` extension: .. toctree:: :hidden: - lab_newton/isaaclab_newton.assets - lab_newton/isaaclab_newton.cloner - lab_newton/isaaclab_newton.physics - lab_newton/isaaclab_newton.renderers - lab_newton/isaaclab_newton.sensors - lab_newton/isaaclab_newton.sim.schemas + lab_newton/isaaclab_newton.sim.spawners isaaclab_ov extension --------------------- @@ -182,11 +171,6 @@ The following modules are available in the ``isaaclab_ov`` extension: renderers -.. toctree:: - :hidden: - - lab_ov/isaaclab_ov.renderers - isaaclab_assets extension ------------------------- @@ -200,12 +184,6 @@ The following modules are available in the ``isaaclab_assets`` extension: robots sensors -.. toctree:: - :hidden: - - lab_assets/isaaclab_assets.robots - lab_assets/isaaclab_assets.sensors - isaaclab_visualizers extension ------------------------------ @@ -221,14 +199,6 @@ The following modules are available in the ``isaaclab_visualizers`` extension: rerun viser -.. toctree:: - :hidden: - - lab_visualizers/isaaclab_visualizers.kit - lab_visualizers/isaaclab_visualizers.newton - lab_visualizers/isaaclab_visualizers.rerun - lab_visualizers/isaaclab_visualizers.viser - isaaclab_ovphysx extension --------------------------- @@ -244,13 +214,6 @@ The following modules are available in the ``isaaclab_ovphysx`` extension: cloner physics -.. toctree:: - :hidden: - - lab_ovphysx/isaaclab_ovphysx.assets - lab_ovphysx/isaaclab_ovphysx.cloner - lab_ovphysx/isaaclab_ovphysx.physics - isaaclab_experimental extension -------------------------------- @@ -266,13 +229,6 @@ The following modules are available in the ``isaaclab_experimental`` extension: managers utils -.. toctree:: - :hidden: - - lab_experimental/isaaclab_experimental.envs - lab_experimental/isaaclab_experimental.managers - lab_experimental/isaaclab_experimental.utils - isaaclab_tasks_experimental extension -------------------------------------- diff --git a/docs/source/api/lab/isaaclab.assets.rst b/docs/source/api/lab/isaaclab.assets.rst index 28356330b600..b7b980c24231 100644 --- a/docs/source/api/lab/isaaclab.assets.rst +++ b/docs/source/api/lab/isaaclab.assets.rst @@ -15,6 +15,11 @@ RigidObjectCollection RigidObjectCollectionData RigidObjectCollectionCfg + BaseDeformableObject + BaseDeformableObjectData + DeformableObject + DeformableObjectData + DeformableObjectCfg Articulation ArticulationData ArticulationCfg @@ -71,6 +76,37 @@ Rigid Object Collection :show-inheritance: :exclude-members: __init__, class_type +Deformable Object +----------------- + +.. autoclass:: DeformableObject + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: BaseDeformableObject + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: DeformableObjectData + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: BaseDeformableObjectData + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: DeformableObjectCfg + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__, class_type, InitialStateCfg + Articulation ------------ diff --git a/docs/source/api/lab/isaaclab.sim.schemas.rst b/docs/source/api/lab/isaaclab.sim.schemas.rst index bb0651c83ec2..ea29860cbe4e 100644 --- a/docs/source/api/lab/isaaclab.sim.schemas.rst +++ b/docs/source/api/lab/isaaclab.sim.schemas.rst @@ -19,6 +19,9 @@ isaaclab.sim.schemas JointDriveBaseCfg MeshCollisionBaseCfg MassPropertiesCfg + JointDrivePropertiesCfg + FixedTendonPropertiesCfg + DeformableBodyPropertiesBaseCfg .. rubric:: Mesh collision approximations (USD-only, no PhysX schema) @@ -44,9 +47,8 @@ isaaclab.sim.schemas define_mesh_collision_properties modify_mesh_collision_properties modify_fixed_tendon_properties - modify_spatial_tendon_properties - -.. currentmodule:: isaaclab.sim.schemas + define_deformable_body_properties + modify_deformable_body_properties Articulation Root ----------------- @@ -158,11 +160,10 @@ Tendon cfg classes are PhysX-only and live in Deformable Body --------------- -.. note:: - - Deformable body schemas have moved to the PhysX backend extension. See - :class:`isaaclab_physx.sim.schemas.DeformableBodyPropertiesCfg`, - :func:`isaaclab_physx.sim.schemas.define_deformable_body_properties`, and - :func:`isaaclab_physx.sim.schemas.modify_deformable_body_properties`. +.. autoclass:: DeformableBodyPropertiesBaseCfg + :members: + :show-inheritance: + :exclude-members: __init__ - For migration details, see :ref:`migrating-deformables`. +.. autofunction:: define_deformable_body_properties +.. autofunction:: modify_deformable_body_properties diff --git a/docs/source/api/lab/isaaclab.sim.spawners.rst b/docs/source/api/lab/isaaclab.sim.spawners.rst index a31968edfe07..d42fa13e524e 100644 --- a/docs/source/api/lab/isaaclab.sim.spawners.rst +++ b/docs/source/api/lab/isaaclab.sim.spawners.rst @@ -21,6 +21,7 @@ SpawnerCfg RigidObjectSpawnerCfg + DeformableObjectSpawnerCfg Spawners -------- @@ -34,12 +35,10 @@ Spawners :show-inheritance: :exclude-members: __init__ -.. note:: - - ``DeformableObjectSpawnerCfg`` has moved to the PhysX backend extension. See - :class:`isaaclab_physx.sim.spawners.DeformableObjectSpawnerCfg`. - - For migration details, see :ref:`migrating-deformables`. +.. autoclass:: DeformableObjectSpawnerCfg + :members: + :show-inheritance: + :exclude-members: __init__ Shapes ------ @@ -110,6 +109,7 @@ Meshes MeshConeCfg MeshCuboidCfg MeshCylinderCfg + MeshRectangleCfg MeshSphereCfg .. autoclass:: MeshCfg @@ -144,6 +144,13 @@ Meshes :show-inheritance: :exclude-members: __init__, func +.. autofunction:: spawn_mesh_rectangle + +.. autoclass:: MeshRectangleCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + .. autofunction:: spawn_mesh_sphere .. autoclass:: MeshSphereCfg @@ -261,6 +268,10 @@ Materials GlassMdlCfg PhysicsMaterialCfg RigidBodyMaterialCfg + DeformableBodyMaterialBaseCfg + SurfaceDeformableBodyMaterialBaseCfg + DeformableBodyMaterialCfg + SurfaceDeformableBodyMaterialCfg Visual Materials ~~~~~~~~~~~~~~~~ @@ -298,15 +309,36 @@ Physical Materials :members: :exclude-members: __init__, func +.. autofunction:: spawn_deformable_body_material + +.. autoclass:: DeformableBodyMaterialBaseCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: SurfaceDeformableBodyMaterialBaseCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + .. note:: - ``DeformableBodyMaterialCfg``, ``SurfaceDeformableBodyMaterialCfg``, and - ``spawn_deformable_body_material`` have moved to the PhysX backend extension. See - :class:`isaaclab_physx.sim.spawners.materials.DeformableBodyMaterialCfg`, - :class:`isaaclab_physx.sim.spawners.materials.SurfaceDeformableBodyMaterialCfg`, and - :func:`isaaclab_physx.sim.spawners.materials.spawn_deformable_body_material`. + Backend-specific deformable material cfgs live in + :mod:`isaaclab_physx.sim.spawners.materials` and + :mod:`isaaclab_newton.sim.spawners.materials`. The legacy default names below + are forwarded to the deprecated PhysX aliases for compatibility. - For migration details, see :ref:`migrating-deformables`. +.. autoclass:: DeformableBodyMaterialCfg + :no-index: + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: SurfaceDeformableBodyMaterialCfg + :no-index: + :members: + :show-inheritance: + :exclude-members: __init__, func Wrappers -------- diff --git a/docs/source/api/lab_contrib/isaaclab_contrib.deformable.rst b/docs/source/api/lab_contrib/isaaclab_contrib.deformable.rst new file mode 100644 index 000000000000..0267a922c5c8 --- /dev/null +++ b/docs/source/api/lab_contrib/isaaclab_contrib.deformable.rst @@ -0,0 +1,73 @@ +isaaclab_contrib.deformable +=========================== + +.. automodule:: isaaclab_contrib.deformable + + .. rubric:: Classes + + .. autosummary:: + + deformable_object.DeformableObject + deformable_object_data.DeformableObjectData + newton_manager_cfg.VBDSolverCfg + newton_manager_cfg.CoupledMJWarpVBDSolverCfg + newton_manager_cfg.CoupledFeatherstoneVBDSolverCfg + newton_manager_cfg.NewtonModelCfg + vbd_manager.NewtonVBDManager + coupled_mjwarp_vbd_manager.NewtonCoupledMJWarpVBDManager + coupled_featherstone_vbd_manager.NewtonCoupledFeatherstoneVBDManager + +Deformable Object +----------------- + +.. autoclass:: isaaclab_contrib.deformable.deformable_object.DeformableObject + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: isaaclab_contrib.deformable.deformable_object_data.DeformableObjectData + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__ + +Newton Solver Configurations +---------------------------- + +.. autoclass:: isaaclab_contrib.deformable.newton_manager_cfg.VBDSolverCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: isaaclab_contrib.deformable.newton_manager_cfg.CoupledMJWarpVBDSolverCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: isaaclab_contrib.deformable.newton_manager_cfg.CoupledFeatherstoneVBDSolverCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: isaaclab_contrib.deformable.newton_manager_cfg.NewtonModelCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Newton Solver Managers +---------------------- + +.. autoclass:: isaaclab_contrib.deformable.vbd_manager.NewtonVBDManager + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: isaaclab_contrib.deformable.coupled_mjwarp_vbd_manager.NewtonCoupledMJWarpVBDManager + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: isaaclab_contrib.deformable.coupled_featherstone_vbd_manager.NewtonCoupledFeatherstoneVBDManager + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/source/api/lab_experimental/isaaclab_experimental.envs.rst b/docs/source/api/lab_experimental/isaaclab_experimental.envs.rst new file mode 100644 index 000000000000..6e87a9a17cb9 --- /dev/null +++ b/docs/source/api/lab_experimental/isaaclab_experimental.envs.rst @@ -0,0 +1,4 @@ +isaaclab\_experimental.envs +=========================== + +.. automodule:: isaaclab_experimental.envs diff --git a/docs/source/api/lab_experimental/isaaclab_experimental.managers.rst b/docs/source/api/lab_experimental/isaaclab_experimental.managers.rst new file mode 100644 index 000000000000..c0f56ed1a6a7 --- /dev/null +++ b/docs/source/api/lab_experimental/isaaclab_experimental.managers.rst @@ -0,0 +1,4 @@ +isaaclab\_experimental.managers +=============================== + +.. automodule:: isaaclab_experimental.managers diff --git a/docs/source/api/lab_experimental/isaaclab_experimental.utils.rst b/docs/source/api/lab_experimental/isaaclab_experimental.utils.rst new file mode 100644 index 000000000000..49d7bd6dcdb9 --- /dev/null +++ b/docs/source/api/lab_experimental/isaaclab_experimental.utils.rst @@ -0,0 +1,4 @@ +isaaclab\_experimental.utils +============================ + +.. automodule:: isaaclab_experimental.utils diff --git a/docs/source/api/lab_newton/isaaclab_newton.assets.rst b/docs/source/api/lab_newton/isaaclab_newton.assets.rst index 8dc6f9b8f58d..f044f9b089fb 100644 --- a/docs/source/api/lab_newton/isaaclab_newton.assets.rst +++ b/docs/source/api/lab_newton/isaaclab_newton.assets.rst @@ -2,3 +2,82 @@ ======================= .. automodule:: isaaclab_newton.assets + + .. rubric:: Classes + + .. autosummary:: + + Articulation + ArticulationData + RigidObject + RigidObjectData + RigidObjectCollection + RigidObjectCollectionData + DeformableObject + DeformableObjectData + +.. currentmodule:: isaaclab_newton.assets + +Articulation +------------ + +.. autoclass:: Articulation + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: ArticulationData + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__ + +Rigid Object +------------ + +.. autoclass:: RigidObject + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: RigidObjectData + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__ + +Rigid Object Collection +----------------------- + +.. autoclass:: RigidObjectCollection + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: RigidObjectCollectionData + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__ + +Deformable Object +----------------- + +.. note:: + + :class:`isaaclab.assets.DeformableObjectCfg` is the shared configuration + class for deformable objects. The Newton extension exposes the Newton + implementation of :class:`isaaclab.assets.DeformableObject`, while + deformable schema and material cfgs referenced by ``spawn`` remain + backend-specific. + +.. autoclass:: DeformableObject + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: DeformableObjectData + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__ diff --git a/docs/source/api/lab_newton/isaaclab_newton.physics.rst b/docs/source/api/lab_newton/isaaclab_newton.physics.rst index 692293ce88e7..b452f3ad538b 100644 --- a/docs/source/api/lab_newton/isaaclab_newton.physics.rst +++ b/docs/source/api/lab_newton/isaaclab_newton.physics.rst @@ -2,3 +2,103 @@ ======================== .. automodule:: isaaclab_newton.physics + + .. rubric:: Classes + + .. autosummary:: + + NewtonManager + NewtonCfg + NewtonSolverCfg + MJWarpSolverCfg + XPBDSolverCfg + FeatherstoneSolverCfg + KaminoSolverCfg + NewtonCollisionPipelineCfg + HydroelasticSDFCfg + NewtonShapeCfg + NewtonMJWarpManager + NewtonXPBDManager + NewtonFeatherstoneManager + NewtonKaminoManager + +.. currentmodule:: isaaclab_newton.physics + +Physics Manager +--------------- + +.. autoclass:: NewtonManager + :members: + :inherited-members: + :show-inheritance: + +Physics Configuration +--------------------- + +.. autoclass:: NewtonCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: NewtonSolverCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: MJWarpSolverCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: XPBDSolverCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: FeatherstoneSolverCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: KaminoSolverCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: NewtonCollisionPipelineCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: HydroelasticSDFCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: NewtonShapeCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Solver Managers +--------------- + +.. autoclass:: NewtonMJWarpManager + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: NewtonXPBDManager + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: NewtonFeatherstoneManager + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: NewtonKaminoManager + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/source/api/lab_newton/isaaclab_newton.sim.schemas.rst b/docs/source/api/lab_newton/isaaclab_newton.sim.schemas.rst index f0dde258fe8c..78fb53b7098c 100644 --- a/docs/source/api/lab_newton/isaaclab_newton.sim.schemas.rst +++ b/docs/source/api/lab_newton/isaaclab_newton.sim.schemas.rst @@ -16,6 +16,7 @@ isaaclab_newton.sim.schemas .. autosummary:: + NewtonDeformableBodyPropertiesCfg NewtonRigidBodyPropertiesCfg NewtonJointDrivePropertiesCfg NewtonCollisionPropertiesCfg @@ -32,6 +33,17 @@ isaaclab_newton.sim.schemas .. currentmodule:: isaaclab_newton.sim.schemas +Deformable Body +--------------- + +.. autoclass:: NewtonDeformableBodyPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +Schema define and modify functions remain unified in +:mod:`isaaclab.sim.schemas`. + Rigid Body ---------- diff --git a/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst new file mode 100644 index 000000000000..d60b953ce5c8 --- /dev/null +++ b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst @@ -0,0 +1,33 @@ +isaaclab_newton.sim.spawners +============================ + +.. automodule:: isaaclab_newton.sim.spawners.materials + + .. rubric:: Classes + + .. autosummary:: + + NewtonDeformableBodyMaterialCfg + NewtonDeformableMaterialCfg + NewtonSurfaceDeformableBodyMaterialCfg + +Deformable Materials +-------------------- + +Newton provides the backend-specific deformable material cfgs. Deformable material spawning is unified in +:func:`isaaclab.sim.spawners.materials.spawn_deformable_body_material`. + +.. autoclass:: NewtonDeformableBodyMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: NewtonDeformableMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: NewtonSurfaceDeformableBodyMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func diff --git a/docs/source/api/lab_ovphysx/isaaclab_ovphysx.assets.rst b/docs/source/api/lab_ovphysx/isaaclab_ovphysx.assets.rst new file mode 100644 index 000000000000..165c12772c06 --- /dev/null +++ b/docs/source/api/lab_ovphysx/isaaclab_ovphysx.assets.rst @@ -0,0 +1,4 @@ +isaaclab\_ovphysx.assets +======================== + +.. automodule:: isaaclab_ovphysx.assets diff --git a/docs/source/api/lab_ovphysx/isaaclab_ovphysx.cloner.rst b/docs/source/api/lab_ovphysx/isaaclab_ovphysx.cloner.rst new file mode 100644 index 000000000000..7467364b075d --- /dev/null +++ b/docs/source/api/lab_ovphysx/isaaclab_ovphysx.cloner.rst @@ -0,0 +1,4 @@ +isaaclab\_ovphysx.cloner +======================== + +.. automodule:: isaaclab_ovphysx.cloner diff --git a/docs/source/api/lab_ovphysx/isaaclab_ovphysx.physics.rst b/docs/source/api/lab_ovphysx/isaaclab_ovphysx.physics.rst new file mode 100644 index 000000000000..aa8c37a4af57 --- /dev/null +++ b/docs/source/api/lab_ovphysx/isaaclab_ovphysx.physics.rst @@ -0,0 +1,4 @@ +isaaclab\_ovphysx.physics +========================= + +.. automodule:: isaaclab_ovphysx.physics diff --git a/docs/source/api/lab_physx/isaaclab_physx.assets.rst b/docs/source/api/lab_physx/isaaclab_physx.assets.rst index b631dcbcb7b5..851a01f9b6e0 100644 --- a/docs/source/api/lab_physx/isaaclab_physx.assets.rst +++ b/docs/source/api/lab_physx/isaaclab_physx.assets.rst @@ -16,7 +16,6 @@ RigidObjectCollectionData DeformableObject DeformableObjectData - DeformableObjectCfg SurfaceGripper SurfaceGripperCfg @@ -78,11 +77,13 @@ Deformable Object :show-inheritance: :exclude-members: __init__ -.. autoclass:: DeformableObjectCfg - :members: - :inherited-members: - :show-inheritance: - :exclude-members: __init__, class_type, InitialStateCfg +.. note:: + + :class:`isaaclab.assets.DeformableObjectCfg` is the shared configuration + class for deformable objects. The PhysX extension provides the PhysX + implementation of :class:`isaaclab.assets.DeformableObject`, while + deformable schema and material cfgs referenced by ``spawn`` remain + backend-specific. Surface Gripper --------------- diff --git a/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst b/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst index 305269068991..7a01585545da 100644 --- a/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst +++ b/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst @@ -49,6 +49,9 @@ isaaclab_physx.sim.schemas .. autosummary:: + OmniPhysicsDeformableBodyPropertiesCfg + PhysxDeformableCollisionPropertiesCfg + PhysxDeformableBodyPropertiesCfg DeformableBodyPropertiesCfg .. rubric:: Functions @@ -136,10 +139,25 @@ Tendon Deformable Body --------------- +.. autoclass:: OmniPhysicsDeformableBodyPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: PhysxDeformableCollisionPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: PhysxDeformableBodyPropertiesCfg + :members: + :show-inheritance: + :exclude-members: __init__ + .. autoclass:: DeformableBodyPropertiesCfg :members: :show-inheritance: :exclude-members: __init__ -.. autofunction:: define_deformable_body_properties -.. autofunction:: modify_deformable_body_properties +Schema define and modify functions remain unified in +:mod:`isaaclab.sim.schemas`. diff --git a/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst b/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst index 27463a44425d..7a2ace2a33ff 100644 --- a/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst +++ b/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst @@ -1,45 +1,41 @@ isaaclab_physx.sim.spawners =========================== -.. automodule:: isaaclab_physx.sim.spawners - - .. rubric:: Submodules - - .. autosummary:: - - materials +.. automodule:: isaaclab_physx.sim.spawners.materials .. rubric:: Classes .. autosummary:: - DeformableObjectSpawnerCfg + PhysxDeformableBodyMaterialCfg + PhysxSurfaceDeformableBodyMaterialCfg + PhysXDeformableMaterialCfg + DeformableBodyMaterialCfg + SurfaceDeformableBodyMaterialCfg -.. currentmodule:: isaaclab_physx.sim.spawners +Deformable Materials +-------------------- -Spawners --------- +PhysX provides the backend-specific deformable material cfgs. Deformable material spawning is unified in +:func:`isaaclab.sim.spawners.materials.spawn_deformable_body_material`. -.. autoclass:: DeformableObjectSpawnerCfg +.. autoclass:: PhysxDeformableBodyMaterialCfg :members: :show-inheritance: - :exclude-members: __init__ - -Materials ---------- - -.. automodule:: isaaclab_physx.sim.spawners.materials - - .. rubric:: Classes - - .. autosummary:: + :exclude-members: __init__, func - DeformableBodyMaterialCfg - SurfaceDeformableBodyMaterialCfg +.. autoclass:: PhysxSurfaceDeformableBodyMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func -.. currentmodule:: isaaclab_physx.sim.spawners.materials +.. autoclass:: PhysXDeformableMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func -.. autofunction:: spawn_deformable_body_material +Deprecated Aliases +------------------ .. autoclass:: DeformableBodyMaterialCfg :members: diff --git a/docs/source/migration/migrating_deformables.rst b/docs/source/migration/migrating_deformables.rst index efb7f7da689e..cce2e15fa3e4 100644 --- a/docs/source/migration/migrating_deformables.rst +++ b/docs/source/migration/migrating_deformables.rst @@ -7,8 +7,11 @@ Migration of Deformables In the newer versions of Omni Physics (107.0 and later), the old deformable body functionality has become deprecated. The following sections describe the changes to migrate to the new Omni Physics API, specifically moving away from -Soft Bodies and towards Surface and Volume Deformables. We currently only support deformable bodies in the PhysX -backend, hence these features are implemented in ``isaaclab_physx``. +Soft Bodies and towards Surface and Volume Deformables. The deformable object asset classes remain in +``isaaclab.assets``. Schema define/modify functions remain unified in ``isaaclab.sim.schemas``, and deformable +material spawning remains unified in ``isaaclab.sim.spawners.materials``. Deformable property and material +configuration classes are backend-specific: PhysX configurations live in ``isaaclab_physx.sim`` and Newton +configurations live in ``isaaclab_newton.sim``. .. note:: @@ -31,8 +34,12 @@ With the new Omni Physics API, deformable bodies are split into two distinct typ The type of deformable is determined by the **physics material** assigned to the object: -- :class:`~isaaclab_physx.sim.DeformableBodyMaterialCfg` creates a **volume** deformable. -- :class:`~isaaclab_physx.sim.SurfaceDeformableBodyMaterialCfg` creates a **surface** deformable. +- :class:`~isaaclab_physx.sim.PhysxDeformableBodyMaterialCfg` creates a PhysX **volume** deformable. +- :class:`~isaaclab_physx.sim.PhysxSurfaceDeformableBodyMaterialCfg` creates a PhysX **surface** deformable. +- :class:`~isaaclab_newton.sim.spawners.materials.NewtonDeformableBodyMaterialCfg` creates a Newton + **volume** deformable. +- :class:`~isaaclab_newton.sim.spawners.materials.NewtonSurfaceDeformableBodyMaterialCfg` creates a Newton + **surface** deformable. Migration from the Old API @@ -41,8 +48,8 @@ Migration from the Old API Import Changes ^^^^^^^^^^^^^^ -All deformable-related classes have moved from ``isaaclab`` to ``isaaclab_physx``. The table below summarizes the -import changes: +Deformable object cfgs remain in ``isaaclab.assets``. Deformable schema and material cfgs should be imported +from the physics backend package: .. list-table:: :header-rows: 1 @@ -51,15 +58,26 @@ import changes: * - Old Import - New Import * - ``from isaaclab.sim import DeformableBodyPropertiesCfg`` - - ``from isaaclab_physx.sim import DeformableBodyPropertiesCfg`` + - ``from isaaclab_physx.sim import PhysxDeformableBodyPropertiesCfg`` * - ``from isaaclab.sim import DeformableBodyMaterialCfg`` - - ``from isaaclab_physx.sim import DeformableBodyMaterialCfg`` + - ``from isaaclab_physx.sim import PhysxDeformableBodyMaterialCfg`` + * - ``from isaaclab.sim import SurfaceDeformableBodyMaterialCfg`` + - ``from isaaclab_physx.sim import PhysxSurfaceDeformableBodyMaterialCfg`` + * - ``from isaaclab.sim import DeformableBodyPropertiesCfg`` + - ``from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg`` + * - ``from isaaclab.sim import DeformableBodyMaterialCfg`` + - ``from isaaclab_newton.sim.spawners.materials import NewtonDeformableBodyMaterialCfg`` + * - ``from isaaclab.sim import SurfaceDeformableBodyMaterialCfg`` + - ``from isaaclab_newton.sim.spawners.materials import NewtonSurfaceDeformableBodyMaterialCfg`` + * - ``from isaaclab_physx.assets import DeformableObjectCfg`` + - ``from isaaclab.assets import DeformableObjectCfg`` Removed Properties ^^^^^^^^^^^^^^^^^^ -The following properties have been **removed** from :class:`~isaaclab_physx.sim.DeformableBodyPropertiesCfg`: +The following properties have been **removed** from +:class:`~isaaclab_physx.sim.PhysxDeformableBodyPropertiesCfg`: - ``collision_simplification`` and related parameters (``collision_simplification_remeshing``, ``collision_simplification_target_triangle_count``, ``collision_simplification_force_conforming``, @@ -75,8 +93,11 @@ The following properties have been **removed** from :class:`~isaaclab_physx.sim. Added Properties ^^^^^^^^^^^^^^^^ -The following properties have been **added** to :class:`~isaaclab_physx.sim.DeformableBodyPropertiesCfg`: +The following properties have been **added** to +:class:`~isaaclab_physx.sim.PhysxDeformableBodyPropertiesCfg`: +- ``deformable_body_enabled``, ``kinematic_enabled``, and ``mass`` — OmniPhysics + deformable body properties owned by the PhysX backend cfg. - ``linear_damping`` — linear damping coefficient [1/s]. - ``max_linear_velocity`` — maximum allowable linear velocity [m/s]. A negative value lets the simulation choose a per-vertex value dynamically (currently only supported for surface deformables). @@ -93,14 +114,20 @@ For a full description of all available properties, refer to the `PhysX deformab Material Changes ^^^^^^^^^^^^^^^^ -The old :class:`DeformableBodyMaterialCfg` (from ``isaaclab.sim``) has been replaced by a new hierarchy in -``isaaclab_physx``: +The deformable material hierarchy is now split by backend: -- :class:`~isaaclab_physx.sim.DeformableBodyMaterialCfg` — for volume deformables. Contains ``density``, +- :class:`~isaaclab.sim.DeformableBodyMaterialBaseCfg` and + :class:`~isaaclab.sim.SurfaceDeformableBodyMaterialBaseCfg` — empty base classes for backend-specific + deformable material configs. +- :class:`~isaaclab_physx.sim.PhysxDeformableBodyMaterialCfg` — for PhysX volume deformables. Contains ``density``, ``static_friction``, ``dynamic_friction``, ``youngs_modulus``, ``poissons_ratio``, and ``elasticity_damping``. -- :class:`~isaaclab_physx.sim.SurfaceDeformableBodyMaterialCfg` — extends the volume material config with +- :class:`~isaaclab_physx.sim.PhysxSurfaceDeformableBodyMaterialCfg` — extends the PhysX volume material config with surface-specific properties: ``surface_thickness``, ``surface_stretch_stiffness``, ``surface_shear_stiffness``, ``surface_bend_stiffness``, and ``bend_damping``. +- :class:`~isaaclab_newton.sim.spawners.materials.NewtonDeformableBodyMaterialCfg` and + :class:`~isaaclab_newton.sim.spawners.materials.NewtonSurfaceDeformableBodyMaterialCfg` contain Newton-specific + fields such as density, particle radius, direct Lame parameters ``k_mu``/``k_lambda`` for volume deformables, + and VBD stiffness parameters for surface deformables. The old ``damping_scale`` property has been removed. Use ``elasticity_damping`` directly instead. @@ -139,19 +166,19 @@ Volume Deformable (Before and After) **After**: .. code-block:: python - :emphasize-lines: 1,2,3 + :emphasize-lines: 1,2 import isaaclab.sim as sim_utils - from isaaclab_physx.assets import DeformableObject, DeformableObjectCfg - from isaaclab_physx.sim import DeformableBodyPropertiesCfg, DeformableBodyMaterialCfg + from isaaclab.assets import DeformableObject, DeformableObjectCfg + from isaaclab_physx.sim import PhysxDeformableBodyMaterialCfg, PhysxDeformableBodyPropertiesCfg cfg = DeformableObjectCfg( prim_path="/World/Origin.*/Cube", spawn=sim_utils.MeshCuboidCfg( size=(0.2, 0.2, 0.2), - deformable_props=DeformableBodyPropertiesCfg(), + deformable_props=PhysxDeformableBodyPropertiesCfg(), visual_material=sim_utils.PreviewSurfaceCfg(), - physics_material=DeformableBodyMaterialCfg(poissons_ratio=0.4, youngs_modulus=1e5), + physics_material=PhysxDeformableBodyMaterialCfg(poissons_ratio=0.4, youngs_modulus=1e5), ), ) cube_object = DeformableObject(cfg=cfg) @@ -159,23 +186,23 @@ Volume Deformable (Before and After) Surface Deformable (New) ^^^^^^^^^^^^^^^^^^^^^^^^ -Surface deformables use :class:`~isaaclab.sim.spawners.meshes.MeshSquareCfg` for 2D meshes, combined with -:class:`~isaaclab_physx.sim.SurfaceDeformableBodyMaterialCfg`: +Surface deformables use :class:`~isaaclab.sim.spawners.meshes.MeshRectangleCfg` for 2D meshes, combined with +:class:`~isaaclab_physx.sim.PhysxSurfaceDeformableBodyMaterialCfg`: .. code-block:: python import isaaclab.sim as sim_utils - from isaaclab_physx.assets import DeformableObject, DeformableObjectCfg - from isaaclab_physx.sim import DeformableBodyPropertiesCfg, SurfaceDeformableBodyMaterialCfg + from isaaclab.assets import DeformableObject, DeformableObjectCfg + from isaaclab_physx.sim import PhysxDeformableBodyPropertiesCfg, PhysxSurfaceDeformableBodyMaterialCfg cfg = DeformableObjectCfg( prim_path="/World/Origin.*/Cloth", - spawn=sim_utils.MeshSquareCfg( - size=1.5, + spawn=sim_utils.MeshRectangleCfg( + size=(1.5, 1.5), resolution=(21, 21), - deformable_props=DeformableBodyPropertiesCfg(), + deformable_props=PhysxDeformableBodyPropertiesCfg(), visual_material=sim_utils.PreviewSurfaceCfg(), - physics_material=SurfaceDeformableBodyMaterialCfg(poissons_ratio=0.4, youngs_modulus=1e5), + physics_material=PhysxSurfaceDeformableBodyMaterialCfg(poissons_ratio=0.4, youngs_modulus=1e5), ), ) cloth_object = DeformableObject(cfg=cfg) @@ -189,8 +216,8 @@ Deformable properties can also be applied to imported USD assets using .. code-block:: python import isaaclab.sim as sim_utils - from isaaclab_physx.assets import DeformableObject, DeformableObjectCfg - from isaaclab_physx.sim import DeformableBodyPropertiesCfg, DeformableBodyMaterialCfg + from isaaclab.assets import DeformableObject, DeformableObjectCfg + from isaaclab_physx.sim import PhysxDeformableBodyMaterialCfg, PhysxDeformableBodyPropertiesCfg from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR @@ -198,8 +225,8 @@ Deformable properties can also be applied to imported USD assets using prim_path="/World/Origin.*/Teddy", spawn=sim_utils.UsdFileCfg( usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Objects/Teddy_Bear/teddy_bear.usd", - deformable_props=DeformableBodyPropertiesCfg(), - physics_material=DeformableBodyMaterialCfg(poissons_ratio=0.4, youngs_modulus=1e5), + deformable_props=PhysxDeformableBodyPropertiesCfg(), + physics_material=PhysxDeformableBodyMaterialCfg(poissons_ratio=0.4, youngs_modulus=1e5), scale=[0.05, 0.05, 0.05], ), ) @@ -210,12 +237,13 @@ Limitations ~~~~~~~~~~~ - **Kinematic targets are volume-only.** Calling - :meth:`~isaaclab_physx.assets.DeformableObject.write_nodal_kinematic_target_to_sim_index` on a surface + :meth:`~isaaclab.assets.DeformableObject.write_nodal_kinematic_target_to_sim_index` on a surface deformable will raise a ``ValueError``. - **Surface-specific solver properties** (``collision_pair_update_frequency``, ``collision_iteration_multiplier``) have no effect on volume deformables. -- **Deformables are PhysX-only.** The ``isaaclab_physx`` extension is required; other physics backends - do not support deformable bodies through Isaac Lab yet. +- **Newton deformables are experimental.** They are implemented in + :mod:`isaaclab_contrib.deformable` and currently target VBD-based solvers and + coupled rigid-deformable workflows. .. _Omni Physics documentation: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/110.0/dev_guide/deformables/deformable_bodies.html diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index efa7d8678023..fc8657658aa6 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -69,9 +69,9 @@ New ``isaaclab_physx`` and ``isaaclab_newton`` Extensions Two new backend extensions have been introduced: -- **``isaaclab_physx``** — PhysX-specific implementations of all asset and sensor classes. -- **``isaaclab_newton``** — Newton-specific implementations of asset classes (Articulation and - RigidObject). +- **``isaaclab_physx``** — PhysX-specific implementations of asset and sensor classes. +- **``isaaclab_newton``** — Newton-specific implementations of supported asset classes, including + articulations, rigid objects, and deformable objects. The following classes have been moved to ``isaaclab_physx``: @@ -81,17 +81,18 @@ The following classes have been moved to ``isaaclab_physx``: * - Isaac Lab 2.x - Isaac Lab 3.0 - * - ``from isaaclab.assets import DeformableObject`` - - ``from isaaclab_physx.assets import DeformableObject`` - * - ``from isaaclab.assets import DeformableObjectCfg`` - - ``from isaaclab_physx.assets import DeformableObjectCfg`` - * - ``from isaaclab.assets import DeformableObjectData`` - - ``from isaaclab_physx.assets import DeformableObjectData`` * - ``from isaaclab.assets import SurfaceGripper`` - ``from isaaclab_physx.assets import SurfaceGripper`` * - ``from isaaclab.assets import SurfaceGripperCfg`` - ``from isaaclab_physx.assets import SurfaceGripperCfg`` +.. note:: + + Deformable object public APIs remain in the backend-neutral ``isaaclab`` + package. Continue importing :class:`~isaaclab.assets.DeformableObject`, + :class:`~isaaclab.assets.DeformableObjectCfg`, and + :class:`~isaaclab.assets.DeformableObjectData` from ``isaaclab.assets``. + .. note:: The ``isaaclab_physx`` extension is installed automatically with Isaac Lab. No additional @@ -382,13 +383,17 @@ release. The old soft body API has been deprecated and replaced by two distinct types: **volume deformables** (3D FEM tetrahedral meshes) and **surface deformables** (2D triangle cloth meshes). The deformable type is determined by the physics material assigned: -- :class:`~isaaclab_physx.sim.DeformableBodyMaterialCfg` for volume deformables. -- :class:`~isaaclab_physx.sim.SurfaceDeformableBodyMaterialCfg` for surface deformables. +- :class:`~isaaclab_physx.sim.PhysxDeformableBodyMaterialCfg` for PhysX volume deformables. +- :class:`~isaaclab_physx.sim.PhysxSurfaceDeformableBodyMaterialCfg` for PhysX surface deformables. +- :class:`~isaaclab_newton.sim.spawners.materials.NewtonDeformableBodyMaterialCfg` for Newton volume deformables. +- :class:`~isaaclab_newton.sim.spawners.materials.NewtonSurfaceDeformableBodyMaterialCfg` for Newton surface + deformables. -All deformable-related classes have moved from ``isaaclab`` to ``isaaclab_physx``, as shown -in the import table above. Several properties on -:class:`~isaaclab_physx.sim.DeformableBodyPropertiesCfg` have been removed or added to match -the new Omni Physics schema. +Deformable property and material cfgs are backend-specific. Several properties on +:class:`~isaaclab_physx.sim.PhysxDeformableBodyPropertiesCfg` have been removed or added to +match the new Omni Physics schema. The common +:class:`~isaaclab.sim.DeformableBodyPropertiesBaseCfg` is now empty; OmniPhysics +deformable body fields are owned by :class:`~isaaclab_physx.sim.PhysxDeformableBodyPropertiesCfg`. For a comprehensive guide covering the full deformable API migration — including removed and added properties, material changes, code examples for both volume and surface deformables, and @@ -877,7 +882,7 @@ Here's a complete example showing how to update your code: .. code-block:: python - from isaaclab_physx.assets import DeformableObject, DeformableObjectCfg + from isaaclab.assets import DeformableObject, DeformableObjectCfg from isaaclab_physx.assets import SurfaceGripper, SurfaceGripperCfg from isaaclab.assets import RigidObjectCollection # unchanged @@ -1219,7 +1224,7 @@ All ``.data.*`` properties on asset and sensor classes now return the underlying ``wp.array`` and exposes explicit ``.torch`` and ``.warp`` accessors. This change applies to all asset classes (:class:`~isaaclab.assets.Articulation`, :class:`~isaaclab.assets.RigidObject`, :class:`~isaaclab.assets.RigidObjectCollection`, -:class:`~isaaclab_physx.assets.DeformableObject`) and all sensor classes +:class:`~isaaclab.assets.DeformableObject`) and all sensor classes (:class:`~isaaclab_physx.sensors.ContactSensor`, :class:`~isaaclab_physx.sensors.Imu`, :class:`~isaaclab_physx.sensors.Pva`, :class:`~isaaclab_physx.sensors.FrameTransformer`). @@ -1276,8 +1281,8 @@ Common patterns that need updating: - ``isaaclab`` / ``isaaclab_physx`` * - :class:`~isaaclab.assets.RigidObjectCollection` - ``isaaclab`` / ``isaaclab_physx`` - * - :class:`~isaaclab_physx.assets.DeformableObject` - - ``isaaclab_physx`` + * - :class:`~isaaclab.assets.DeformableObject` + - ``isaaclab`` / ``isaaclab_physx`` / ``isaaclab_newton`` * - :class:`~isaaclab_physx.sensors.ContactSensor` - ``isaaclab_physx`` * - :class:`~isaaclab_physx.sensors.Imu` diff --git a/docs/source/overview/core-concepts/multi_backend_architecture.rst b/docs/source/overview/core-concepts/multi_backend_architecture.rst index 5d894c6820f8..8a946b50023c 100644 --- a/docs/source/overview/core-concepts/multi_backend_architecture.rst +++ b/docs/source/overview/core-concepts/multi_backend_architecture.rst @@ -43,6 +43,10 @@ This pattern applies to all simulation components: - :class:`~isaaclab.assets.RigidObject` - :class:`~isaaclab_physx.assets.RigidObject` - :class:`~isaaclab_newton.assets.RigidObject` + * - Deformable Object + - :class:`~isaaclab.assets.DeformableObject` + - :class:`~isaaclab_physx.assets.DeformableObject` + - :class:`~isaaclab_newton.assets.DeformableObject` * - Contact Sensor - :class:`~isaaclab.sensors.ContactSensor` - :class:`~isaaclab_physx.sensors.ContactSensor` @@ -254,6 +258,8 @@ the established conventions: │ │ └── articulation_data.py │ ├── rigid_object/ │ │ └── ... + │ ├── deformable_object/ + │ │ └── ... │ └── rigid_object_collection/ │ └── ... ├── sensors/ @@ -384,4 +390,6 @@ See Also - :doc:`/source/features/hydra` — preset system for multi-backend environment configurations - :doc:`physical-backends/index` — feature matrix and per-backend guides (PhysX, Newton, OvPhysX) - :doc:`physical-backends/newton/index` — Newton backend guide +- :doc:`physical-backends/newton/newton-manager-abstraction` — adding Newton solver managers and + coupled solvers - :doc:`renderers` — renderer backend architecture diff --git a/docs/source/overview/core-concepts/physical-backends/newton/index.rst b/docs/source/overview/core-concepts/physical-backends/newton/index.rst index 087adda9cb23..71e1ffed14e6 100644 --- a/docs/source/overview/core-concepts/physical-backends/newton/index.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/index.rst @@ -20,10 +20,18 @@ have successfully deployed a Newton-trained locomotion policy to a G1 robot. Newton can support `multiple solvers `_ for handling different types of physics simulation. The Isaac Lab integration ships -two solver pages: +the following solver pages: * :doc:`mjwarp-solver` — the primary, validated solver path. * :doc:`kamino-solver` — beta support on selected classic tasks. +* :doc:`using-vbd-solver` — experimental VBD solver for cloth and soft bodies, + available through :mod:`isaaclab_contrib.deformable` and the MJWarp + VBD or + Featherstone + VBD coupled managers. + +Each solver is exposed as a small subclass of +:class:`~isaaclab_newton.physics.NewtonManager`. See +:doc:`newton-manager-abstraction` for the developer-facing guide to adding a +new solver or a coupled solver. During the beta phase, breaking changes and incomplete documentation are still expected. Official support and debugging assistance will follow once the framework @@ -41,5 +49,7 @@ new backend, see :doc:`../../multi_backend_architecture`. supported-features mjwarp-solver kamino-solver + using-vbd-solver + newton-manager-abstraction warp-environments warp-env-migration diff --git a/docs/source/overview/core-concepts/physical-backends/newton/mjwarp-solver.rst b/docs/source/overview/core-concepts/physical-backends/newton/mjwarp-solver.rst index 805f51a7ec2d..18bdeabfc614 100644 --- a/docs/source/overview/core-concepts/physical-backends/newton/mjwarp-solver.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/mjwarp-solver.rst @@ -7,7 +7,9 @@ for the Newton backend in Isaac Lab. It is enabled by setting :class:`~isaaclab_newton.physics.MJWarpSolverCfg`, usually exposed as the ``newton_mjwarp`` physics preset on a task configuration. Newton ships with beta support for an alternative Kamino solver — see :doc:`kamino-solver` and -:ref:`hydra-backend-solver-presets` for how presets are selected. +:ref:`hydra-backend-solver-presets` for how presets are selected. For details +on how solver-specific managers are implemented, or how to add a new solver +manager, see :doc:`newton-manager-abstraction`. .. note:: diff --git a/docs/source/overview/core-concepts/physical-backends/newton/newton-manager-abstraction.rst b/docs/source/overview/core-concepts/physical-backends/newton/newton-manager-abstraction.rst new file mode 100644 index 000000000000..312d0c5bb792 --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/newton/newton-manager-abstraction.rst @@ -0,0 +1,237 @@ +Newton Manager Abstraction +========================== + +Newton exposes multiple solver families, and Isaac Lab keeps that flexibility by +making each solver an implementation detail of a small +:class:`~isaaclab_newton.physics.NewtonManager` subclass. The simulation context +still sees a normal physics manager; the solver configuration decides which +manager class is used. + +For most new Newton solvers, the integration surface is intentionally small: + +* define a solver config that inherits from + :class:`~isaaclab_newton.physics.NewtonSolverCfg`; +* point the config's ``class_type`` at a manager subclass; +* implement ``_build_solver()`` in that manager; +* set the three base-manager slots: ``_solver``, ``_use_single_state``, and + ``_needs_collision_pipeline``. + +The existing MuJoCo Warp, XPBD, Featherstone, and Kamino managers are examples +of this pattern. + + +Adding a Solver Manager +----------------------- + +The solver config carries both user-tunable solver parameters and the manager +dispatch target: + +.. code-block:: python + + from isaaclab_newton.physics import NewtonManager, NewtonSolverCfg + from isaaclab.utils.configclass import configclass + + + @configclass + class MySolverCfg(NewtonSolverCfg): + class_type: type[NewtonManager] | str = "{DIR}.my_solver_manager:NewtonMySolverManager" + solver_type: str = "my_solver" + iterations: int = 16 + + +``NewtonCfg`` copies ``solver_cfg.class_type`` into its own ``class_type`` in +``__post_init__``. User code keeps the normal shape: + +.. code-block:: python + + from isaaclab.sim import SimulationCfg + from isaaclab_newton.physics import NewtonCfg + + sim_cfg = SimulationCfg( + physics=NewtonCfg( + solver_cfg=MySolverCfg(iterations=32), + num_substeps=2, + ) + ) + + +The manager then owns solver construction: + +.. code-block:: python + + from newton import Model + from newton.solvers import SolverMySolver + + from isaaclab_newton.physics import NewtonManager + + + class NewtonMySolverManager(NewtonManager): + @classmethod + def _build_solver(cls, model: Model, solver_cfg: MySolverCfg) -> None: + NewtonManager._solver = SolverMySolver(model, iterations=solver_cfg.iterations) + NewtonManager._use_single_state = False + NewtonManager._needs_collision_pipeline = True + + +``_use_single_state`` tells the base manager whether the solver advances in +place or swaps input/output states. ``_needs_collision_pipeline`` tells the base +manager whether to allocate and pass Newton collision-pipeline contacts to the +solver. A solver with its own internal contact detector can set it to ``False``. + +Optional Overrides +------------------ + +Most managers only implement ``_build_solver()``. Override more only when the +solver actually needs it: + +* ``_initialize_contacts()``: allocate custom contact buffers or support an + internal contact detector. +* ``_step_solver(state_0, state_1, control, substep_dt)``: change one substep of + solver execution while keeping the base simulation loop. +* ``_simulate_physics_only()``: add per-step work around the base substep loop, + such as rebuilding a BVH. +* ``step()``: handle solver-specific reset masks, graph capture, or model-change + notification before delegating to the base manager. +* ``start_simulation()`` or ``instantiate_builder_from_stage()``: customize model + building or post-finalize setup. +* ``_solver_specific_clear()``: release any class-level state owned by the + solver manager. + +Keep the manager name prefixed with ``Newton`` and the solver config grouped +with the other Newton solver configs so autocomplete and backend discovery stay +predictable. + + +Custom Coupled Solvers +---------------------- + +Coupled solvers use the same abstraction. Instead of wrapping one Newton solver, +a coupled manager constructs two or more sub-solvers and overrides +``_step_solver()`` to define the substep order. +That means a custom coupling usually needs only a config that stores existing +solver configs plus a manager that defines how data flows between them; the +component solvers can stay unchanged. + +The MJWarp + VBD deformable manager is a concrete example: + +* :class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg` stores a + ``rigid_solver_cfg`` for :class:`~isaaclab_newton.physics.MJWarpSolverCfg`, a + ``soft_solver_cfg`` for :class:`~isaaclab_contrib.deformable.VBDSolverCfg`, + and a ``coupling_mode``. +* ``NewtonCoupledMJWarpVBDManager._build_solver()`` constructs + ``SolverMuJoCo`` and ``SolverVBD`` from those sub-configs. +* ``_step_solver()`` dispatches to either one-way or two-way coupling. +* The base ``NewtonManager`` still owns state allocation, substep iteration, + Fabric synchronization, and reset/clear lifecycle. + +The two-way MJWarp + VBD substep stays compact because it is expressed as a +short coupling algorithm: + +.. admonition:: Algorithm: Two-Way MJWarp + VBD Substep + :class: note + + **Inputs:** rigid body state, deformable particle state, and the shared + Newton collision pipeline. + + **Output:** updated rigid body and deformable particle state for one Newton + substep. + + 1. **Reset force accumulators.** + Clear the rigid body and particle force buffers before evaluating the + next contact pass. + + 2. **Detect coupled contacts.** + Run Newton collision detection once over the current rigid and + deformable state. + + 3. **Apply soft-to-rigid reactions.** + Inject body-particle contact reactions into ``body_f`` so the rigid + bodies can be pushed back by the deformable contact penalties. + + 4. **Advance the rigid solver.** + Step the MJWarp rigid solver with the coupled contact forces applied. + + 5. **Preserve shared contacts for the soft solve.** + Clear particle forces written during the rigid step while keeping the + detected contact information available. + + 6. **Advance the deformable solver.** + Step the VBD soft solver against the same coupled contacts. + + +This keeps the custom part focused on the coupling policy. The manager does not +need to reimplement scene loading, asset buffers, reset handling, or the outer +simulation loop. + +.. figure:: ../../../../_static/newton/franka-mjwarp-vbd-coupling.png + :align: center + :figwidth: 480px + :class: square-crop-figure + :alt: Franka manipulating a deformable object with MJWarp and VBD coupling + + Franka manipulation using MJWarp for rigid bodies and VBD for the deformable + object. + +You can exercise this coupling path with the Franka soft-body lifting task: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/environments/zero_agent.py --task Isaac-Lift-Soft-Franka-v0 --num_envs 1 --visualizer kit + +For the surface-deformable cloth variant, use ``--task Isaac-Lift-Cloth-Franka-v0``. + + +This environment configures +:class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg` with +``coupling_mode="two_way"``. + +Tuning the Franka Soft-Body Lift +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tune the coupled contact behavior before training a policy: + +* Start with ``coupling_mode="two_way"``. Compared with one-way coupling, two-way + coupling can prevent clipping more easily because body-particle contact + penalties can push the robot back instead of only moving the deformable. +* Use a small scripted grasp/lift check before training to confirm that grasping + is possible and to inspect what clips when the grasp fails. +* Lower the arm actuator stiffness enough that the arm can respond to contact + penalties. Prefer the arm being pushed back over the gripper clipping into the + deformable. +* Tune :attr:`~isaaclab_contrib.deformable.NewtonModelCfg.soft_contact_ke` + first. Increase it only as much as needed to prevent clipping, then adjust + :attr:`~isaaclab_contrib.deformable.NewtonModelCfg.soft_contact_mu` so the + gripper can carry the object without requiring an obviously unphysical + friction value. Use + :attr:`~isaaclab_contrib.deformable.NewtonModelCfg.soft_contact_kd` for + stabilization if contacts chatter. +* Tune the ``soft_contact_*`` values together with ``shape_material_*`` values + because rigid shape material parameters also affect the effective contact. +* If ``soft_contact_ke`` is not sufficient, or ``soft_contact_mu`` must be + unphysically high, tune the Franka arm and hand actuator stiffness and maximum + effort. For the gripper command, fully close the fingers and let the actuator + maximum effort limit the actual squeeze. +* If the deformable no longer visibly deforms, ``soft_contact_ke`` is likely too + high. +* If contacts are unstable or missed, increase the deformable mesh resolution or + increase ``particle_radius`` in the deformable material so contact is detected + earlier from a larger distance. +* If the rigid shapes still clip through the deformable, increase + :attr:`~isaaclab_contrib.deformable.VBDSolverCfg.iterations`; more VBD + iterations can improve contact convergence. + + +When to Add a Coupled Manager +----------------------------- + +Add a coupled manager when one solver cannot own the whole model step by itself: + +* rigid bodies should use one solver while particles or cloth use another; +* contact detection is shared, but each solver consumes the contacts + differently; +* you need a custom force, impulse, or state exchange between solvers; +* the substep order is part of the algorithm. + +Use a normal single-solver manager when all physics can be advanced by one +Newton solver. Use a coupled manager only for the small amount of glue that is +truly solver-specific. diff --git a/docs/source/overview/core-concepts/physical-backends/newton/supported-features.rst b/docs/source/overview/core-concepts/physical-backends/newton/supported-features.rst index d7fd6aabf0d7..def15735fc98 100644 --- a/docs/source/overview/core-concepts/physical-backends/newton/supported-features.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/supported-features.rst @@ -35,10 +35,18 @@ isaaclab * Rigid Object and Rigid Object Collection APIs * Sensors: Contact Sensor, IMU, Frame Transformer, Joint Wrench, PVA * Direct and Manager-based single-agent workflows +* Backend-neutral deformable object API * Omniverse Kit visualizer (when Isaac Sim is installed) * Newton-Warp visualizer (kit-less) * Tiled rendering via the Newton-Warp renderer +isaaclab_contrib +^^^^^^^^^^^^^^^^ + +* Experimental Newton deformable objects +* VBD deformable solver (see :doc:`using-vbd-solver`) +* Coupled MJWarp + VBD and Featherstone + VBD solver managers + The following sensors are backend-agnostic (implemented in ``isaaclab`` core) and work transparently with Newton: @@ -71,6 +79,8 @@ Manager-based workflows: Unitree G1, Go1, Go2, Unitree H1, Spot * Locomotion velocity, rough terrain: Anymal-C, Cassie, Go1, Go2 * Manipulation: reach (Franka, UR10), cabinet, dexsuite +* Manipulation lift with deformable objects: Franka soft-body lift, Franka cloth + lift (via coupled MJWarp + VBD) Solver Coverage @@ -80,15 +90,19 @@ Solver Coverage * **Kamino solver**: beta. Currently validated on ``Isaac-Cartpole-Direct-v0``, ``Isaac-Ant-Direct-v0``, ``Isaac-Cartpole-v0``, and ``Isaac-Ant-v0``. See :doc:`kamino-solver`. - -Other Newton solvers (e.g. VBD) are not yet exposed through Isaac Lab. +* **VBD solver**: experimental, exposed through :mod:`isaaclab_contrib.deformable` + for cloth and soft-body simulation. Most often used inside the coupled + MJWarp + VBD or Featherstone + VBD managers so one solver advances rigid + bodies and VBD advances deformable particles. See :doc:`using-vbd-solver` + and :doc:`newton-manager-abstraction`. Known Gaps ---------- -* Soft bodies, particles, and other non-rigid PhysX features are not yet - available through Newton. +* Soft bodies and particles are available through the experimental VBD path in + :mod:`isaaclab_contrib.deformable`; other non-rigid PhysX features are not + yet covered. * Behaviour on stiff contact stacks can diverge from PhysX; expect to retune contact and substep parameters when porting tasks across backends. * Multi-agent and self-play workflows are not yet wired up for Newton. diff --git a/docs/source/overview/core-concepts/physical-backends/newton/using-vbd-solver.rst b/docs/source/overview/core-concepts/physical-backends/newton/using-vbd-solver.rst new file mode 100644 index 000000000000..4e50b1f4339b --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/newton/using-vbd-solver.rst @@ -0,0 +1,344 @@ +.. _newton-using-vbd: + +Using the VBD Solver +==================== + +Vertex Block Descent (VBD) is a Newton solver for cloth and soft-body +simulation. In Isaac Lab, VBD is enabled by selecting a +:class:`~isaaclab_newton.physics.NewtonCfg` whose ``solver_cfg`` is provided by +:mod:`isaaclab_contrib.deformable`. + +VBD support is experimental. The solver managers, configuration fields, and +recommended tuning values may change while Newton deformable support is under +active development. A task that works with PhysX or with Newton's MuJoCo-Warp +solver may still need deformable assets, materials, contacts, and coupling tuned +before it works well with VBD. + +VBD is usually exposed through a task-specific physics preset rather than a +general ``newton_vbd`` preset. Deformable-only scenes can use +:class:`~isaaclab_contrib.deformable.VBDSolverCfg` directly. Robot or +rigid-body scenes usually use +:class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg` or +:class:`~isaaclab_contrib.deformable.CoupledFeatherstoneVBDSolverCfg` so one +solver advances rigid bodies and VBD advances deformable particles. + +Start from a Supported Deformable Task +-------------------------------------- + +Before adding VBD to a new task, first run one of the experimental Franka +deformable tasks: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/environments/zero_agent.py --task Isaac-Lift-Soft-Franka-v0 --num_envs 1 --visualizer kit + +For the surface-deformable cloth variant, use: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/environments/zero_agent.py --task Isaac-Lift-Cloth-Franka-v0 --num_envs 1 --visualizer kit + +Both tasks configure MJWarp for the rigid Franka and VBD for the deformable +object through +:class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg`. +Use these tasks as starting points for asset setup, solver coupling, and contact +tuning. + +Add a VBD Physics Preset +------------------------ + +Tasks that support multiple physics options usually store ``SimulationCfg.physics`` +as a :class:`~isaaclab_tasks.utils.hydra.PresetCfg`. For deformable Newton tasks, +the preset can use a small :class:`~isaaclab_newton.physics.NewtonCfg` subclass +to carry :class:`~isaaclab_contrib.deformable.NewtonModelCfg` alongside the +normal Newton fields: + +.. code-block:: python + + from isaaclab.utils.configclass import configclass + from isaaclab_newton.physics import NewtonCfg + + from isaaclab_contrib.deformable import NewtonModelCfg + + + @configclass + class DeformableNewtonCfg(NewtonCfg): + model_cfg: NewtonModelCfg | None = None + + +The Franka soft-body task defines a ``newton_mjwarp_vbd`` preset that couples +MJWarp and VBD: + +.. literalinclude:: ../../../../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/franka_soft_env_cfg.py + :language: python + :start-at: class PhysicsCfg + :end-at: default = newton_mjwarp_vbd + :emphasize-lines: 4-32 + +The important pieces are: + +* Add a Newton physics preset whose value is ``DeformableNewtonCfg``. +* Use :class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg` when rigid + bodies and deformables must interact in the same scene. +* Use ``soft_solver_cfg=VBDSolverCfg(integrate_with_external_rigid_solver=True)`` + inside a coupled solver so VBD advances only the deformable particles. +* Add :class:`~isaaclab_contrib.deformable.NewtonModelCfg` when body-particle or + self-contact values need task-level tuning. +* Keep the preset at the same config path used by the task's + :class:`~isaaclab.sim.SimulationCfg`, for example ``env.sim.physics``. + +You can select the deformable Newton preset globally: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task=Isaac-Lift-Soft-Franka-v0 presets=newton_mjwarp_vbd + +or select the physics field directly: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task=Isaac-Lift-Soft-Franka-v0 env.sim.physics=newton_mjwarp_vbd + +Use the direct path override when only one task field should use the VBD preset. +Use ``presets=newton_mjwarp_vbd`` when you want every matching preset field in +the task config to resolve to that preset. Isaac Lab training scripts accept +these Hydra overrides after the regular command line flags; no separator is +needed for the examples above. + + +Check Task and Asset Compatibility +---------------------------------- + +VBD uses the Newton model built from the task assets. When adding VBD to a new +task, validate the following before tuning solver parameters: + +* The task must already be compatible with the Newton backend. If a rigid-only + ``newton_mjwarp`` preset fails during model construction, fix the asset or task + configuration first. +* The scene must include Newton-compatible deformable assets and materials. Use + :class:`~isaaclab_newton.sim.spawners.materials.NewtonDeformableBodyMaterialCfg` + for volume deformables and + :class:`~isaaclab_newton.sim.spawners.materials.NewtonSurfaceDeformableBodyMaterialCfg` + for cloth or surface deformables. +* Coupled robot tasks should start with ``coupling_mode="two_way"`` when the + robot should feel contact forces from the deformable object. +* Contact-heavy scenes usually need task-specific ``num_substeps``, + :class:`~isaaclab_contrib.deformable.VBDSolverCfg`, and + :class:`~isaaclab_contrib.deformable.NewtonModelCfg` values. Start from the + Franka soft-body or cloth preset that most closely resembles the scene. +* Use a small visual smoke test before training. Confirm that the deformable + spawns, renders, deforms, and contacts rigid bodies as expected. + +VBD Solver Parameters +--------------------- + +The following fields are specific to +:class:`~isaaclab_contrib.deformable.VBDSolverCfg`. They are grouped by the part +of the solver they affect. + +Core Solve +^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``iterations`` + - Default: ``10``. Number of VBD iterations per substep. Increasing this value improves deformation and contact convergence, especially for stiff materials or rigid gripper contacts, but increases runtime. + * - ``integrate_with_external_rigid_solver`` + - Default: ``False``. Set to ``True`` when VBD is used inside a coupled solver so the rigid sub-solver owns rigid-body integration. Leave ``False`` for deformable-only VBD scenes. + + +Self-Contact +^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``particle_enable_self_contact`` + - Default: ``False``. Enables deformable self-contact. Use this for cloth folds or soft bodies that collide with themselves. It increases contact work and usually needs additional tuning. + * - ``particle_self_contact_radius`` + - Default: ``0.005`` [m]. Effective self-contact thickness. VBD applies vertex-triangle and edge-edge self-contact response when the current primitive distance is smaller than this radius. + * - ``particle_self_contact_margin`` + - Default: ``0.005`` [m]. Self-contact candidate search distance. VBD uses this envelope when building self-contact lists, then applies contact response using ``particle_self_contact_radius``. Keep this greater than or equal to the radius to avoid missed contacts. + * - ``particle_collision_detection_interval`` + - Default: ``-1``. Controls how often self-contact detection runs. A negative value detects before initialization only. ``0`` detects before and immediately after initialization. A positive value ``k`` detects before every ``k`` VBD iterations. + * - ``particle_vertex_contact_buffer_size`` + - Default: ``32``. Preallocation size for each vertex's vertex-triangle self-contact buffer. Increase it if dense folds or high-resolution cloth exceed the default capacity. + * - ``particle_edge_contact_buffer_size`` + - Default: ``64``. Preallocation size for each edge's edge-edge self-contact buffer. Increase it if dense folds or high-resolution cloth exceed the default capacity. + * - ``particle_topological_contact_filter_threshold`` + - Default: ``2``. Filters contacts between mesh primitives that are close in topology. Increase this to suppress contact between neighboring elements of the same surface. Values greater than ``3`` can significantly increase compute time. + * - ``particle_rest_shape_contact_exclusion_radius`` + - Default: ``0.0`` [m]. Filters self-contact candidates whose rest-configuration distance is shorter than this distance. Increase it when rest-neighbor contacts produce unwanted resistance. + + +Coupled Solver Parameters +------------------------- + +Use the coupled solver configs when one solver should advance rigid bodies and +VBD should advance deformables: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``rigid_solver_cfg`` + - Rigid-body sub-solver configuration. :class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg` uses :class:`~isaaclab_newton.physics.MJWarpSolverCfg`; :class:`~isaaclab_contrib.deformable.CoupledFeatherstoneVBDSolverCfg` uses :class:`~isaaclab_newton.physics.FeatherstoneSolverCfg`. + * - ``soft_solver_cfg`` + - VBD sub-solver configuration. In coupled scenes, set ``integrate_with_external_rigid_solver=True`` so VBD advances only deformable particles. + * - ``coupling_mode="one_way"`` + - Rigid solver advances first, and VBD reacts to the updated rigid poses. The rigid solver does not feel particle contact forces. + * - ``coupling_mode="two_way"`` + - Contact reactions from deformables are injected into the rigid solver before the rigid step, then VBD advances deformables against the shared contacts. Use this for manipulation tasks where the robot should be pushed back by deformable contact. + * - ``coupling_mode="kinematic"`` + - Available on :class:`~isaaclab_contrib.deformable.CoupledFeatherstoneVBDSolverCfg`. Rigid bodies are kinematically updated by Featherstone, and VBD reacts to them. The rigid solver does not feel particle contacts. + +The rigid solver parameters still matter. For example, MJWarp's ``nconmax`` and +``njmax`` must be large enough for the rigid contacts in the scene, and +``ccd_iterations`` can affect fast rigid contacts near deformables. See +:doc:`mjwarp-solver` for the MJWarp-side parameters. + + +Contact and Material Parameters +------------------------------- + +Contact Model +^^^^^^^^^^^^^ + +:class:`~isaaclab_contrib.deformable.NewtonModelCfg` applies contact parameters +to the finalized Newton model: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``soft_contact_ke`` + - Default: ``1.0e3`` [N/m]. Stiffness for body-particle and particle self-contact. Increase it to reduce clipping through rigid shapes or through other deformable particles. If it is too high, the object can stop visibly deforming or require more VBD iterations and substeps. + * - ``soft_contact_kd`` + - Default: ``1.0e-2`` [N*s/m]. Contact damping. Increase it to reduce chatter or bouncing. Too much damping can make contact response sticky or overdamped. + * - ``soft_contact_mu`` + - Default: ``0.5``. Friction coefficient for body-particle and particle self-contact. Increase it when a gripper cannot carry the deformable object without slipping. + * - ``shape_material_ke`` + - Default: ``None`` [N/m]. Optional override for all rigid collision-shape contact stiffness values in the Newton model. Use this when the rigid-side material parsed from the asset is not appropriate for deformable contact. + * - ``shape_material_kd`` + - Default: ``None`` [N*s/m]. Optional override for all rigid collision-shape contact damping values in the Newton model. + * - ``shape_material_mu`` + - Default: ``None``. Optional override for all rigid collision-shape friction values in the Newton model. Body-particle friction depends on both the soft contact and rigid shape friction coefficients. + + +Volume Deformable Materials +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use +:class:`~isaaclab_newton.sim.spawners.materials.NewtonDeformableBodyMaterialCfg` +for volume deformables: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``density`` + - Default: ``1.0`` [kg/m^3]. Material density. Higher density increases particle mass and inertia, so the object accelerates and deforms less for the same contact forces. + * - ``particle_radius`` + - Default: ``0.008`` [m]. Particle contact radius used by Newton. Increase it when contacts are missed or detected too late. If it is too large relative to the mesh resolution, contacts can start too early. + * - ``k_mu`` + - Default: ``1.0e5`` [Pa]. First Lame material parameter. Higher values make the deformable object stiffer and usually require more VBD iterations, more substeps, or a smaller timestep. + * - ``k_lambda`` + - Default: ``1.0e5`` [Pa]. Second Lame material parameter. Higher values make the deformable object stiffer and usually require more VBD iterations, more substeps, or a smaller timestep. + * - ``k_damp`` + - Default: ``0.0`` [Pa*s]. Damping for tetrahedral elements. Increase it to reduce oscillations after deformation, but avoid overdamping if the object should rebound. + + +Surface Deformable Materials +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use +:class:`~isaaclab_newton.sim.spawners.materials.NewtonSurfaceDeformableBodyMaterialCfg` +for cloth or surface deformables: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``density`` + - Default: ``1.0`` [kg/m^3]. Material density. Higher density increases particle mass and inertia. + * - ``particle_radius`` + - Default: ``0.008`` [m]. Particle contact radius used by Newton. + * - ``tri_ke`` + - Default: ``1.0e4`` [Pa]. Triangle area-preserving stiffness. Increase it to reduce cloth stretch. + * - ``tri_ka`` + - Default: ``1.0e4`` [Pa]. Triangle area stiffness. Increase it to reduce cloth area change. + * - ``tri_kd`` + - Default: ``1.5e-6`` [Pa*s]. Triangle area damping. Increase it to reduce cloth vibration after stretching. + * - ``edge_ke`` + - Default: ``5.0`` [N*m]. Bending stiffness. Increase it for stiffer cloth folds; decrease it for softer draping. + * - ``edge_kd`` + - Default: ``1.0e-2`` [N*m*s]. Bending damping. Increase it to damp fold oscillations. + +Tuning Workflow +--------------- + +Use the following sequence when bringing up a new VBD task: + +1. Run one of the supported Franka deformable tasks and confirm your + installation, visualizer, and deformable rendering path work. +2. Add a task-specific VBD or coupled VBD preset copied from the closest + supported task. +3. Run a small visual smoke test with ``--num_envs 1`` before training. +4. Tune deformable material stiffness and damping until the object deforms in + the expected range without rigid contact. +5. Increase ``num_substeps`` or decrease ``dt`` if the object is unstable before + increasing stiffness further. +6. Increase :attr:`~isaaclab_contrib.deformable.VBDSolverCfg.iterations` when + contacts or stiff materials do not converge within a substep. +7. Tune :attr:`~isaaclab_contrib.deformable.NewtonModelCfg.soft_contact_ke` to + reduce rigid/deformable clipping, then tune + :attr:`~isaaclab_contrib.deformable.NewtonModelCfg.soft_contact_mu` for grip + and :attr:`~isaaclab_contrib.deformable.NewtonModelCfg.soft_contact_kd` for + chatter. +8. Enable self-contact only after body-particle contact is stable, then tune + ``particle_self_contact_radius`` for active self-contact thickness, + ``particle_self_contact_margin`` for missed contacts, and + ``particle_collision_detection_interval`` for detection frequency. +9. Increase ``num_envs`` and profile only after the single-environment scene is + stable. + + +Symptoms and First Parameters to Check +-------------------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 35 65 + + * - Symptom + - First parameters to check + * - Rigid bodies visibly clip through the deformable. + - Increase ``soft_contact_ke``, VBD ``iterations``, ``num_substeps``, or the deformable material ``particle_radius``. + * - The robot cannot lift the deformable. + - Use ``coupling_mode="two_way"``, then increase ``soft_contact_mu`` and rigid-side ``shape_material_mu``. Also check gripper actuator stiffness and effort limits. + * - The deformable barely deforms. + - Reduce material stiffness, ``soft_contact_ke``, or shape contact stiffness. + * - Contact chatters or bounces. + - Increase ``soft_contact_kd`` or material damping, and consider using more substeps. + * - Cloth passes through itself. + - Enable ``particle_enable_self_contact``, increase ``particle_self_contact_radius`` if the active self-contact thickness is too small, increase ``particle_self_contact_margin`` if contacts are missed, and use a positive ``particle_collision_detection_interval``. + * - Self-contact is too expensive. + - Increase ``particle_collision_detection_interval``, reduce mesh resolution, or disable self-contact until the rest of the scene is tuned. + +For implementation details of the VBD and coupled solver managers, see +:doc:`newton-manager-abstraction`. diff --git a/docs/source/overview/core-concepts/physical-backends/newton/warp-environments.rst b/docs/source/overview/core-concepts/physical-backends/newton/warp-environments.rst index a5dcbd648724..3c0e1ba7d6b0 100644 --- a/docs/source/overview/core-concepts/physical-backends/newton/warp-environments.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/warp-environments.rst @@ -321,11 +321,4 @@ Migrating Existing Environments For step-by-step instructions on porting an existing stable env (or writing a new warp env from scratch) — covering project layout, the kernel + launch pattern shared by observations / rewards / events / terminations / actions, capture-safety rules, and -parity testing — see :doc:`warp-env-migration` below. - - -.. toctree:: - :maxdepth: 2 - :hidden: - - warp-env-migration +parity testing — see :doc:`warp-env-migration`. diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index 61a13d058ffd..384f2dbff515 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -151,6 +151,9 @@ for the lift-cube environment: +-------------------------+------------------------------+-----------------------------------------------------------------------------+------------------------------+ | |lift-cube| | |lift-cube-link| | Pick a cube and bring it to a sampled target position with the Franka robot | | +-------------------------+------------------------------+-----------------------------------------------------------------------------+------------------------------+ + | |lift-soft-franka| | |lift-soft-franka-link| | Pick a deformable soft body and bring it to a sampled target position with | ``newton_mjwarp_vdb``, | + | | | the Franka robot | ``physx`` | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+------------------------------+ | |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 | | @@ -245,6 +248,7 @@ for the lift-cube environment: .. |reach-ur10| image:: ../_static/tasks/manipulation/ur10_reach.jpg .. |deploy-reach-ur10e| image:: ../_static/tasks/manipulation/ur10e_reach.jpg .. |lift-cube| image:: ../_static/tasks/manipulation/franka_lift.jpg +.. |lift-soft-franka| image:: ../_static/newton/franka-mjwarp-vbd-coupling.png .. |cabi-franka| image:: ../_static/tasks/manipulation/franka_open_drawer.jpg .. |cube-allegro| image:: ../_static/tasks/manipulation/allegro_cube.jpg .. |cube-shadow| image:: ../_static/tasks/manipulation/shadow_cube.jpg @@ -272,6 +276,7 @@ for the lift-cube environment: .. |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>`__ +.. |lift-soft-franka-link| replace:: `Isaac-Lift-Soft-Franka-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/franka_soft_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>`__ @@ -1047,6 +1052,16 @@ inferencing, including reading from an already trained checkpoint and disabling - Manager Based - **rsl_rl** (PPO), **skrl** (PPO), **rl_games** (PPO), **sb3** (PPO) - + * - Isaac-Lift-Soft-Franka-v0 + - + - Manager Based + - **rsl_rl** (PPO) + - ``newton_mjwarp_vdb``, ``physx`` + * - Isaac-Lift-Cloth-Franka-v0 + - + - Manager Based + - **rsl_rl** (PPO) + - ``newton_mjwarp_vdb`` * - Isaac-Lift-Teddy-Bear-Franka-IK-Abs-v0 - - Manager Based diff --git a/docs/source/overview/showroom.rst b/docs/source/overview/showroom.rst index bb2248375749..358a310e9e1e 100644 --- a/docs/source/overview/showroom.rst +++ b/docs/source/overview/showroom.rst @@ -57,7 +57,7 @@ A few quick showroom scripts to run and checkout: :alt: Biped robots in Isaac Lab -- Spawn different deformable (soft) bodies and let them fall from a height: +- Spawn different deformable objects and let them fall from a height: .. tab-set:: :sync-group: os @@ -76,6 +76,9 @@ A few quick showroom scripts to run and checkout: isaaclab.bat -p scripts\demos\deformables.py + Add ``--backend newton`` to run the same demo with the experimental Newton + deformable backend. + .. image:: ../_static/demos/deformables.jpg :width: 100% :alt: Deformable primitive-shaped objects in Isaac Lab diff --git a/docs/source/tutorials/00_sim/spawn_prims.rst b/docs/source/tutorials/00_sim/spawn_prims.rst index 1c4555687a28..86672091b2f7 100644 --- a/docs/source/tutorials/00_sim/spawn_prims.rst +++ b/docs/source/tutorials/00_sim/spawn_prims.rst @@ -139,7 +139,8 @@ default to the default values set by USD Physics. Lastly, we spawn a cuboid ``CuboidDeformable`` which contains deformable body physics properties. Unlike the rigid body simulation, a deformable body can have relative motion between its vertices. This is useful for simulating soft bodies like cloth, rubber, or jello. It is important to note that deformable bodies are only supported in -GPU simulation and require a mesh object to be spawned with the deformable body physics properties. +GPU simulation and require a mesh object to be spawned with deformable body physics properties and a deformable +physics material. This example uses the PhysX-specific deformable property and material cfgs. .. literalinclude:: ../../../../scripts/tutorials/00_sim/spawn_prims.py :language: python diff --git a/docs/source/tutorials/01_assets/run_deformable_object.rst b/docs/source/tutorials/01_assets/run_deformable_object.rst index 61092353cec8..bb2607a20949 100644 --- a/docs/source/tutorials/01_assets/run_deformable_object.rst +++ b/docs/source/tutorials/01_assets/run_deformable_object.rst @@ -7,14 +7,17 @@ Interacting with a deformable object .. currentmodule:: isaaclab While deformable objects sometimes refer to a broader class of objects, such as cloths, fluids and soft bodies, -in PhysX, deformable objects are represented as either surface or volume deformables. Unlike rigid objects, soft bodies can deform -under external forces and collisions. In this tutorial, we will focus on volume deformable bodies. For an example of surface -deformables (cloth), see the deformable demo at ``scripts/demos/deformables.py``. - -Soft bodies are simulated using Finite Element Method (FEM) in PhysX. The volume deformable comprises of two tetrahedral -meshes -- a simulation mesh and a collision mesh. The simulation mesh is used to simulate the deformations of -the soft body, while the collision mesh is used to detect collisions with other objects in the scene. -For more details, please check the `PhysX documentation`_. +Isaac Lab represents deformable objects as either surface or volume deformables. Unlike rigid objects, soft bodies can +deform under external forces and collisions. In this tutorial, we focus on volume deformable bodies. For an example of +surface deformables (cloth), see the deformable demo at ``scripts/demos/deformables.py``. + +The deformable object API and schema define/modify functions are shared across backends, while deformable +property and material configuration classes are backend-specific. PhysX simulates soft bodies using the Finite +Element Method (FEM); the Newton experimental backend uses VBD-based deformable support from +:mod:`isaaclab_contrib.deformable`. +The volume deformable comprises of two tetrahedral meshes -- a simulation mesh and a collision mesh. The simulation +mesh is used to simulate the deformations of the soft body, while the collision mesh is used to detect collisions +with other objects in the scene. For PhysX-specific details, please check the `PhysX documentation`_. This tutorial shows how to interact with a deformable object in the simulation. We will spawn a set of soft cubes and see how to set their nodal positions and velocities, along with apply kinematic @@ -31,7 +34,7 @@ The tutorial corresponds to the ``run_deformable_object.py`` script in the ``scr .. literalinclude:: ../../../../scripts/tutorials/01_assets/run_deformable_object.py :language: python - :emphasize-lines: 68-82, 110-126, 131-139, 141-149 + :emphasize-lines: 65-98, 119-124, 126-135, 140-148, 150-158 :linenos: @@ -54,8 +57,10 @@ the :class:`assets.DeformableObject` class, it spawns the object and initializes when the simulation is played. .. note:: - The deformable object is only supported in GPU simulation and requires a mesh object to be spawned with the - deformable body physics properties on it. + Deformable objects require a mesh object to be spawned with backend-specific deformable body physics + properties and a matching deformable physics material. + Use ``--backend physx`` for the PhysX implementation or ``--backend newton`` for the experimental Newton + implementation. As seen in the rigid body tutorial, we can spawn the deformable object into the scene in a similar fashion by creating @@ -63,7 +68,7 @@ an instance of the :class:`assets.DeformableObject` class by passing the configu .. literalinclude:: ../../../../scripts/tutorials/01_assets/run_deformable_object.py :language: python - :start-at: # Create separate groups called "Origin0", "Origin1", ... + :start-at: # Create separate groups called "env_0", "env_1", ... :end-at: cube_object = DeformableObject(cfg=cfg) Running the simulation loop @@ -112,8 +117,8 @@ Stepping the simulation """"""""""""""""""""""" Deformable bodies support user-driven kinematic control where a user can specify position targets for some of -the mesh nodes while the rest of the nodes are simulated using the FEM solver. This `partial kinematic`_ control -is useful for applications where the user wants to interact with the deformable object in a controlled manner. +the mesh nodes while the rest of the nodes are simulated by the active deformable solver. This `partial kinematic`_ +control is useful for applications where the user wants to interact with the deformable object in a controlled manner. In this tutorial, we apply kinematic commands to two out of the four cubes in the scene. We set the position targets for the node at index 0 (bottom-left corner) to move the cube along the z-axis. @@ -162,6 +167,12 @@ Now that we have gone through the code, let's run the script and see the result: ./isaaclab.sh -p scripts/tutorials/01_assets/run_deformable_object.py --visualizer kit +To run the same tutorial with the experimental Newton deformable backend: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/tutorials/01_assets/run_deformable_object.py --backend newton --visualizer kit + This should open a stage with a ground plane, lights, and several cubes. Two of the four cubes must be dropping from a height and settling on to the ground. Meanwhile the other two cubes must be moving along the z-axis. You diff --git a/scripts/demos/deformables.py b/scripts/demos/deformables.py index 71f40b5f9c4a..9da0ba196597 100644 --- a/scripts/demos/deformables.py +++ b/scripts/demos/deformables.py @@ -21,6 +21,7 @@ # create argparser parser = argparse.ArgumentParser(description="This script demonstrates how to spawn deformable prims into the scene.") +parser.add_argument("--backend", type=str, default="physx", choices=["physx", "newton"], help="Physics backend.") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # demos should open Kit visualizer by default @@ -39,13 +40,27 @@ import torch import tqdm -# deformables supported in PhysX -from isaaclab_physx.assets import DeformableObject, DeformableObjectCfg -from isaaclab_physx.sim import DeformableBodyMaterialCfg, DeformableBodyPropertiesCfg, SurfaceDeformableBodyMaterialCfg - import isaaclab.sim as sim_utils +from isaaclab.assets import DeformableObject, DeformableObjectCfg from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +if args_cli.backend == "newton": + from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg as DeformableBodyPropertiesCfg + from isaaclab_newton.sim.spawners.materials import ( + NewtonDeformableBodyMaterialCfg as VolumeDeformableMaterialCfg, + ) + from isaaclab_newton.sim.spawners.materials import ( + NewtonSurfaceDeformableBodyMaterialCfg as SurfaceDeformableMaterialCfg, + ) +else: + from isaaclab_physx.sim.schemas import PhysxDeformableBodyPropertiesCfg as DeformableBodyPropertiesCfg + from isaaclab_physx.sim.spawners.materials import ( + PhysxDeformableBodyMaterialCfg as VolumeDeformableMaterialCfg, + ) + from isaaclab_physx.sim.spawners.materials import ( + PhysxSurfaceDeformableBodyMaterialCfg as SurfaceDeformableMaterialCfg, + ) + def define_origins(num_origins: int, radius: float = 2.0, center_height: float = 3.0) -> list[list[float]]: """Defines origins distributed on the surface of a sphere, sampled according to a Fibonacci lattice. @@ -84,47 +99,47 @@ def design_scene() -> tuple[dict, list[list[float]]]: radius=0.4, deformable_props=DeformableBodyPropertiesCfg(), visual_material=sim_utils.PreviewSurfaceCfg(), - physics_material=DeformableBodyMaterialCfg(), + physics_material=VolumeDeformableMaterialCfg(), ) cfg_cuboid = sim_utils.MeshCuboidCfg( size=(0.6, 0.6, 0.6), deformable_props=DeformableBodyPropertiesCfg(), visual_material=sim_utils.PreviewSurfaceCfg(), - physics_material=DeformableBodyMaterialCfg(), + physics_material=VolumeDeformableMaterialCfg(), ) cfg_cylinder = sim_utils.MeshCylinderCfg( radius=0.25, height=0.5, deformable_props=DeformableBodyPropertiesCfg(), visual_material=sim_utils.PreviewSurfaceCfg(), - physics_material=DeformableBodyMaterialCfg(), + physics_material=VolumeDeformableMaterialCfg(), ) cfg_capsule = sim_utils.MeshCapsuleCfg( radius=0.35, height=0.5, deformable_props=DeformableBodyPropertiesCfg(), visual_material=sim_utils.PreviewSurfaceCfg(), - physics_material=DeformableBodyMaterialCfg(), + physics_material=VolumeDeformableMaterialCfg(), ) cfg_cone = sim_utils.MeshConeCfg( radius=0.35, height=0.75, deformable_props=DeformableBodyPropertiesCfg(), visual_material=sim_utils.PreviewSurfaceCfg(), - physics_material=DeformableBodyMaterialCfg(), + physics_material=VolumeDeformableMaterialCfg(), ) - cfg_cloth = sim_utils.MeshSquareCfg( - size=1.5, + cfg_cloth = sim_utils.MeshRectangleCfg( + size=(1.5, 1.0), resolution=(21, 21), deformable_props=DeformableBodyPropertiesCfg(), visual_material=sim_utils.PreviewSurfaceCfg(), - physics_material=SurfaceDeformableBodyMaterialCfg(), + physics_material=SurfaceDeformableMaterialCfg(), ) cfg_usd = sim_utils.UsdFileCfg( usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Objects/Teddy_Bear/teddy_bear.usd", deformable_props=DeformableBodyPropertiesCfg(), visual_material=sim_utils.PreviewSurfaceCfg(), - physics_material=DeformableBodyMaterialCfg(), + physics_material=VolumeDeformableMaterialCfg(), scale=[0.05, 0.05, 0.05], ) # create a dictionary of all the objects to be spawned @@ -141,50 +156,48 @@ def design_scene() -> tuple[dict, list[list[float]]]: # Create separate groups of deformable objects origins = define_origins(num_origins=12, radius=1.5, center_height=2.0) print("[INFO]: Spawning objects...") - num_volumes = 0 - num_surfaces = 0 - # Iterate over all the origins and randomly spawn objects + # Iterate over all the origins, spawn objects, and create a view for all the deformables + # note: since we manually spawned random deformable meshes above, we don't need to + # specify the spawn configuration for the deformable object + scene_entities = {} for idx, origin in tqdm.tqdm(enumerate(origins), total=len(origins)): # randomly select an object to spawn obj_name = random.choice(list(objects_cfg.keys())) obj_cfg = objects_cfg[obj_name] - # randomize the young modulus - obj_cfg.physics_material.youngs_modulus = random.uniform(5e5, 1e8) - # higher mesh resolution causes instability at low stiffness - if obj_name in ["sphere", "capsule", "cloth", "usd"]: - obj_cfg.physics_material.youngs_modulus = random.uniform(1e8, 5e9) - # randomize the poisson's ratio - obj_cfg.physics_material.poissons_ratio = random.uniform(0.25, 0.45) + # randomize the deformable material stiffness + if args_cli.backend == "newton" and obj_name == "cloth": + obj_cfg.physics_material.tri_ke = random.uniform(5e3, 5e4) + obj_cfg.physics_material.tri_ka = random.uniform(5e3, 5e4) + else: + youngs_modulus = random.uniform(5e5, 1e8) + poissons_ratio = random.uniform(0.25, 0.45) + if args_cli.backend == "newton": + obj_cfg.physics_material.k_mu = youngs_modulus / (2.0 * (1.0 + poissons_ratio)) + obj_cfg.physics_material.k_lambda = ( + youngs_modulus * poissons_ratio / ((1.0 + poissons_ratio) * (1.0 - 2.0 * poissons_ratio)) + ) + else: + obj_cfg.physics_material.youngs_modulus = youngs_modulus + obj_cfg.physics_material.poissons_ratio = poissons_ratio # randomize the color obj_cfg.visual_material.diffuse_color = (random.random(), random.random(), random.random()) # spawn the object, separate groups for surface and volume deformables if obj_name in ["cloth"]: - obj_cfg.func(f"/World/Origin/Surface{idx:02d}", obj_cfg, translation=origin) - num_surfaces += 1 + prim_path = f"/World/Origin/Surface{idx:02d}" + cfg = DeformableObjectCfg( + prim_path=prim_path, + spawn=obj_cfg, + init_state=DeformableObjectCfg.InitialStateCfg(pos=origin), + ) + scene_entities[f"Surface{idx:02d}"] = DeformableObject(cfg=cfg) else: - obj_cfg.func(f"/World/Origin/Volume{idx:02d}", obj_cfg, translation=origin) - num_volumes += 1 - - # create a view for all the deformables, separate views for volume and surface deformables - # note: since we manually spawned random deformable meshes above, we don't need to - # specify the spawn configuration for the deformable object - scene_entities = {} - if num_volumes > 0: - cfg = DeformableObjectCfg( - prim_path="/World/Origin/Volume.*", - spawn=None, - init_state=DeformableObjectCfg.InitialStateCfg(), - ) - volume_deformable_object = DeformableObject(cfg=cfg) - scene_entities["volume_deformable_object"] = volume_deformable_object - if num_surfaces > 0: - cfg = DeformableObjectCfg( - prim_path="/World/Origin/Surface.*", - spawn=None, - init_state=DeformableObjectCfg.InitialStateCfg(), - ) - surface_deformable_object = DeformableObject(cfg=cfg) - scene_entities["surface_deformable_object"] = surface_deformable_object + prim_path = f"/World/Origin/Volume{idx:02d}" + cfg = DeformableObjectCfg( + prim_path=prim_path, + spawn=obj_cfg, + init_state=DeformableObjectCfg.InitialStateCfg(pos=origin), + ) + scene_entities[f"Volume{idx:02d}"] = DeformableObject(cfg=cfg) # return the scene information return scene_entities, origins @@ -224,7 +237,17 @@ def run_simulator(sim: sim_utils.SimulationContext, entities: dict[str, Deformab def main(): """Main function.""" # Initialize the simulation context - sim_cfg = sim_utils.SimulationCfg(dt=0.01, device=args_cli.device) + if args_cli.backend == "newton": + from isaaclab_newton.physics import NewtonCfg + + from isaaclab_contrib.deformable.newton_manager_cfg import VBDSolverCfg + + physics_cfg = NewtonCfg(solver_cfg=VBDSolverCfg(iterations=10), num_substeps=4) + else: + from isaaclab_physx.physics import PhysxCfg + + physics_cfg = PhysxCfg() + sim_cfg = sim_utils.SimulationCfg(dt=0.01, device=args_cli.device, physics=physics_cfg) sim = sim_utils.SimulationContext(sim_cfg) # Set main camera sim.set_camera_view([4.0, 4.0, 3.0], [0.5, 0.5, 0.0]) diff --git a/scripts/environments/state_machine/lift_franka_soft.py b/scripts/environments/state_machine/lift_franka_soft.py new file mode 100644 index 000000000000..a9561718a591 --- /dev/null +++ b/scripts/environments/state_machine/lift_franka_soft.py @@ -0,0 +1,382 @@ +# 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 + +""" +Script to demonstrate lifting a deformable object with a robotic arm. + +The state machine is implemented in the kernel function `infer_state_machine`. +It uses the `warp` library to run the state machine in parallel on the GPU. + +.. code-block:: bash + + ./isaaclab.sh -p scripts/environments/state_machine/lift_franka_soft.py + +""" + +"""Launch Omniverse Toolkit first.""" + +import argparse + +from isaaclab.app import AppLauncher + +# add argparse arguments +parser = argparse.ArgumentParser(description="Pick and lift a deformable with a robotic arm.") +parser.add_argument("--num_envs", type=int, default=1, help="Number of environments to simulate.") +parser.add_argument("--task", type=str, default="Isaac-Lift-Soft-Franka-v0", help="The task to run.") +parser.add_argument("--video", action="store_true", default=False, help="Record a video of the rollout.") +parser.add_argument("--video_length", type=int, default=500, help="Length of the recorded video (in env steps).") +parser.add_argument( + "--video_folder", + type=str, + default="videos/lift_franka_soft", + help="Directory to write recorded videos into.", +) +# append AppLauncher cli args +AppLauncher.add_app_launcher_args(parser) +# parse the arguments +args_cli = parser.parse_args() + +# RecordVideo needs an rgb_array render mode, which in turn requires cameras to be enabled. +if args_cli.video: + args_cli.enable_cameras = True + +# launch omniverse app +app_launcher = AppLauncher(args_cli) +simulation_app = app_launcher.app + +# disable metrics assembler due to scene graph instancing +from isaacsim.core.experimental.utils.app import enable_extension + +enable_extension("omni.usd.metrics.assembler.ui", enabled=False) + +"""Rest everything else.""" + +import os +from collections.abc import Sequence + +import gymnasium as gym +import torch +import warp as wp + +from isaaclab.assets.deformable_object.deformable_object_data import DeformableObjectData + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.manager_based.manipulation.lift_franka_soft.franka_cloth_env_cfg import FrankaClothEnvCfg +from isaaclab_tasks.manager_based.manipulation.lift_franka_soft.franka_soft_env_cfg import FrankaSoftEnvCfg +from isaaclab_tasks.utils.parse_cfg import parse_env_cfg + +# initialize warp +wp.init() + + +class GripperState: + """States for the gripper.""" + + OPEN = wp.constant(1.0) + CLOSE = wp.constant(-1.0) + + +class PickSmState: + """States for the pick state machine.""" + + REST = wp.constant(0) + APPROACH_ABOVE_OBJECT = wp.constant(1) + APPROACH_OBJECT = wp.constant(2) + GRASP_OBJECT = wp.constant(3) + LIFT_OBJECT = wp.constant(4) + OPEN_GRIPPER = wp.constant(5) + + +@wp.func +def distance_below_threshold(current_pos: wp.vec3, desired_pos: wp.vec3, threshold: float) -> bool: + return wp.length(current_pos - desired_pos) < threshold + + +@wp.kernel +def infer_state_machine( + dt: wp.array(dtype=float), + sm_state: wp.array(dtype=int), + sm_wait_time: wp.array(dtype=float), + ee_pose: wp.array(dtype=wp.transform), + object_pose: wp.array(dtype=wp.transform), + des_object_pose: wp.array(dtype=wp.transform), + des_ee_pose: wp.array(dtype=wp.transform), + gripper_state: wp.array(dtype=float), + offset: wp.array(dtype=wp.transform), + position_threshold: float, +): + # retrieve thread id + tid = wp.tid() + # retrieve state machine state + state = sm_state[tid] + # decide next state + if state == PickSmState.REST: + des_ee_pose[tid] = ee_pose[tid] + gripper_state[tid] = GripperState.OPEN + # wait for a while + if sm_wait_time[tid] >= PickSmWaitTime.REST: + # move to next state and reset wait time + sm_state[tid] = PickSmState.APPROACH_ABOVE_OBJECT + sm_wait_time[tid] = 0.0 + elif state == PickSmState.APPROACH_ABOVE_OBJECT: + des_ee_pose[tid] = wp.transform_multiply(offset[tid], object_pose[tid]) + gripper_state[tid] = GripperState.OPEN + if distance_below_threshold( + wp.transform_get_translation(ee_pose[tid]), + wp.transform_get_translation(des_ee_pose[tid]), + position_threshold, + ): + # wait for a while + if sm_wait_time[tid] >= PickSmWaitTime.APPROACH_OBJECT: + # move to next state and reset wait time + sm_state[tid] = PickSmState.APPROACH_OBJECT + sm_wait_time[tid] = 0.0 + elif state == PickSmState.APPROACH_OBJECT: + des_ee_pose[tid] = object_pose[tid] + gripper_state[tid] = GripperState.OPEN + if distance_below_threshold( + wp.transform_get_translation(ee_pose[tid]), + wp.transform_get_translation(des_ee_pose[tid]), + position_threshold, + ): + # wait for a while + if sm_wait_time[tid] >= PickSmWaitTime.APPROACH_OBJECT: + # move to next state and reset wait time + sm_state[tid] = PickSmState.GRASP_OBJECT + sm_wait_time[tid] = 0.0 + elif state == PickSmState.GRASP_OBJECT: + des_ee_pose[tid] = object_pose[tid] + gripper_state[tid] = GripperState.CLOSE + # wait for a while + if sm_wait_time[tid] >= PickSmWaitTime.GRASP_OBJECT: + # move to next state and reset wait time + sm_state[tid] = PickSmState.LIFT_OBJECT + sm_wait_time[tid] = 0.0 + elif state == PickSmState.LIFT_OBJECT: + des_ee_pose[tid] = des_object_pose[tid] + gripper_state[tid] = GripperState.CLOSE + if distance_below_threshold( + wp.transform_get_translation(ee_pose[tid]), + wp.transform_get_translation(des_ee_pose[tid]), + position_threshold, + ): + # wait for a while + if sm_wait_time[tid] >= PickSmWaitTime.LIFT_OBJECT: + # move to next state and reset wait time + sm_state[tid] = PickSmState.OPEN_GRIPPER + sm_wait_time[tid] = 0.0 + elif state == PickSmState.OPEN_GRIPPER: + # des_ee_pose[tid] = object_pose[tid] + gripper_state[tid] = GripperState.OPEN + # wait for a while + if sm_wait_time[tid] >= PickSmWaitTime.OPEN_GRIPPER: + # move to next state and reset wait time + sm_state[tid] = PickSmState.OPEN_GRIPPER + sm_wait_time[tid] = 0.0 + # increment wait time + sm_wait_time[tid] = sm_wait_time[tid] + dt[tid] + + +class PickSmWaitTime: + """Additional wait times (in s) for states for before switching. + + Wait times are generous because the low-PD Franka takes a while to settle on each IK target. + """ + + REST = wp.constant(0.2) + APPROACH_ABOVE_OBJECT = wp.constant(1.0) + APPROACH_OBJECT = wp.constant(1.0) + GRASP_OBJECT = wp.constant(1.0) + LIFT_OBJECT = wp.constant(1.5) + OPEN_GRIPPER = wp.constant(0.0) + + +class PickAndLiftSm: + """A simple state machine in a robot's task space to pick and lift an object. + + The state machine is implemented as a warp kernel. It takes in the current state of + the robot's end-effector and the object, and outputs the desired state of the robot's + end-effector and the gripper. The state machine is implemented as a finite state + machine with the following states: + + 1. REST: The robot is at rest. + 2. APPROACH_ABOVE_OBJECT: The robot moves above the object. + 3. APPROACH_OBJECT: The robot moves to the object. + 4. GRASP_OBJECT: The robot grasps the object. + 5. LIFT_OBJECT: The robot lifts the object to the desired pose. This is the final state. + """ + + def __init__(self, dt: float, num_envs: int, device: torch.device | str = "cpu", position_threshold=0.03): + """Initialize the state machine. + + Args: + dt: The environment time step. + num_envs: The number of environments to simulate. + device: The device to run the state machine on. + """ + # save parameters + self.dt = float(dt) + self.num_envs = num_envs + self.device = device + self.position_threshold = position_threshold + # initialize state machine + self.sm_dt = torch.full((self.num_envs,), self.dt, device=self.device) + self.sm_state = torch.full((self.num_envs,), 0, dtype=torch.int32, device=self.device) + self.sm_wait_time = torch.zeros((self.num_envs,), device=self.device) + + # desired state + self.des_ee_pose = torch.zeros((self.num_envs, 7), device=self.device) + self.des_gripper_state = torch.full((self.num_envs,), 0.0, device=self.device) + + # approach above object offset + self.offset = torch.zeros((self.num_envs, 7), device=self.device) + self.offset[:, 2] = 0.1 + self.offset[:, -1] = 1.0 # warp expects quaternion as (x, y, z, w) + + # convert to warp + self.sm_dt_wp = wp.from_torch(self.sm_dt, wp.float32) + self.sm_state_wp = wp.from_torch(self.sm_state, wp.int32) + self.sm_wait_time_wp = wp.from_torch(self.sm_wait_time, wp.float32) + self.des_ee_pose_wp = wp.from_torch(self.des_ee_pose, wp.transform) + self.des_gripper_state_wp = wp.from_torch(self.des_gripper_state, wp.float32) + self.offset_wp = wp.from_torch(self.offset, wp.transform) + + def reset_idx(self, env_ids: Sequence[int] = None): + """Reset the state machine.""" + if env_ids is None: + env_ids = slice(None) + self.sm_state[env_ids] = 0 + self.sm_wait_time[env_ids] = 0.0 + + def compute(self, ee_pose: torch.Tensor, object_pose: torch.Tensor, des_object_pose: torch.Tensor): + """Compute the desired state of the robot's end-effector and the gripper.""" + + # convert to warp + ee_pose_wp = wp.from_torch(ee_pose.contiguous(), wp.transform) + object_pose_wp = wp.from_torch(object_pose.contiguous(), wp.transform) + des_object_pose_wp = wp.from_torch(des_object_pose.contiguous(), wp.transform) + + # run state machine + wp.launch( + kernel=infer_state_machine, + dim=self.num_envs, + inputs=[ + self.sm_dt_wp, + self.sm_state_wp, + self.sm_wait_time_wp, + ee_pose_wp, + object_pose_wp, + des_object_pose_wp, + self.des_ee_pose_wp, + self.des_gripper_state_wp, + self.offset_wp, + self.position_threshold, + ], + device=self.device, + ) + + # convert to torch + return torch.cat([self.des_ee_pose, self.des_gripper_state.unsqueeze(-1)], dim=-1) + + +def main(): + # create environment + render_mode = "rgb_array" if args_cli.video else None + if args_cli.task == "Isaac-Lift-Soft-Franka-v0": + # parse configuration + env_cfg: FrankaSoftEnvCfg = parse_env_cfg( + "Isaac-Lift-Soft-Franka-v0", + device=args_cli.device, + num_envs=args_cli.num_envs, + ) + env_cfg.viewer.eye = (2.1, 1.0, 1.3) + env = gym.make("Isaac-Lift-Soft-Franka-v0", cfg=env_cfg, render_mode=render_mode) + elif args_cli.task == "Isaac-Lift-Soft-Franka-Cloth-v0": + # parse configuration + env_cfg: FrankaClothEnvCfg = parse_env_cfg( + "Isaac-Lift-Cloth-Franka-v0", + device=args_cli.device, + num_envs=args_cli.num_envs, + ) + env_cfg.viewer.eye = (2.1, 1.0, 1.3) + env = gym.make("Isaac-Lift-Cloth-Franka-v0", cfg=FrankaClothEnvCfg(), render_mode=render_mode) + else: + raise ValueError(f"Unknown task: {args_cli.task}") + + # wrap for video recording + if args_cli.video: + video_folder = os.path.abspath(args_cli.video_folder) + os.makedirs(video_folder, exist_ok=True) + env = gym.wrappers.RecordVideo( + env, + video_folder=video_folder, + step_trigger=lambda step: step == 0, + video_length=args_cli.video_length, + disable_logger=True, + ) + print(f"[INFO] Recording video to {video_folder} (length={args_cli.video_length} steps)") + + # reset environment at start + env.reset() + + # create action buffers (position + quaternion) + actions = torch.zeros(env.unwrapped.action_space.shape, device=env.unwrapped.device) + actions[:, 3] = 1.0 + # desired rotation after grasping + desired_orientation = torch.zeros((env.unwrapped.num_envs, 4), device=env.unwrapped.device) + desired_orientation[:, 0] = 1.0 + + # Top-down approach: identity quaternion (wxyz, w=1) aligns panda_hand with the Franka root, + # giving the canonical top-down grasp pose. The bar lies along world-X, so the gripper + # closes across its short side without any wrist twist. + object_grasp_orientation = torch.zeros((env.unwrapped.num_envs, 4), device=env.unwrapped.device) + object_grasp_orientation[:, 0] = 1.0 + # Grasp at the deformable's centre of mass. + object_local_grasp_position = torch.tensor([0.0, 0.0, 0.0], device=env.unwrapped.device) + + # create state machine + pick_sm = PickAndLiftSm(env_cfg.sim.dt * env_cfg.decimation, env.unwrapped.num_envs, env.unwrapped.device) + + while simulation_app.is_running(): + # run everything in inference mode + with torch.inference_mode(): + # step environment + dones = env.step(actions)[-2] + + # observations + # -- end-effector frame + ee_frame_sensor = env.unwrapped.scene["ee_frame"] + tcp_rest_position = ( + ee_frame_sensor.data.target_pos_w.torch[..., 0, :].clone() - env.unwrapped.scene.env_origins + ) + tcp_rest_orientation = ee_frame_sensor.data.target_quat_w.torch[..., 0, :].clone() + # -- object frame + object_data: DeformableObjectData = env.unwrapped.scene["deformable"].data + object_position = object_data.root_pos_w.torch - env.unwrapped.scene.env_origins + object_position += object_local_grasp_position + + # -- target object frame + desired_position = env.unwrapped.command_manager.get_command("deformable_pose")[..., :3] + + # advance state machine + actions = pick_sm.compute( + torch.cat([tcp_rest_position, tcp_rest_orientation], dim=-1), + torch.cat([object_position, object_grasp_orientation], dim=-1), + torch.cat([desired_position, desired_orientation], dim=-1), + ) + + # reset state machine + if dones.any(): + pick_sm.reset_idx(dones.nonzero(as_tuple=False).squeeze(-1)) + + # close the environment + env.close() + + +if __name__ == "__main__": + # run the main function + main() + # close sim app + simulation_app.close() diff --git a/scripts/tutorials/00_sim/spawn_prims.py b/scripts/tutorials/00_sim/spawn_prims.py index 7c120bd308dd..8d366c650e6b 100644 --- a/scripts/tutorials/00_sim/spawn_prims.py +++ b/scripts/tutorials/00_sim/spawn_prims.py @@ -31,6 +31,9 @@ """Rest everything follows.""" +from isaaclab_physx.sim.schemas import PhysxDeformableBodyPropertiesCfg +from isaaclab_physx.sim.spawners.materials import PhysxDeformableBodyMaterialCfg + import isaaclab.sim as sim_utils from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR @@ -75,9 +78,9 @@ def design_scene(): # spawn a blue cuboid with deformable body cfg_cuboid_deformable = sim_utils.MeshCuboidCfg( size=(0.2, 0.5, 0.2), - deformable_props=sim_utils.DeformableBodyPropertiesCfg(), + deformable_props=PhysxDeformableBodyPropertiesCfg(), visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.0, 1.0)), - physics_material=sim_utils.DeformableBodyMaterialCfg(), + physics_material=PhysxDeformableBodyMaterialCfg(), ) cfg_cuboid_deformable.func("/World/Objects/CuboidDeformable", cfg_cuboid_deformable, translation=(0.15, 0.0, 2.0)) diff --git a/scripts/tutorials/01_assets/run_deformable_object.py b/scripts/tutorials/01_assets/run_deformable_object.py index 368fe7c3358e..9d42a5301ba3 100644 --- a/scripts/tutorials/01_assets/run_deformable_object.py +++ b/scripts/tutorials/01_assets/run_deformable_object.py @@ -22,6 +22,7 @@ # add argparse arguments parser = argparse.ArgumentParser(description="Tutorial on interacting with a deformable object.") +parser.add_argument("--backend", type=str, default="physx", choices=["physx", "newton"], help="Physics backend.") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # demos should open Kit visualizer by default @@ -36,14 +37,10 @@ """Rest everything follows.""" import torch -from isaaclab_physx.assets import DeformableObject, DeformableObjectCfg - -# deformables supported in PhysX -from isaaclab_physx.sim import DeformableBodyMaterialCfg, DeformableBodyPropertiesCfg import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils -from isaaclab.sim import SimulationContext +from isaaclab.assets import DeformableObject, DeformableObjectCfg def design_scene(): @@ -58,20 +55,43 @@ def design_scene(): # Create a dictionary for the scene entities scene_entities = {} - # Create separate groups called "Origin0", "Origin1", ... - # Each group will have a robot in it + # Create separate groups called "env_0", "env_1", ... + # Newton's scene loader requires the "env_\d+" naming convention to + # detect per-environment Xforms and replicate them as separate worlds. origins = [[0.25, 0.25, 0.0], [-0.25, 0.25, 0.0], [0.25, -0.25, 0.0], [-0.25, -0.25, 0.0]] for i, origin in enumerate(origins): - sim_utils.create_prim(f"/World/Origin{i}", "Xform", translation=origin) + sim_utils.create_prim(f"/World/env_{i}", "Xform", translation=origin) + + youngs_modulus = 1e5 + poissons_ratio = 0.4 + density = 500.0 + if args_cli.backend == "newton": + from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg + from isaaclab_newton.sim.spawners.materials import NewtonDeformableBodyMaterialCfg + + deformable_props = NewtonDeformableBodyPropertiesCfg() + physics_material = NewtonDeformableBodyMaterialCfg( + k_mu=youngs_modulus / (2.0 * (1.0 + poissons_ratio)), + k_lambda=youngs_modulus * poissons_ratio / ((1.0 + poissons_ratio) * (1.0 - 2.0 * poissons_ratio)), + density=density, + ) + else: + from isaaclab_physx.sim.schemas import PhysxDeformableBodyPropertiesCfg + from isaaclab_physx.sim.spawners.materials import PhysxDeformableBodyMaterialCfg + + deformable_props = PhysxDeformableBodyPropertiesCfg(rest_offset=0.0, contact_offset=0.001) + physics_material = PhysxDeformableBodyMaterialCfg( + poissons_ratio=poissons_ratio, youngs_modulus=youngs_modulus, density=density + ) # 3D Deformable Object cfg = DeformableObjectCfg( - prim_path="/World/Origin.*/Cube", + prim_path="/World/env_.*/Cube", spawn=sim_utils.MeshCuboidCfg( size=(0.2, 0.2, 0.2), - deformable_props=DeformableBodyPropertiesCfg(), + deformable_props=deformable_props, visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.5, 0.1, 0.0)), - physics_material=DeformableBodyMaterialCfg(poissons_ratio=0.4, youngs_modulus=1e5), + physics_material=physics_material, ), init_state=DeformableObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), debug_vis=True, @@ -155,8 +175,18 @@ def run_simulator(sim: sim_utils.SimulationContext, entities: dict, origins: tor def main(): """Main function.""" # Load kit helper - sim_cfg = sim_utils.SimulationCfg(dt=0.01, device=args_cli.device) - sim = SimulationContext(sim_cfg) + if args_cli.backend == "newton": + from isaaclab_newton.physics import NewtonCfg + + from isaaclab_contrib.deformable.newton_manager_cfg import VBDSolverCfg + + physics_cfg = NewtonCfg(solver_cfg=VBDSolverCfg(iterations=10), num_substeps=4) + else: + from isaaclab_physx.physics import PhysxCfg + + physics_cfg = PhysxCfg() + sim_cfg = sim_utils.SimulationCfg(dt=0.01, device=args_cli.device, physics=physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) # Set main camera sim.set_camera_view(eye=[2.0, 2.0, 2.0], target=[0.0, 0.0, 0.75]) # Design scene diff --git a/source/isaaclab/changelog.d/mym-deformable-backend-split.major.rst b/source/isaaclab/changelog.d/mym-deformable-backend-split.major.rst new file mode 100644 index 000000000000..1fd4b8967bea --- /dev/null +++ b/source/isaaclab/changelog.d/mym-deformable-backend-split.major.rst @@ -0,0 +1,19 @@ +Added +^^^^^ + +* Added common deformable property and material base cfgs in :mod:`isaaclab.sim`. + +Changed +^^^^^^^ + +* Changed deformable spawners to accept backend-specific deformable property and + material cfgs. Use PhysX cfgs from :mod:`isaaclab_physx.sim` or Newton cfgs + from :mod:`isaaclab_newton.sim`. + +Deprecated +^^^^^^^^^^ + +* Deprecated generic deformable property and material cfgs in favor of + ``PhysxDeformableBodyPropertiesCfg``, ``PhysxDeformableBodyMaterialCfg``, + ``PhysxSurfaceDeformableBodyMaterialCfg``, ``NewtonDeformableBodyPropertiesCfg``, + ``NewtonDeformableBodyMaterialCfg``, and ``NewtonSurfaceDeformableBodyMaterialCfg``. diff --git a/source/isaaclab/changelog.d/mym-deformable_experimental.minor.rst b/source/isaaclab/changelog.d/mym-deformable_experimental.minor.rst new file mode 100644 index 000000000000..059828bf03ab --- /dev/null +++ b/source/isaaclab/changelog.d/mym-deformable_experimental.minor.rst @@ -0,0 +1,34 @@ +Added +^^^^^ + +* Added backend-neutral deformable asset APIs, including + :class:`~isaaclab.assets.DeformableObject`, + :class:`~isaaclab.assets.DeformableObjectCfg`, and shared base/data classes. +* Added deformable body spawner, schema, and material APIs under + :mod:`isaaclab.sim`, including + :class:`~isaaclab.sim.DeformableObjectSpawnerCfg`, + :class:`~isaaclab.sim.DeformableBodyPropertiesCfg`, + :func:`~isaaclab.sim.define_deformable_body_properties`, + :func:`~isaaclab.sim.modify_deformable_body_properties`, + :class:`~isaaclab.sim.DeformableBodyMaterialCfg`, + :class:`~isaaclab.sim.SurfaceDeformableBodyMaterialCfg`, and + :func:`~isaaclab.sim.spawn_deformable_body_material`. +* Added ``pytetwild`` as a package dependency for tetrahedral mesh generation. +* Added deformable API, migration, and tutorial documentation for + backend-neutral imports and Newton backend selection. + +Changed +^^^^^^^ + +* Changed deformable demos and tutorials to use the backend-neutral + :mod:`isaaclab.assets` and :mod:`isaaclab.sim` APIs with selectable PhysX or + Newton backends. +* Changed USD spawning to support deformable objects whose USD assets contain + embedded tetrahedral mesh data. +* **Breaking:** Renamed :class:`~isaaclab.sim.spawners.meshes.MeshSquareCfg` + to :class:`~isaaclab.sim.spawners.meshes.MeshRectangleCfg` and + :func:`~isaaclab.sim.spawners.meshes.spawn_mesh_square` to + :func:`~isaaclab.sim.spawners.meshes.spawn_mesh_rectangle`. The ``size`` + attribute is now a ``tuple[float, float]`` of X- and Y-axis lengths instead + of a single edge length. Migrate ``MeshSquareCfg(size=s)`` to + ``MeshRectangleCfg(size=(s, s))``. diff --git a/source/isaaclab/isaaclab/assets/__init__.pyi b/source/isaaclab/isaaclab/assets/__init__.pyi index 727eae33002b..55b42b8819ab 100644 --- a/source/isaaclab/isaaclab/assets/__init__.pyi +++ b/source/isaaclab/isaaclab/assets/__init__.pyi @@ -21,6 +21,11 @@ __all__ = [ "RigidObjectCollection", "RigidObjectCollectionCfg", "RigidObjectCollectionData", + "BaseDeformableObject", + "BaseDeformableObjectData", + "DeformableObject", + "DeformableObjectCfg", + "DeformableObjectData", ] from .articulation import ( @@ -46,3 +51,10 @@ from .rigid_object_collection import ( RigidObjectCollectionCfg, RigidObjectCollectionData, ) +from .deformable_object import ( + BaseDeformableObject, + BaseDeformableObjectData, + DeformableObject, + DeformableObjectCfg, + DeformableObjectData, +) diff --git a/source/isaaclab/isaaclab/assets/deformable_object/__init__.py b/source/isaaclab/isaaclab/assets/deformable_object/__init__.py new file mode 100644 index 000000000000..525b6ae5fd98 --- /dev/null +++ b/source/isaaclab/isaaclab/assets/deformable_object/__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 + +"""Sub-module for deformable object assets.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab/isaaclab/assets/deformable_object/__init__.pyi b/source/isaaclab/isaaclab/assets/deformable_object/__init__.pyi new file mode 100644 index 000000000000..ebe78d5b6dbc --- /dev/null +++ b/source/isaaclab/isaaclab/assets/deformable_object/__init__.pyi @@ -0,0 +1,18 @@ +# 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 .base_deformable_object import BaseDeformableObject +from .base_deformable_object_data import BaseDeformableObjectData +from .deformable_object import DeformableObject +from .deformable_object_cfg import DeformableObjectCfg +from .deformable_object_data import DeformableObjectData + +__all__ = [ + "BaseDeformableObject", + "BaseDeformableObjectData", + "DeformableObject", + "DeformableObjectCfg", + "DeformableObjectData", +] diff --git a/source/isaaclab/isaaclab/assets/deformable_object/base_deformable_object.py b/source/isaaclab/isaaclab/assets/deformable_object/base_deformable_object.py new file mode 100644 index 000000000000..e21895a10bfb --- /dev/null +++ b/source/isaaclab/isaaclab/assets/deformable_object/base_deformable_object.py @@ -0,0 +1,371 @@ +# 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 warnings +from abc import abstractmethod +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.assets.asset_base import AssetBase +from isaaclab.utils.warp import ProxyArray + +if TYPE_CHECKING: + from .base_deformable_object_data import BaseDeformableObjectData + from .deformable_object_cfg import DeformableObjectCfg + + +class BaseDeformableObject(AssetBase): + """Abstract base class for deformable object assets. + + Deformable objects are assets that can be deformed in the simulation. They are typically used for + soft bodies, such as stuffed animals, food items, and cloth. + + Unlike rigid object assets, deformable objects have a more complex structure and require additional + handling for simulation. The state of a deformable object comprises of its nodal positions and + velocities, and not the object's root position and orientation. The nodal positions and velocities + are in the simulation frame. + + Soft bodies can be `partially kinematic`_, where some nodes are driven by kinematic targets, and + the rest are simulated. The kinematic targets are the desired positions of the nodes, and the + simulation drives the nodes towards these targets. + + .. _partially kinematic: https://nvidia-omniverse.github.io/PhysX/physx/5.4.1/docs/SoftBodies.html#kinematic-soft-bodies + """ + + cfg: DeformableObjectCfg + """Configuration instance for the deformable object.""" + + __backend_name__: str = "base" + """The name of the backend for the deformable object.""" + + def __init__(self, cfg: DeformableObjectCfg): + """Initialize the deformable object. + + Args: + cfg: A configuration instance. + """ + super().__init__(cfg) + + """ + Properties + """ + + @property + @abstractmethod + def data(self) -> BaseDeformableObjectData: + """Data container for the deformable object.""" + raise NotImplementedError() + + @property + @abstractmethod + def num_instances(self) -> int: + """Number of instances of the asset.""" + raise NotImplementedError() + + @property + @abstractmethod + def num_bodies(self) -> int: + """Number of bodies in the asset. + + This is always 1 since each object is a single deformable body. + """ + raise NotImplementedError() + + @property + @abstractmethod + def max_sim_vertices_per_body(self) -> int: + """The maximum number of simulation mesh vertices per deformable body.""" + raise NotImplementedError() + + """ + Operations. + """ + + @abstractmethod + def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None) -> None: + """Reset the deformable object. + + Args: + env_ids: Environment indices. If None, then all indices are used. + env_mask: Environment mask. If None, then all the instances are updated. + Shape is (num_instances,). + """ + raise NotImplementedError() + + @abstractmethod + def write_data_to_sim(self): + """Write data to the simulator.""" + raise NotImplementedError() + + @abstractmethod + def update(self, dt: float): + """Update the internal buffers. + + Args: + dt: The amount of time passed from last :meth:`update` call [s]. + """ + raise NotImplementedError() + + """ + Operations - Write to simulation (index variants). + """ + + def write_nodal_state_to_sim_index( + self, + nodal_state: torch.Tensor | wp.array | ProxyArray, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + full_data: bool = False, + ) -> None: + """Set the nodal state over selected environment indices into the simulation. + + The nodal state comprises of the nodal positions and velocities. Since these are nodes, + the velocity only has a translational component. All the quantities are in the simulation frame. + + Args: + nodal_state: Nodal state in simulation frame [m, m/s]. + Shape is (len(env_ids), max_sim_vertices_per_body, 6) + or (num_instances, max_sim_vertices_per_body, 6). + env_ids: Environment indices. If None, then all indices are used. + full_data: Whether to expect full data. Defaults to False. + """ + # Convert array wrappers to torch for slicing into position and velocity views. + if isinstance(nodal_state, ProxyArray): + nodal_state = nodal_state.torch + elif isinstance(nodal_state, wp.array): + nodal_state = wp.to_torch(nodal_state) + # set into simulation + self.write_nodal_pos_to_sim_index(nodal_state[..., :3], env_ids=env_ids, full_data=full_data) + self.write_nodal_velocity_to_sim_index(nodal_state[..., 3:], env_ids=env_ids, full_data=full_data) + + @abstractmethod + def write_nodal_pos_to_sim_index( + self, + nodal_pos: torch.Tensor | wp.array | ProxyArray, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + full_data: bool = False, + ) -> None: + """Set the nodal positions over selected environment indices into the simulation. + + Args: + nodal_pos: Nodal positions in simulation frame [m]. + Shape is (len(env_ids), max_sim_vertices_per_body, 3) + or (num_instances, max_sim_vertices_per_body, 3). + env_ids: Environment indices. If None, then all indices are used. + full_data: Whether to expect full data. Defaults to False. + """ + raise NotImplementedError() + + @abstractmethod + def write_nodal_velocity_to_sim_index( + self, + nodal_vel: torch.Tensor | wp.array | ProxyArray, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + full_data: bool = False, + ) -> None: + """Set the nodal velocity over selected environment indices into the simulation. + + Args: + nodal_vel: Nodal velocities in simulation frame [m/s]. + Shape is (len(env_ids), max_sim_vertices_per_body, 3) + or (num_instances, max_sim_vertices_per_body, 3). + env_ids: Environment indices. If None, then all indices are used. + full_data: Whether to expect full data. Defaults to False. + """ + raise NotImplementedError() + + @abstractmethod + def write_nodal_kinematic_target_to_sim_index( + self, + targets: torch.Tensor | wp.array | ProxyArray, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + full_data: bool = False, + ) -> None: + """Set the kinematic targets of the simulation mesh for the deformable bodies using indices. + + The kinematic targets comprise of individual nodal positions of the simulation mesh + for the deformable body and a flag indicating whether the node is kinematically driven or not. + The positions are in the simulation frame. + + .. note:: + The flag is set to 0.0 for kinematically driven nodes and 1.0 for free nodes. + + Args: + targets: The kinematic targets comprising of nodal positions and flags [m]. + Shape is (len(env_ids), max_sim_vertices_per_body, 4) + or (num_instances, max_sim_vertices_per_body, 4). + env_ids: Environment indices. If None, then all indices are used. + full_data: Whether to expect full data. Defaults to False. + """ + raise NotImplementedError() + + """ + Operations - Write to simulation (mask variants). + """ + + def write_nodal_state_to_sim_mask( + self, + nodal_state: torch.Tensor | wp.array | ProxyArray, + env_mask: wp.array | None = None, + ) -> None: + """Set the nodal state over selected environment mask into the simulation. + + Args: + nodal_state: Nodal state in simulation frame [m, m/s]. + Shape is (num_instances, max_sim_vertices_per_body, 6). + env_mask: Environment mask. If None, then all indices are used. + """ + if env_mask is not None: + env_ids = wp.nonzero(env_mask) + else: + env_ids = self._ALL_INDICES + self.write_nodal_state_to_sim_index(nodal_state, env_ids=env_ids, full_data=True) + + def write_nodal_pos_to_sim_mask( + self, + nodal_pos: torch.Tensor | wp.array | ProxyArray, + env_mask: wp.array | None = None, + ) -> None: + """Set the nodal positions over selected environment mask into the simulation. + + Args: + nodal_pos: Nodal positions in simulation frame [m]. + Shape is (num_instances, max_sim_vertices_per_body, 3). + env_mask: Environment mask. If None, then all indices are used. + """ + if env_mask is not None: + env_ids = wp.nonzero(env_mask) + else: + env_ids = self._ALL_INDICES + self.write_nodal_pos_to_sim_index(nodal_pos, env_ids=env_ids, full_data=True) + + def write_nodal_velocity_to_sim_mask( + self, + nodal_vel: torch.Tensor | wp.array | ProxyArray, + env_mask: wp.array | None = None, + ) -> None: + """Set the nodal velocity over selected environment mask into the simulation. + + Args: + nodal_vel: Nodal velocities in simulation frame [m/s]. + Shape is (num_instances, max_sim_vertices_per_body, 3). + env_mask: Environment mask. If None, then all indices are used. + """ + if env_mask is not None: + env_ids = wp.nonzero(env_mask) + else: + env_ids = self._ALL_INDICES + self.write_nodal_velocity_to_sim_index(nodal_vel, env_ids=env_ids, full_data=True) + + def write_nodal_kinematic_target_to_sim_mask( + self, + targets: torch.Tensor | wp.array | ProxyArray, + env_mask: wp.array | None = None, + ) -> None: + """Set the kinematic targets of the simulation mesh for the deformable bodies using mask. + + Args: + targets: The kinematic targets comprising of nodal positions and flags [m]. + Shape is (num_instances, max_sim_vertices_per_body, 4). + env_mask: Environment mask. If None, then all indices are used. + """ + if env_mask is not None: + env_ids = wp.nonzero(env_mask) + else: + env_ids = self._ALL_INDICES + self.write_nodal_kinematic_target_to_sim_index(targets, env_ids=env_ids, full_data=True) + + """ + Operations - Deprecated wrappers. + """ + + def write_nodal_state_to_sim( + self, + nodal_state: torch.Tensor | wp.array | ProxyArray, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated. Please use :meth:`write_nodal_state_to_sim_index` instead.""" + warnings.warn( + "The method 'write_nodal_state_to_sim' is deprecated. Please use 'write_nodal_state_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_nodal_state_to_sim_index(nodal_state, env_ids=env_ids) + + def write_nodal_kinematic_target_to_sim( + self, + targets: torch.Tensor | wp.array | ProxyArray, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated. Please use :meth:`write_nodal_kinematic_target_to_sim_index` instead.""" + warnings.warn( + "The method 'write_nodal_kinematic_target_to_sim' is deprecated." + " Please use 'write_nodal_kinematic_target_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_nodal_kinematic_target_to_sim_index(targets, env_ids=env_ids) + + def write_nodal_pos_to_sim( + self, + nodal_pos: torch.Tensor | wp.array | ProxyArray, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated. Please use :meth:`write_nodal_pos_to_sim_index` instead.""" + warnings.warn( + "The method 'write_nodal_pos_to_sim' is deprecated. Please use 'write_nodal_pos_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_nodal_pos_to_sim_index(nodal_pos, env_ids=env_ids) + + def write_nodal_velocity_to_sim( + self, + nodal_vel: torch.Tensor | wp.array | ProxyArray, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ) -> None: + """Deprecated. Please use :meth:`write_nodal_velocity_to_sim_index` instead.""" + warnings.warn( + "The method 'write_nodal_velocity_to_sim' is deprecated." + " Please use 'write_nodal_velocity_to_sim_index' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.write_nodal_velocity_to_sim_index(nodal_vel, env_ids=env_ids) + + """ + Operations - Helper. + """ + + def transform_nodal_pos( + self, nodal_pos: torch.Tensor, pos: torch.Tensor | None = None, quat: torch.Tensor | None = None + ) -> torch.Tensor: + """Transform the nodal positions based on the pose transformation. + + This function computes the transformation of the nodal positions based on the pose transformation. + It multiplies the nodal positions with the rotation matrix of the pose and adds the translation. + Internally, it calls the :meth:`isaaclab.utils.math.transform_points` function. + + Args: + nodal_pos: The nodal positions in the simulation frame [m]. + Shape is (N, max_sim_vertices_per_body, 3). + pos: The position transformation [m]. Shape is (N, 3). + Defaults to None, in which case the position is assumed to be zero. + quat: The orientation transformation as quaternion (x, y, z, w). Shape is (N, 4). + Defaults to None, in which case the orientation is assumed to be identity. + + Returns: + The transformed nodal positions [m]. Shape is (N, max_sim_vertices_per_body, 3). + """ + # offset the nodal positions to center them around the origin + mean_nodal_pos = nodal_pos.mean(dim=1, keepdim=True) + nodal_pos = nodal_pos - mean_nodal_pos + # transform the nodal positions based on the pose around the origin + return math_utils.transform_points(nodal_pos, pos, quat) + mean_nodal_pos diff --git a/source/isaaclab/isaaclab/assets/deformable_object/base_deformable_object_data.py b/source/isaaclab/isaaclab/assets/deformable_object/base_deformable_object_data.py new file mode 100644 index 000000000000..e0b1ae497511 --- /dev/null +++ b/source/isaaclab/isaaclab/assets/deformable_object/base_deformable_object_data.py @@ -0,0 +1,136 @@ +# 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 + +from abc import ABC, abstractmethod + +from isaaclab.utils.warp import ProxyArray + + +class BaseDeformableObjectData(ABC): + """Abstract data container for a deformable object. + + This class defines the interface for deformable object data in the simulation. + The data includes the nodal states of the root deformable body in the object. + The data is stored in the simulation world frame unless otherwise specified. + + The data is lazily updated, meaning that the data is only updated when it is accessed. + This is useful when the data is expensive to compute or retrieve. The data is updated + when the timestamp of the buffer is older than the current simulation timestamp. + """ + + def __init__(self, device: str): + """Initialize the deformable object data. + + Args: + device: The device used for processing. + """ + self.device = device + # Set initial time stamp + self._sim_timestamp = 0.0 + + def update(self, dt: float): + """Update the data for the deformable object. + + Args: + dt: The time step for the update [s]. This must be a positive value. + """ + # update the simulation timestamp + self._sim_timestamp += dt + + ## + # Defaults. + ## + + default_nodal_state_w: ProxyArray | None = None + """Default nodal state ``[nodal_pos, nodal_vel]`` in simulation world frame. + + Shape is (num_instances, max_sim_vertices_per_body), dtype ``vec6f``. + Use :attr:`ProxyArray.warp` for the underlying :class:`warp.array` or + :attr:`ProxyArray.torch` for a cached zero-copy :class:`torch.Tensor` view. + """ + + ## + # Kinematic commands. + ## + + nodal_kinematic_target: ProxyArray | None = None + """Simulation mesh kinematic targets for the deformable bodies. + + Shape is (num_instances, max_sim_vertices_per_body), dtype ``wp.vec4f``. + Use :attr:`ProxyArray.warp` for the underlying :class:`warp.array` or + :attr:`ProxyArray.torch` for a cached zero-copy :class:`torch.Tensor` view. + + The kinematic targets are used to drive the simulation mesh vertices to the target positions. + The targets are stored as (x, y, z, is_not_kinematic) where "is_not_kinematic" is a binary + flag indicating whether the vertex is kinematic or not. The flag is set to 0 for kinematic vertices + and 1 for non-kinematic vertices. + """ + + ## + # Properties. + ## + + @property + @abstractmethod + def nodal_pos_w(self) -> ProxyArray: + """Nodal positions in simulation world frame [m]. + + Shape is (num_instances, max_sim_vertices_per_body), dtype ``wp.vec3f``. + Use :attr:`ProxyArray.warp` for the underlying :class:`warp.array` or + :attr:`ProxyArray.torch` for a cached zero-copy :class:`torch.Tensor` view. + """ + raise NotImplementedError() + + @property + @abstractmethod + def nodal_vel_w(self) -> ProxyArray: + """Nodal velocities in simulation world frame [m/s]. + + Shape is (num_instances, max_sim_vertices_per_body), dtype ``wp.vec3f``. + Use :attr:`ProxyArray.warp` for the underlying :class:`warp.array` or + :attr:`ProxyArray.torch` for a cached zero-copy :class:`torch.Tensor` view. + """ + raise NotImplementedError() + + @property + @abstractmethod + def nodal_state_w(self) -> ProxyArray: + """Nodal state ``[nodal_pos, nodal_vel]`` in simulation world frame [m, m/s]. + + Shape is (num_instances, max_sim_vertices_per_body), dtype ``vec6f``. + Use :attr:`ProxyArray.warp` for the underlying :class:`warp.array` or + :attr:`ProxyArray.torch` for a cached zero-copy :class:`torch.Tensor` view. + """ + raise NotImplementedError() + + ## + # Derived properties. + ## + + @property + @abstractmethod + def root_pos_w(self) -> ProxyArray: + """Root position from nodal positions of the simulation mesh for the deformable bodies + in simulation world frame [m]. Shape is (num_instances,) vec3f. + + This quantity is computed as the mean of the nodal positions. + Use :attr:`ProxyArray.warp` for the underlying :class:`warp.array` or + :attr:`ProxyArray.torch` for a cached zero-copy :class:`torch.Tensor` view. + """ + raise NotImplementedError() + + @property + @abstractmethod + def root_vel_w(self) -> ProxyArray: + """Root velocity from vertex velocities for the deformable bodies in simulation + world frame [m/s]. Shape is (num_instances,) vec3f. + + This quantity is computed as the mean of the nodal velocities. + Use :attr:`ProxyArray.warp` for the underlying :class:`warp.array` or + :attr:`ProxyArray.torch` for a cached zero-copy :class:`torch.Tensor` view. + """ + raise NotImplementedError() diff --git a/source/isaaclab/isaaclab/assets/deformable_object/deformable_object.py b/source/isaaclab/isaaclab/assets/deformable_object/deformable_object.py new file mode 100644 index 000000000000..3bd41c8728be --- /dev/null +++ b/source/isaaclab/isaaclab/assets/deformable_object/deformable_object.py @@ -0,0 +1,27 @@ +# 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 + +from typing import TYPE_CHECKING + +from isaaclab.utils.backend_utils import FactoryBase + +from .base_deformable_object import BaseDeformableObject +from .base_deformable_object_data import BaseDeformableObjectData + +if TYPE_CHECKING: + from isaaclab_physx.assets.deformable_object import DeformableObject as PhysXDeformableObject + from isaaclab_physx.assets.deformable_object import DeformableObjectData as PhysXDeformableObjectData + + +class DeformableObject(FactoryBase, BaseDeformableObject): + """Factory for creating deformable object instances.""" + + data: BaseDeformableObjectData | PhysXDeformableObjectData + + def __new__(cls, *args, **kwargs) -> BaseDeformableObject | PhysXDeformableObject: + """Create a new instance of a deformable object based on the backend.""" + return super().__new__(cls, *args, **kwargs) diff --git a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_cfg.py b/source/isaaclab/isaaclab/assets/deformable_object/deformable_object_cfg.py similarity index 100% rename from source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_cfg.py rename to source/isaaclab/isaaclab/assets/deformable_object/deformable_object_cfg.py diff --git a/source/isaaclab/isaaclab/assets/deformable_object/deformable_object_data.py b/source/isaaclab/isaaclab/assets/deformable_object/deformable_object_data.py new file mode 100644 index 000000000000..35567aceb847 --- /dev/null +++ b/source/isaaclab/isaaclab/assets/deformable_object/deformable_object_data.py @@ -0,0 +1,25 @@ +# 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 + +from typing import TYPE_CHECKING + +from isaaclab.utils.backend_utils import FactoryBase + +from .base_deformable_object_data import BaseDeformableObjectData + +if TYPE_CHECKING: + from isaaclab_physx.assets.deformable_object.deformable_object_data import ( + DeformableObjectData as PhysXDeformableObjectData, + ) + + +class DeformableObjectData(FactoryBase): + """Factory for creating deformable object data instances.""" + + def __new__(cls, *args, **kwargs) -> BaseDeformableObjectData | PhysXDeformableObjectData: + """Create a new instance of a deformable object data based on the backend.""" + return super().__new__(cls, *args, **kwargs) diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 70756007d6fb..b8df495d1e79 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from isaaclab_physx.assets import DeformableObject, SurfaceGripper + from isaaclab_physx.assets import SurfaceGripper from isaaclab.renderers.base_renderer import BaseRenderer @@ -25,6 +25,8 @@ Articulation, ArticulationCfg, AssetBaseCfg, + DeformableObject, + DeformableObjectCfg, RigidObject, RigidObjectCfg, RigidObjectCollection, @@ -878,7 +880,7 @@ def _is_scene_setup_from_cfg(self) -> bool: def _add_entities_from_cfg(self): # noqa: C901 """Add scene entities from the config.""" - from isaaclab_physx.assets import DeformableObjectCfg, SurfaceGripperCfg # noqa: PLC0415 + from isaaclab_physx.assets import SurfaceGripperCfg # noqa: PLC0415 # store paths that are in global collision filter self._global_prim_paths = list() diff --git a/source/isaaclab/isaaclab/sim/__init__.py b/source/isaaclab/isaaclab/sim/__init__.py index e1458e7873f3..a0f1d706e1eb 100644 --- a/source/isaaclab/isaaclab/sim/__init__.py +++ b/source/isaaclab/isaaclab/sim/__init__.py @@ -40,8 +40,9 @@ "PhysxJointDrivePropertiesCfg", "CollisionPropertiesCfg", "PhysxCollisionPropertiesCfg", - "PhysXCollisionPropertiesCfg", + "DeformableBodyPropertiesCfg", "PhysxDeformableCollisionPropertiesCfg", + "PhysxDeformableBodyPropertiesCfg", "ArticulationRootPropertiesCfg", "PhysxArticulationRootPropertiesCfg", "MeshCollisionPropertiesCfg", @@ -63,8 +64,12 @@ # Names that moved out of this package into ``isaaclab_physx.sim.spawners.materials``. _PHYSX_FORWARDS_MATERIALS = frozenset({ + "DeformableBodyMaterialCfg", "RigidBodyMaterialCfg", + "SurfaceDeformableBodyMaterialCfg", "PhysxRigidBodyMaterialCfg", + "PhysxDeformableBodyMaterialCfg", + "PhysxSurfaceDeformableBodyMaterialCfg", }) _PHYSX_FORWARDS = _PHYSX_FORWARDS_SCHEMAS | _PHYSX_FORWARDS_MATERIALS diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 2d5edfdf921f..0c787cc64c67 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -22,11 +22,13 @@ __all__ = [ "activate_contact_sensors", "define_articulation_root_properties", "define_collision_properties", + "define_deformable_body_properties", "define_mass_properties", "define_mesh_collision_properties", "define_rigid_body_properties", "modify_articulation_root_properties", "modify_collision_properties", + "modify_deformable_body_properties", "modify_fixed_tendon_properties", "modify_joint_drive_properties", "modify_mass_properties", @@ -39,6 +41,8 @@ __all__ = [ "CollisionBaseCfg", "ConvexDecompositionPropertiesCfg", "ConvexHullPropertiesCfg", + "DeformableBodyPropertiesBaseCfg", + "DeformableBodyPropertiesCfg", "FixedTendonPropertiesCfg", "JointDriveBaseCfg", "MassPropertiesCfg", @@ -60,6 +64,7 @@ __all__ = [ "TriangleMeshSimplificationPropertiesCfg", "SpawnerCfg", "RigidObjectSpawnerCfg", + "DeformableObjectSpawnerCfg", "spawn_from_mjcf", "spawn_from_urdf", "spawn_from_usd", @@ -78,8 +83,13 @@ __all__ = [ "LightCfg", "SphereLightCfg", "spawn_rigid_body_material", + "spawn_deformable_body_material", "PhysicsMaterialCfg", "RigidBodyMaterialCfg", + "DeformableBodyMaterialBaseCfg", + "DeformableBodyMaterialCfg", + "SurfaceDeformableBodyMaterialBaseCfg", + "SurfaceDeformableBodyMaterialCfg", "spawn_from_mdl_file", "spawn_preview_surface", "GlassMdlCfg", @@ -90,15 +100,15 @@ __all__ = [ "spawn_mesh_cone", "spawn_mesh_cuboid", "spawn_mesh_cylinder", + "spawn_mesh_rectangle", "spawn_mesh_sphere", - "spawn_mesh_square", "MeshCapsuleCfg", "MeshCfg", "MeshConeCfg", "MeshCuboidCfg", "MeshCylinderCfg", + "MeshRectangleCfg", "MeshSphereCfg", - "MeshSquareCfg", "spawn_camera", "spawn_sensor_frame", "FisheyeCameraCfg", @@ -198,11 +208,13 @@ from .schemas import ( activate_contact_sensors, define_articulation_root_properties, define_collision_properties, + define_deformable_body_properties, define_mass_properties, define_mesh_collision_properties, define_rigid_body_properties, modify_articulation_root_properties, modify_collision_properties, + modify_deformable_body_properties, modify_fixed_tendon_properties, modify_joint_drive_properties, modify_mass_properties, @@ -215,6 +227,8 @@ from .schemas import ( CollisionBaseCfg, ConvexDecompositionPropertiesCfg, ConvexHullPropertiesCfg, + DeformableBodyPropertiesBaseCfg, + DeformableBodyPropertiesCfg, FixedTendonPropertiesCfg, JointDriveBaseCfg, MassPropertiesCfg, @@ -240,6 +254,7 @@ NewtonRigidBodyPropertiesCfg = ... from .spawners import ( SpawnerCfg, RigidObjectSpawnerCfg, + DeformableObjectSpawnerCfg, spawn_from_mjcf, spawn_from_urdf, spawn_from_usd, @@ -258,8 +273,13 @@ from .spawners import ( LightCfg, SphereLightCfg, spawn_rigid_body_material, + spawn_deformable_body_material, PhysicsMaterialCfg, RigidBodyMaterialCfg, + DeformableBodyMaterialBaseCfg, + DeformableBodyMaterialCfg, + SurfaceDeformableBodyMaterialBaseCfg, + SurfaceDeformableBodyMaterialCfg, spawn_from_mdl_file, spawn_preview_surface, GlassMdlCfg, @@ -270,15 +290,15 @@ from .spawners import ( spawn_mesh_cone, spawn_mesh_cuboid, spawn_mesh_cylinder, + spawn_mesh_rectangle, spawn_mesh_sphere, - spawn_mesh_square, MeshCapsuleCfg, MeshCfg, MeshConeCfg, MeshCuboidCfg, MeshCylinderCfg, + MeshRectangleCfg, MeshSphereCfg, - MeshSquareCfg, spawn_camera, spawn_sensor_frame, FisheyeCameraCfg, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.py b/source/isaaclab/isaaclab/sim/schemas/__init__.py index 223627d4e523..ed0e5a30ef07 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.py +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.py @@ -46,8 +46,9 @@ "PhysxJointDrivePropertiesCfg", "CollisionPropertiesCfg", "PhysxCollisionPropertiesCfg", - "PhysXCollisionPropertiesCfg", + "DeformableBodyPropertiesCfg", "PhysxDeformableCollisionPropertiesCfg", + "PhysxDeformableBodyPropertiesCfg", "ArticulationRootPropertiesCfg", "PhysxArticulationRootPropertiesCfg", "MeshCollisionPropertiesCfg", diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index a9306afdf414..8d753b553d6c 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -11,11 +11,13 @@ __all__ = [ "define_actuator_properties", "define_articulation_root_properties", "define_collision_properties", + "define_deformable_body_properties", "define_mass_properties", "define_mesh_collision_properties", "define_rigid_body_properties", "modify_articulation_root_properties", "modify_collision_properties", + "modify_deformable_body_properties", "modify_fixed_tendon_properties", "modify_joint_drive_properties", "modify_mass_properties", @@ -26,6 +28,8 @@ __all__ = [ "BoundingCubePropertiesCfg", "BoundingSpherePropertiesCfg", "CollisionBaseCfg", + "DeformableBodyPropertiesBaseCfg", + "DeformableBodyPropertiesCfg", "JointDriveBaseCfg", "MassPropertiesCfg", "MeshCollisionBaseCfg", @@ -47,11 +51,13 @@ from .schemas import ( activate_contact_sensors, define_articulation_root_properties, define_collision_properties, + define_deformable_body_properties, define_mass_properties, define_mesh_collision_properties, define_rigid_body_properties, modify_articulation_root_properties, modify_collision_properties, + modify_deformable_body_properties, modify_fixed_tendon_properties, modify_joint_drive_properties, modify_mass_properties, @@ -67,6 +73,8 @@ from .schemas_cfg import ( BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, CollisionBaseCfg, + DeformableBodyPropertiesBaseCfg, + DeformableBodyPropertiesCfg, JointDriveBaseCfg, MassPropertiesCfg, MeshCollisionBaseCfg, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index b7a74e27ff25..8bd2c314bf93 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -10,14 +10,19 @@ import logging import math -from pxr import Usd, UsdPhysics +import numpy as np +import warp as wp + +from pxr import Sdf, Usd, UsdGeom, UsdPhysics from isaaclab.sim.utils.stage import get_current_stage from isaaclab.utils.string import to_camel_case from ..utils import ( apply_nested, + create_prim, find_global_fixed_joint_prim, + get_all_matching_child_prims, safe_set_attribute_on_usd_prim, safe_set_attribute_on_usd_schema, ) @@ -1071,3 +1076,360 @@ def modify_mesh_collision_properties( # success return True + + +""" +Deformable body properties. +""" + + +@wp.kernel +def _fix_tet_winding_kernel( + points: wp.array(dtype=wp.vec3), + tet_indices: wp.array(ndim=2, dtype=wp.int32), +): + """Flip any tet with negative signed volume by swapping its last two vertex indices. + + ``UsdGeom.TetMesh`` and :meth:`UsdGeom.TetMesh.ComputeSurfaceFaces` require a + right-handed tet winding (positive signed volume). Swapping indices 2 and 3 + reverses the orientation without changing which four vertices form the tet. + """ + i = wp.tid() + v0 = tet_indices[i, 0] + v1 = tet_indices[i, 1] + v2 = tet_indices[i, 2] + v3 = tet_indices[i, 3] + p0 = points[v0] + e1 = points[v1] - p0 + e2 = points[v2] - p0 + e3 = points[v3] - p0 + signed_volume = wp.dot(e1, wp.cross(e2, e3)) + if signed_volume < 0.0: + tet_indices[i, 2] = v3 + tet_indices[i, 3] = v2 + + +def define_deformable_body_properties( + prim_path: str, + cfg: schemas_cfg.DeformableBodyPropertiesBaseCfg, + stage: Usd.Stage | None = None, + deformable_type: str = "volume", + sim_mesh_prim_path: str | None = None, +): + """Apply the deformable body schema on the input prim and set its properties. The input prim should + have a visual surface mesh as child. Volume deformables will have their simulation tetrahedral mesh + automatically computed from the surface mesh of the input prim. Surface deformables simply copy the visual mesh + as simulation mesh. + + See :func:`modify_deformable_body_properties` for more details on how the properties are set. + + .. note:: + If the input prim is not a mesh, this function will traverse the prim and find the first mesh + under it. If no mesh or multiple meshes are found, an error is raised. This is because the deformable + body schema can only be applied to a single mesh. + + Args: + prim_path: The prim path where to apply the deformable body schema. + cfg: The configuration for the deformable body. + stage: The stage where to find the prim. Defaults to None, in which case the + current stage is used. + deformable_type: The type of the deformable body (surface or volume). + This is used to determine which USD API to use for the deformable body. Defaults to "volume". + sim_mesh_prim_path: Optional override for the simulation mesh creation prim path. + Ignored when pre-tetrahedralized mesh is found for volume deformables. + If None, it is set to ``{prim_path}/sim_mesh``. + + Raises: + ValueError: When the prim path is not valid. + ValueError: When the prim has no mesh or multiple meshes. + RuntimeError: When setting the deformable body properties fails. + """ + from omni.physx.scripts import deformableUtils + + # get stage handle + if stage is None: + stage = get_current_stage() + + # get USD prim + root_prim = stage.GetPrimAtPath(prim_path) + # check if prim path is valid + if not root_prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + + sim_mesh_prim = None + # for volume deformables, we check if a pre-tetrahedralized TetMesh exists for the sim_mesh + if deformable_type == "volume": + matching_prims = get_all_matching_child_prims(prim_path, lambda p: p.GetTypeName() == "TetMesh") + if len(matching_prims) == 0: + sim_mesh_prim = None + elif len(matching_prims) > 1: + # get list of all meshes found + mesh_paths = [p.GetPrimPath() for p in matching_prims] + raise ValueError( + f"Found multiple tetrahedral meshes in '{prim_path}': {mesh_paths}." + " Deformable body schema can only be applied to one mesh for now." + ) + else: + # found existing tetmesh + sim_mesh_prim = matching_prims[0] + if not sim_mesh_prim.IsValid(): + raise ValueError(f"Mesh prim path '{sim_mesh_prim.GetPrimPath()}' is not valid.") + + # Search for a visual surface mesh for both surface and volume deformables + matching_prims = get_all_matching_child_prims(prim_path, lambda p: p.GetTypeName() == "Mesh") + # check if the visual surface mesh is valid + if len(matching_prims) == 0: + # in case a TetMesh is found but no Mesh is found, we use the TetMesh surface as visual. + if sim_mesh_prim is not None: + tet_mesh_prim = UsdGeom.TetMesh(sim_mesh_prim) + surface_indices = UsdGeom.TetMesh.ComputeSurfaceFaces(tet_mesh_prim, Usd.TimeCode.Default()) + if surface_indices is None or len(surface_indices) == 0: + raise ValueError( + f"Deformable body at '{prim_path}' has no surface indices on its TetMesh prim; " + "cannot sync to visual mesh." + ) + # create visual mesh + vis_mesh_prim = create_prim( + prim_path + "/vis_mesh", + prim_type="Mesh", + attributes={ + "points": tet_mesh_prim.GetPointsAttr().Get(), + "faceVertexIndices": np.asarray(surface_indices).flatten(), + "faceVertexCounts": [3] * len(surface_indices), + }, + stage=stage, + ) + matching_prims = [vis_mesh_prim] + else: + raise ValueError(f"Could not find any visual mesh in '{prim_path}'. Please check asset.") + if len(matching_prims) > 1: + # get list of all meshes found + mesh_paths = [p.GetPrimPath() for p in matching_prims] + raise ValueError( + f"Found multiple visual meshes in '{prim_path}': {mesh_paths}." + " Deformable body schema can only be applied to one mesh for now." + ) + vis_mesh_prim = matching_prims[0] + + # check if the prim is valid + if not vis_mesh_prim.IsValid(): + raise ValueError(f"Mesh prim path '{vis_mesh_prim.GetPrimPath()}' is not valid.") + + # remove potential previous configuration + deformableUtils.remove_deformable_body(stage, prim_path) + + # create and set simulation/root prim properties based on the type of the deformable mesh (surface vs volume) + sim_mesh_prim_path = prim_path + "/sim_mesh" if sim_mesh_prim_path is None else sim_mesh_prim_path + # extract visual surface mesh vertices and faces + vertices = np.array(vis_mesh_prim.GetAttribute("points").Get()) + faces = np.array(vis_mesh_prim.GetAttribute("faceVertexIndices").Get()).flatten() + face_counts = np.array(vis_mesh_prim.GetAttribute("faceVertexCounts").Get()) + if deformable_type == "surface": + # create simulation mesh as copy of visual mesh + sim_mesh_prim = create_prim( + sim_mesh_prim_path, + prim_type="Mesh", + attributes={ + "points": vertices, + "faceVertexIndices": faces, + "faceVertexCounts": face_counts, + }, + stage=stage, + ) + + # apply sim API + if not sim_mesh_prim.ApplyAPI("OmniPhysicsSurfaceDeformableSimAPI"): + raise RuntimeError(f"Failed to set surface deformable body API on prim '{sim_mesh_prim_path}'.") + + # apply collision API + if not sim_mesh_prim.ApplyAPI(UsdPhysics.CollisionAPI): + raise RuntimeError(f"Failed to set surface deformable collision API on prim '{sim_mesh_prim_path}'.") + + # set rest-shape attributes required by OmniPhysicsSurfaceDeformableSimAPI + sim_mesh_prim.GetAttribute("omniphysics:restShapePoints").Set(vertices) + sim_mesh_prim.GetAttribute("omniphysics:restTriVtxIndices").Set(faces) + + elif deformable_type == "volume": + if sim_mesh_prim is None: + try: + from pytetwild import tetrahedralize + except ImportError as exc: + raise ImportError( + "Automatic tetrahedralization of volume deformables requires the optional 'pytetwild' " + "package. Install pytetwild or provide a pre-tetrahedralized UsdGeom.TetMesh under the " + f"deformable prim '{prim_path}'." + ) from exc + + tet_mesh_points, tet_mesh_indices = tetrahedralize( + vertices, + faces.reshape(-1, 3), + edge_length_fac=0.1, + simplify=False, + epsilon=1e-2, + coarsen=True, + ) + # pytetwild's default ordering does not guarantee positive signed volume, which + # ``UsdGeom.TetMesh`` and ``ComputeSurfaceFaces`` require. Flip any inverted tets. + device = "cpu" + _tet_points_wp = wp.array(tet_mesh_points.astype(np.float32), dtype=wp.vec3, device=device) + _tet_indices_wp = wp.array( + np.asarray(tet_mesh_indices, dtype=np.int32).reshape(-1, 4), dtype=wp.int32, device=device + ) + wp.launch( + _fix_tet_winding_kernel, + dim=_tet_indices_wp.shape[0], + inputs=[_tet_points_wp, _tet_indices_wp], + device=device, + ) + tet_mesh_indices = _tet_indices_wp.numpy() + sim_mesh_prim = create_prim( + sim_mesh_prim_path, + prim_type="TetMesh", + attributes={ + "points": tet_mesh_points, + "tetVertexIndices": tet_mesh_indices, + }, + stage=stage, + ) + + # apply sim API + if not sim_mesh_prim.ApplyAPI("OmniPhysicsVolumeDeformableSimAPI"): + raise RuntimeError(f"Failed to set volume deformable body API on prim '{sim_mesh_prim_path}'.") + + # apply collision API + if not sim_mesh_prim.ApplyAPI(UsdPhysics.CollisionAPI): + raise RuntimeError(f"Failed to set volume deformable collision API on prim '{sim_mesh_prim_path}'.") + + # set surface faces and rest-shape attributes required by OmniPhysicsVolumeDeformableSimAPI + surface_face_indices = UsdGeom.TetMesh.ComputeSurfaceFaces( + UsdGeom.TetMesh(sim_mesh_prim), Usd.TimeCode.Default() + ) + UsdGeom.TetMesh(sim_mesh_prim).GetSurfaceFaceVertexIndicesAttr().Set(surface_face_indices) + sim_mesh_prim.GetAttribute("omniphysics:restShapePoints").Set(sim_mesh_prim.GetAttribute("points").Get()) + sim_mesh_prim.GetAttribute("omniphysics:restTetVtxIndices").Set( + sim_mesh_prim.GetAttribute("tetVertexIndices").Get() + ) + + else: + raise ValueError( + f"""Unsupported deformable type: '{deformable_type}'. + Only surface and volume deformables are supported.""" + ) + + # TODO: Temporary solution: Overwrite visual mesh with tet mesh surface points or copy + # surface sim mesh to vis mesh. In the future we can have separate visual from simulation mesh. + # This currently does not work if an asset is loaded where the visual mesh is not the simulation mesh surface. + vis_mesh = UsdGeom.Mesh(vis_mesh_prim) + if deformable_type == "volume": + tet_mesh_prim = UsdGeom.TetMesh(sim_mesh_prim) + surface_indices = tet_mesh_prim.GetSurfaceFaceVertexIndicesAttr().Get() + if surface_indices is None or len(surface_indices) == 0: + raise ValueError( + f"Deformable body at '{prim_path}' has no surface indices on its TetMesh prim; " + "cannot sync to visual mesh." + ) + vis_mesh.GetPointsAttr().Set(tet_mesh_prim.GetPointsAttr().Get()) + vis_mesh.GetFaceVertexIndicesAttr().Set(np.asarray(surface_indices).flatten()) + vis_mesh.GetFaceVertexCountsAttr().Set([3] * len(surface_indices)) + else: + sim_mesh = UsdGeom.Mesh(sim_mesh_prim) + vis_mesh.GetFaceVertexIndicesAttr().Set(sim_mesh.GetFaceVertexIndicesAttr().Get()) + vis_mesh.GetFaceVertexCountsAttr().Set(sim_mesh.GetFaceVertexCountsAttr().Get()) + + # bind visual to sim mesh by applying bind pose deformable pose API + purposes = ["bindPose"] + vis_mesh_prim.ApplyAPI("OmniPhysicsDeformablePoseAPI", "default") + vis_mesh_prim.CreateAttribute("deformablePose:default:omniphysics:purposes", Sdf.ValueTypeNames.TokenArray).Set( + purposes + ) + points = UsdGeom.PointBased(vis_mesh_prim).GetPointsAttr().Get() + vis_mesh_prim.CreateAttribute("deformablePose:default:omniphysics:points", Sdf.ValueTypeNames.Point3fArray).Set( + points + ) + + sim_mesh_prim.ApplyAPI("OmniPhysicsDeformablePoseAPI", "default") + sim_mesh_prim.CreateAttribute("deformablePose:default:omniphysics:purposes", Sdf.ValueTypeNames.TokenArray).Set( + purposes + ) + + # disable simulation mesh for rendering + UsdGeom.Imageable(sim_mesh_prim).GetPurposeAttr().Set(UsdGeom.Tokens.guide) + + # apply deformable body api + if not root_prim.ApplyAPI("OmniPhysicsDeformableBodyAPI"): + raise RuntimeError(f"Failed to set deformable body API on prim '{prim_path}'.") + + # set deformable body properties + modify_deformable_body_properties(prim_path, cfg, stage) + + +@apply_nested +def modify_deformable_body_properties( + prim_path: str, cfg: schemas_cfg.DeformableBodyPropertiesBaseCfg, stage: Usd.Stage | None = None +): + """Modify deformable body parameters for a deformable body prim. + + A `deformable body`_ is a single body (either surface or volume deformable) that can be simulated by PhysX + or Newton. Unlike rigid bodies, deformable bodies support relative motion of the nodes in the mesh. + Consequently, they can be used to simulate deformations under applied forces. + + PhysX deformable body simulation employs Finite Element Analysis (FEA) to simulate the deformations of the mesh. + It uses two meshes to represent the deformable body: + + 1. **Simulation mesh**: This mesh is used for the simulation and is the one that is deformed by the solver. + 2. **Collision mesh**: This mesh only needs to match the surface of the simulation mesh and is used for + collision detection. + + For most applications, we assume that the above two meshes are computed from the "render mesh" of the deformable + body. The render mesh is the mesh that is visible in the scene and is used for rendering purposes. It is composed + of triangles, while the simulation mesh is composed of tetrahedrons for volume deformables, + and triangles for surface deformables. + + We apply similar design choices to the simulation in Newton with a separate visual, simulation and collision mesh. + + .. caution:: + The deformable body schema is still under development by the Omniverse team. The current implementation + works with the PhysX schemas shipped with Isaac Sim 6.0.0 onwards. It may change in future releases. + + .. note:: + This function is decorated with :func:`apply_nested` that sets the properties to all the prims + (that have the schema applied on them) under the input prim path. + + .. _deformable body: https://nvidia-omniverse.github.io/PhysX/physx/5.6.1/docs/DeformableVolume.html + .. _PhysxDeformableBodyAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/physxschema/annotated.html + + Args: + prim_path: The prim path to the deformable body. + cfg: The configuration for the deformable body. + stage: The stage where to find the prim. Defaults to None, in which case the + current stage is used. + + Returns: + True if the properties were successfully set, False otherwise. + """ + # get stage handle + if stage is None: + stage = get_current_stage() + + # get deformable-body USD prim + deformable_body_prim = stage.GetPrimAtPath(prim_path) + # check if the prim is valid + if not deformable_body_prim.IsValid(): + return False + # check if deformable body API is applied + if "OmniPhysicsDeformableBodyAPI" not in deformable_body_prim.GetAppliedSchemas(): + return False + + # build cfg dict from dataclass fields only; USD routing is driven by the + # declaring classes' ``_usd_namespace`` / ``_usd_applied_schema`` metadata. + cfg_dict = {f.name: getattr(cfg, f.name) for f in dataclasses.fields(cfg)} + + if cfg_dict.get("kinematic_enabled"): + logger.warning( + "Kinematic deformable bodies are not fully supported in the current version of Omni Physics. " + "Setting kinematic_enabled to True may lead to unexpected behavior." + ) + + _apply_namespaced_schemas(deformable_body_prim, cfg, cfg_dict) + # success + return True diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index b154d91f4cc5..d6dc99a84748 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -22,8 +22,9 @@ "PhysxJointDrivePropertiesCfg", "CollisionPropertiesCfg", "PhysxCollisionPropertiesCfg", - "PhysXCollisionPropertiesCfg", + "DeformableBodyPropertiesCfg", "PhysxDeformableCollisionPropertiesCfg", + "PhysxDeformableBodyPropertiesCfg", "ArticulationRootPropertiesCfg", "PhysxArticulationRootPropertiesCfg", "MeshCollisionPropertiesCfg", @@ -543,3 +544,14 @@ class BoundingSpherePropertiesCfg(MeshCollisionBaseCfg): mesh_approximation_name: str = "boundingSphere" """Name of mesh collision approximation method. Default: "boundingSphere".""" + + +@configclass +class DeformableBodyPropertiesBaseCfg: + """Base deformable body properties for backend-specific extensions. + + This class is currently empty. It will be populated once the USD deformable + schemas can be unified more cleanly between physics backends. + """ + + pass diff --git a/source/isaaclab/isaaclab/sim/spawners/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/__init__.pyi index dae1a432b47e..c936d166ba3c 100644 --- a/source/isaaclab/isaaclab/sim/spawners/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/__init__.pyi @@ -6,6 +6,7 @@ __all__ = [ "SpawnerCfg", "RigidObjectSpawnerCfg", + "DeformableObjectSpawnerCfg", "spawn_from_mjcf", "spawn_from_urdf", "spawn_from_usd", @@ -24,8 +25,13 @@ __all__ = [ "LightCfg", "SphereLightCfg", "spawn_rigid_body_material", + "spawn_deformable_body_material", "PhysicsMaterialCfg", "RigidBodyMaterialCfg", + "DeformableBodyMaterialBaseCfg", + "DeformableBodyMaterialCfg", + "SurfaceDeformableBodyMaterialBaseCfg", + "SurfaceDeformableBodyMaterialCfg", "spawn_from_mdl_file", "spawn_preview_surface", "GlassMdlCfg", @@ -36,15 +42,15 @@ __all__ = [ "spawn_mesh_cone", "spawn_mesh_cuboid", "spawn_mesh_cylinder", + "spawn_mesh_rectangle", "spawn_mesh_sphere", - "spawn_mesh_square", "MeshCapsuleCfg", "MeshCfg", "MeshConeCfg", "MeshCuboidCfg", "MeshCylinderCfg", + "MeshRectangleCfg", "MeshSphereCfg", - "MeshSquareCfg", "spawn_camera", "spawn_sensor_frame", "FisheyeCameraCfg", @@ -67,7 +73,7 @@ __all__ = [ "MultiUsdFileCfg", ] -from .spawner_cfg import SpawnerCfg, RigidObjectSpawnerCfg +from .spawner_cfg import SpawnerCfg, RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg from .from_files import ( spawn_from_mjcf, spawn_from_urdf, @@ -91,8 +97,13 @@ from .lights import ( ) from .materials import ( spawn_rigid_body_material, + spawn_deformable_body_material, PhysicsMaterialCfg, RigidBodyMaterialCfg, + DeformableBodyMaterialBaseCfg, + DeformableBodyMaterialCfg, + SurfaceDeformableBodyMaterialBaseCfg, + SurfaceDeformableBodyMaterialCfg, spawn_from_mdl_file, spawn_preview_surface, GlassMdlCfg, @@ -105,14 +116,14 @@ from .meshes import ( spawn_mesh_cone, spawn_mesh_cuboid, spawn_mesh_cylinder, + spawn_mesh_rectangle, spawn_mesh_sphere, - spawn_mesh_square, MeshCapsuleCfg, MeshCfg, MeshConeCfg, MeshCuboidCfg, MeshCylinderCfg, - MeshSquareCfg, + MeshRectangleCfg, MeshSphereCfg, ) from .sensors import spawn_camera, spawn_sensor_frame, FisheyeCameraCfg, PinholeCameraCfg, SensorFrameCfg diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index 18ffcaf37c98..9367ef3cc801 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -13,14 +13,10 @@ from filelock import FileLock -# deformables only supported on PhysX backend -from isaaclab_physx.sim import schemas as schemas_physx -from isaaclab_physx.sim.spawners.materials import SurfaceDeformableBodyMaterialCfg - from pxr import Gf, Sdf, Usd, UsdGeom from isaaclab.sim import converters, schemas -from isaaclab.sim.spawners.materials import RigidBodyMaterialCfg +from isaaclab.sim.spawners.materials import RigidBodyMaterialCfg, SurfaceDeformableBodyMaterialBaseCfg from isaaclab.sim.utils import ( add_labels, bind_physics_material, @@ -387,15 +383,17 @@ def _spawn_from_usd_file( # define deformable body properties, or modify if deformable body API is present (PhysX only) if cfg.deformable_props is not None: prim = stage.GetPrimAtPath(prim_path) - deformable_type = "surface" if isinstance(cfg.physics_material, SurfaceDeformableBodyMaterialCfg) else "volume" + deformable_type = ( + "surface" if isinstance(cfg.physics_material, SurfaceDeformableBodyMaterialBaseCfg) else "volume" + ) if "OmniPhysicsDeformableBodyAPI" in prim.GetAppliedSchemas(): - schemas_physx.modify_deformable_body_properties(prim_path, cfg.deformable_props, stage) + schemas.modify_deformable_body_properties(prim_path, cfg.deformable_props, stage) else: - schemas_physx.define_deformable_body_properties(prim_path, cfg.deformable_props, stage, deformable_type) + schemas.define_deformable_body_properties(prim_path, cfg.deformable_props, stage, deformable_type) if cfg.mass_props is not None: raise ValueError( """MassPropertiesCfg are not supported for deformable bodies - and should be set through DeformableBodyPropertiesCfg(mass=).""" + and should be set through deformable_props with mass=.""" ) # apply visual material diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py index 1d61cde5725d..22857f8d45b1 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py @@ -8,12 +8,9 @@ from collections.abc import Callable from dataclasses import MISSING -# deformables only supported on PhysX backend -from isaaclab_physx.sim.spawners.spawner_cfg import DeformableObjectSpawnerCfg - from isaaclab.sim import converters, schemas from isaaclab.sim.spawners import materials -from isaaclab.sim.spawners.spawner_cfg import RigidObjectSpawnerCfg, SpawnerCfg +from isaaclab.sim.spawners.spawner_cfg import DeformableObjectSpawnerCfg, RigidObjectSpawnerCfg, SpawnerCfg from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR from isaaclab.utils.configclass import configclass diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py index 3158625219c5..0df8e552ea2a 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.py @@ -60,8 +60,12 @@ # Resolved lazily on first access so importing ``isaaclab.sim.spawners.materials`` does # not require ``isaaclab_physx`` to be installed. _PHYSX_FORWARDS = frozenset({ + "DeformableBodyMaterialCfg", "RigidBodyMaterialCfg", + "SurfaceDeformableBodyMaterialCfg", "PhysxRigidBodyMaterialCfg", + "PhysxDeformableBodyMaterialCfg", + "PhysxSurfaceDeformableBodyMaterialCfg", }) diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi index 0dd023c2998f..7ce7815955c2 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi @@ -5,8 +5,13 @@ __all__ = [ "spawn_rigid_body_material", + "spawn_deformable_body_material", "PhysicsMaterialCfg", "RigidBodyMaterialBaseCfg", + "DeformableBodyMaterialBaseCfg", + "DeformableBodyMaterialCfg", + "SurfaceDeformableBodyMaterialBaseCfg", + "SurfaceDeformableBodyMaterialCfg", "spawn_from_mdl_file", "spawn_preview_surface", "GlassMdlCfg", @@ -15,10 +20,14 @@ __all__ = [ "VisualMaterialCfg", ] -from .physics_materials import spawn_rigid_body_material +from .physics_materials import spawn_rigid_body_material, spawn_deformable_body_material from .physics_materials_cfg import ( PhysicsMaterialCfg, RigidBodyMaterialBaseCfg, + DeformableBodyMaterialBaseCfg, + DeformableBodyMaterialCfg, + SurfaceDeformableBodyMaterialBaseCfg, + SurfaceDeformableBodyMaterialCfg, ) from .visual_materials import spawn_from_mdl_file, spawn_preview_surface from .visual_materials_cfg import GlassMdlCfg, MdlFileCfg, PreviewSurfaceCfg, VisualMaterialCfg diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py index b76077b38bed..8c12bee9442d 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py @@ -6,7 +6,6 @@ from __future__ import annotations import dataclasses -from typing import TYPE_CHECKING from pxr import Usd, UsdPhysics, UsdShade @@ -14,8 +13,7 @@ from isaaclab.sim.utils import clone from isaaclab.sim.utils.stage import get_current_stage -if TYPE_CHECKING: - from . import physics_materials_cfg +from . import physics_materials_cfg @clone @@ -76,3 +74,49 @@ def spawn_rigid_body_material(prim_path: str, cfg: physics_materials_cfg.RigidBo # return the prim return prim + + +@clone +def spawn_deformable_body_material( + prim_path: str, cfg: physics_materials_cfg.DeformableBodyMaterialBaseCfg +) -> Usd.Prim: + """Create material with deformable-body physics properties. + + Deformable body materials are used to define the physical properties to meshes of a deformable body. These + include the friction and deformable body properties. For more information on deformable body material, + please refer to the documentation on `PxFEMSoftBodyMaterial`_. + + .. 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. This is done to support spawning multiple assets + from a single and cloning the USD prim at the given path expression. + + Args: + prim_path: The prim path or pattern to spawn the asset at. If the prim path is a regex pattern, + then the asset is spawned at all the matching prim paths. + cfg: The configuration for the physics material. + + Returns: + The spawned deformable body material prim. + + Raises: + ValueError: When a prim already exists at the specified prim path and is not a material. + + .. _PxFEMSoftBodyMaterial: https://nvidia-omniverse.github.io/PhysX/physx/5.4.1/_api_build/structPxFEMSoftBodyMaterialModel.html + """ + # get stage handle + stage = get_current_stage() + + # create material prim if no prim exists + if not stage.GetPrimAtPath(prim_path).IsValid(): + _ = UsdShade.Material.Define(stage, prim_path) + + # obtain prim + prim = stage.GetPrimAtPath(prim_path) + # check if prim is a material + if not prim.IsA(UsdShade.Material): + raise ValueError(f"A prim already exists at path: '{prim_path}' but is not a material.") + cfg_dict = {f.name: getattr(cfg, f.name) for f in dataclasses.fields(cfg) if f.name != "func"} + _apply_namespaced_schemas(prim, cfg, cfg_dict) + # return the prim + return prim diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py index 886b049b77b0..5c88731cf8d5 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py @@ -15,7 +15,16 @@ # Resolved lazily so callers using ``from isaaclab.sim.spawners.materials.physics_materials_cfg # import RigidBodyMaterialCfg`` continue to work without importing ``isaaclab_physx`` at module # load time. -_PHYSX_FORWARDS = frozenset({"RigidBodyMaterialCfg", "PhysxRigidBodyMaterialCfg"}) +_PHYSX_FORWARDS = frozenset( + { + "DeformableBodyMaterialCfg", + "RigidBodyMaterialCfg", + "SurfaceDeformableBodyMaterialCfg", + "PhysxRigidBodyMaterialCfg", + "PhysxDeformableBodyMaterialCfg", + "PhysxSurfaceDeformableBodyMaterialCfg", + } +) def __getattr__(name): @@ -78,3 +87,19 @@ class RigidBodyMaterialBaseCfg(PhysicsMaterialCfg): restitution: float = 0.0 """The restitution coefficient. Defaults to 0.0.""" + + +@configclass +class DeformableBodyMaterialBaseCfg(PhysicsMaterialCfg): + """Base physics material parameters for volume deformable bodies. + + Backend-specific subclasses provide the material fields and spawning function + through :attr:`func`. + """ + + func: Callable | str | None = None + + +@configclass +class SurfaceDeformableBodyMaterialBaseCfg(DeformableBodyMaterialBaseCfg): + """Base physics material parameters for surface deformable bodies.""" diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi index 06490befb33f..c853dca4d1d0 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi @@ -8,15 +8,15 @@ __all__ = [ "spawn_mesh_cone", "spawn_mesh_cuboid", "spawn_mesh_cylinder", + "spawn_mesh_rectangle", "spawn_mesh_sphere", - "spawn_mesh_square", "MeshCapsuleCfg", "MeshCfg", "MeshConeCfg", "MeshCuboidCfg", "MeshCylinderCfg", + "MeshRectangleCfg", "MeshSphereCfg", - "MeshSquareCfg", ] from .meshes import ( @@ -24,8 +24,8 @@ from .meshes import ( spawn_mesh_cone, spawn_mesh_cuboid, spawn_mesh_cylinder, + spawn_mesh_rectangle, spawn_mesh_sphere, - spawn_mesh_square, ) from .meshes_cfg import ( MeshCapsuleCfg, @@ -33,6 +33,6 @@ from .meshes_cfg import ( MeshConeCfg, MeshCuboidCfg, MeshCylinderCfg, + MeshRectangleCfg, MeshSphereCfg, - MeshSquareCfg, ) diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index 75ead1c8fb67..cfc7f51b9ff2 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -11,16 +11,12 @@ import trimesh import trimesh.transformations -# deformables only supported on PhysX backend -from isaaclab_physx.sim import schemas as schemas_physx -from isaaclab_physx.sim.spawners.materials import DeformableBodyMaterialCfg, SurfaceDeformableBodyMaterialCfg - from pxr import Usd, UsdPhysics from isaaclab.sim import schemas from isaaclab.sim.utils import bind_physics_material, bind_visual_material, clone, create_prim, get_current_stage -from ..materials import RigidBodyMaterialCfg +from ..materials import DeformableBodyMaterialBaseCfg, RigidBodyMaterialCfg, SurfaceDeformableBodyMaterialBaseCfg if TYPE_CHECKING: from . import meshes_cfg @@ -261,14 +257,14 @@ def spawn_mesh_cone( @clone -def spawn_mesh_square( +def spawn_mesh_rectangle( prim_path: str, - cfg: meshes_cfg.MeshSquareCfg, + cfg: meshes_cfg.MeshRectangleCfg, translation: tuple[float, float, float] | None = None, orientation: tuple[float, float, float, float] | None = None, **kwargs, ) -> Usd.Prim: - """Create a USD-Mesh 2D square prim with the given attributes. + """Create a USD-Mesh 2D rectangle prim with the given attributes. .. note:: This function is decorated with :func:`clone` that resolves prim path into list of paths @@ -294,12 +290,13 @@ def spawn_mesh_square( # create a 2D triangle mesh grid from omni.physx.scripts import deformableUtils - vertices, faces = deformableUtils.create_triangle_mesh_square(cfg.resolution[0], cfg.resolution[1], scale=cfg.size) + vertices, faces = deformableUtils.create_triangle_mesh_square(cfg.resolution[0], cfg.resolution[1], scale=1.0) + vertices = np.array([(v[0] * cfg.size[0], v[1] * cfg.size[1], v[2]) for v in vertices], dtype=np.float32) grid = trimesh.Trimesh(vertices=vertices, faces=np.array(faces).reshape(-1, 3), process=False) # obtain stage handle stage = get_current_stage() - # spawn the square as a mesh + # spawn the rectangle as a mesh _spawn_mesh_geom_from_mesh(prim_path, cfg, grid, translation, orientation, None, stage=stage) # return the prim return stage.GetPrimAtPath(prim_path) @@ -367,7 +364,7 @@ def _spawn_mesh_geom_from_mesh( raise ValueError("Cannot use both deformable and collision properties at the same time.") # check material types are correct if cfg.deformable_props is not None and cfg.physics_material is not None: - if not isinstance(cfg.physics_material, DeformableBodyMaterialCfg): + if not isinstance(cfg.physics_material, DeformableBodyMaterialBaseCfg): raise ValueError("Deformable properties require a deformable physics material.") if cfg.rigid_props is not None and cfg.physics_material is not None: if not isinstance(cfg.physics_material, RigidBodyMaterialCfg): @@ -393,14 +390,16 @@ def _spawn_mesh_geom_from_mesh( if cfg.deformable_props is not None: # apply deformable body properties - deformable_type = "surface" if isinstance(cfg.physics_material, SurfaceDeformableBodyMaterialCfg) else "volume" - schemas_physx.define_deformable_body_properties( + deformable_type = ( + "surface" if isinstance(cfg.physics_material, SurfaceDeformableBodyMaterialBaseCfg) else "volume" + ) + schemas.define_deformable_body_properties( prim_path, cfg.deformable_props, stage=stage, deformable_type=deformable_type ) if cfg.mass_props is not None: raise ValueError( """MassPropertiesCfg are not supported for deformable bodies - and should be set through DeformableBodyPropertiesCfg(mass=).""" + and should be set through deformable_props with mass=.""" ) elif cfg.collision_props is not None: # decide on type of collision approximation based on the mesh diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py index 6bdf9ebf9baa..c6eb26507d3a 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py @@ -9,11 +9,8 @@ from dataclasses import MISSING from typing import Literal -# deformables only supported on PhysX backend -from isaaclab_physx.sim.spawners.spawner_cfg import DeformableObjectSpawnerCfg - from isaaclab.sim.spawners import materials -from isaaclab.sim.spawners.spawner_cfg import RigidObjectSpawnerCfg +from isaaclab.sim.spawners.spawner_cfg import DeformableObjectSpawnerCfg, RigidObjectSpawnerCfg from isaaclab.utils.configclass import configclass @@ -146,15 +143,15 @@ class MeshConeCfg(MeshCfg): @configclass -class MeshSquareCfg(MeshCfg): - """Configuration parameters for a 2D square mesh prim. +class MeshRectangleCfg(MeshCfg): + """Configuration parameters for a 2D rectangle mesh prim. - See :meth:`spawn_mesh_square` for more information. + See :meth:`spawn_mesh_rectangle` for more information. """ - func: Callable | str = "{DIR}.meshes:spawn_mesh_square" + func: Callable | str = "{DIR}.meshes:spawn_mesh_rectangle" - size: float = MISSING - """Edge length of the square (in m).""" + size: tuple[float, float] = MISSING + """Edge lengths of the rectangle along the X and Y axes [m].""" resolution: tuple[int, int] = (5, 5) - """Resolution of the square (in elements/edges per side).""" + """Resolution of the rectangle (in elements/edges per side).""" diff --git a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py index 3cc632e2c0d5..3f1eef72a2fa 100644 --- a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py @@ -100,3 +100,25 @@ class RigidObjectSpawnerCfg(SpawnerCfg): This adds the PhysxContactReporter API to all the rigid bodies in the given prim path and its children. """ + + +@configclass +class DeformableObjectSpawnerCfg(SpawnerCfg): + """Configuration parameters for spawning a deformable asset. + + Unlike rigid objects, deformable objects are affected by forces and can deform when subjected to + external forces. This class is used to configure the properties of the deformable object. + + Deformable bodies don't have a separate collision mesh. The collision mesh is the same as the visual mesh. + The collision properties such as rest and collision offsets are specified in the :attr:`deformable_props`. + + Note: + By default, all properties are set to None. This means that no properties will be added or modified + to the prim outside of the properties available by default when spawning the prim. + """ + + mass_props: schemas.MassPropertiesCfg | None = None + """Mass properties.""" + + deformable_props: schemas.DeformableBodyPropertiesBaseCfg | None = None + """Deformable body properties.""" diff --git a/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py b/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py index dafe5beb812f..285c1765016b 100644 --- a/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers_cfg.py @@ -3,27 +3,10 @@ # # SPDX-License-Identifier: BSD-3-Clause -import warnings from dataclasses import MISSING -# deformables only supported in PhysX backend -try: - from isaaclab_physx.sim.spawners.spawner_cfg import DeformableObjectSpawnerCfg -except ImportError as e: - warnings.warn( - f"""Could not import DeformableObjectSpawnerCfg, is isaaclab_physx installed? - Safe to ignore if using newton only. Complete exception: {e}""" - ) - # import dummy class to avoid errors in type hints - from isaaclab.utils.configclass import configclass - - @configclass - class DeformableObjectSpawnerCfg: - deformable_props = None - - from isaaclab.sim.spawners.from_files import UsdFileCfg -from isaaclab.sim.spawners.spawner_cfg import RigidObjectSpawnerCfg, SpawnerCfg +from isaaclab.sim.spawners.spawner_cfg import DeformableObjectSpawnerCfg, RigidObjectSpawnerCfg, SpawnerCfg from isaaclab.utils.configclass import configclass diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index bc64609de3fd..57f732d4c589 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -30,6 +30,8 @@ # procedural-generation "trimesh", "pyglet>=2.1.6,<3", + # tetrahedralization for deformable bodies (pinned: >=0.3 unconditionally imports pyvista at package import time) + "pytetwild==0.2.3", # image processing "transformers==4.57.6", "einops", # needed for transformers, doesn't always auto-install diff --git a/source/isaaclab/test/sim/test_deformable_backend_split.py b/source/isaaclab/test/sim/test_deformable_backend_split.py new file mode 100644 index 000000000000..dc170fe55c5a --- /dev/null +++ b/source/isaaclab/test/sim/test_deformable_backend_split.py @@ -0,0 +1,131 @@ +# 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 + +"""Tests for the backend split of deformable schemas and materials.""" + +import dataclasses + +import isaaclab_physx.sim.schemas as physx_schemas +from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg +from isaaclab_newton.sim.spawners.materials import ( + NewtonDeformableBodyMaterialCfg, + NewtonDeformableMaterialCfg, + NewtonSurfaceDeformableBodyMaterialCfg, +) +from isaaclab_physx.sim.schemas import ( + OmniPhysicsDeformableBodyPropertiesCfg, + PhysxDeformableBodyPropertiesCfg, + PhysxDeformableCollisionPropertiesCfg, +) +from isaaclab_physx.sim.schemas.schemas_cfg import PhysXDeformableBodyPropertiesCfg +from isaaclab_physx.sim.spawners.materials import ( + PhysxDeformableBodyMaterialCfg, + PhysXDeformableMaterialCfg, + PhysxSurfaceDeformableBodyMaterialCfg, +) + +import isaaclab.sim.schemas as schemas +import isaaclab.sim.spawners.materials.physics_materials_cfg as core_materials_cfg +from isaaclab.sim.schemas import DeformableBodyPropertiesBaseCfg +from isaaclab.sim.spawners.materials import ( + DeformableBodyMaterialBaseCfg, + SurfaceDeformableBodyMaterialBaseCfg, +) + + +def _field_names(cls) -> set[str]: + return {field.name for field in dataclasses.fields(cls)} + + +def _assert_no_property_prefix_field(cls): + assert "_property_prefix" not in _field_names(cls) + + +def test_common_deformable_property_cfg_has_no_backend_fields(): + """Common deformable properties are empty backend extension points.""" + fields = _field_names(DeformableBodyPropertiesBaseCfg) + + assert fields == set() + _assert_no_property_prefix_field(DeformableBodyPropertiesBaseCfg) + assert not hasattr(DeformableBodyPropertiesBaseCfg, "_usd_namespace") + assert not hasattr(DeformableBodyPropertiesBaseCfg, "_usd_applied_schema") + + +def test_common_deformable_material_cfg_has_no_backend_fields(): + """Common deformable material bases are empty backend extension points.""" + fields = _field_names(DeformableBodyMaterialBaseCfg) + surface_fields = _field_names(SurfaceDeformableBodyMaterialBaseCfg) + + assert fields == {"func"} + assert surface_fields == {"func"} + assert "DeformableBodyMaterialCfg" not in core_materials_cfg.__dict__ + assert "SurfaceDeformableBodyMaterialCfg" not in core_materials_cfg.__dict__ + assert not hasattr(core_materials_cfg, "PhysXDeformableMaterialCfg") + assert not hasattr(core_materials_cfg, "NewtonDeformableMaterialCfg") + assert not hasattr(DeformableBodyMaterialBaseCfg, "_usd_namespace") + assert not hasattr(DeformableBodyMaterialBaseCfg, "_usd_applied_schema") + assert not hasattr(SurfaceDeformableBodyMaterialBaseCfg, "_usd_namespace") + assert not hasattr(SurfaceDeformableBodyMaterialBaseCfg, "_usd_applied_schema") + _assert_no_property_prefix_field(DeformableBodyMaterialBaseCfg) + _assert_no_property_prefix_field(SurfaceDeformableBodyMaterialBaseCfg) + + +def test_physx_deformable_cfgs_use_core_schema_and_material_functions(): + """PhysX deformable cfgs own PhysX fields while schema and material functions stay in core.""" + props = PhysxDeformableBodyPropertiesCfg() + material = PhysxDeformableBodyMaterialCfg() + surface_material = PhysxSurfaceDeformableBodyMaterialCfg() + + assert not hasattr(props, "define_func") + assert not hasattr(props, "modify_func") + assert physx_schemas.define_deformable_body_properties is schemas.define_deformable_body_properties + assert physx_schemas.modify_deformable_body_properties is schemas.modify_deformable_body_properties + assert str(material.func) == "isaaclab.sim.spawners.materials.physics_materials:spawn_deformable_body_material" + assert str(surface_material.func) == str(material.func) + _assert_no_property_prefix_field(type(props)) + _assert_no_property_prefix_field(type(material)) + _assert_no_property_prefix_field(type(surface_material)) + assert PhysXDeformableBodyPropertiesCfg._usd_namespace == "physxDeformableBody" + assert PhysXDeformableBodyPropertiesCfg._usd_applied_schema == "PhysxBaseDeformableBodyAPI" + assert OmniPhysicsDeformableBodyPropertiesCfg._usd_namespace == "omniphysics" + assert OmniPhysicsDeformableBodyPropertiesCfg._usd_applied_schema is None + assert PhysxDeformableCollisionPropertiesCfg._usd_namespace == "physxCollision" + assert PhysxDeformableCollisionPropertiesCfg._usd_applied_schema == "PhysxCollisionAPI" + assert {"deformable_body_enabled", "kinematic_enabled", "mass"}.issubset(_field_names(type(props))) + assert {"density", "static_friction", "dynamic_friction", "youngs_modulus", "poissons_ratio"}.issubset( + _field_names(type(material)) + ) + assert "surface_thickness" in _field_names(type(surface_material)) + assert "bend_damping" in _field_names(type(surface_material)) + assert PhysXDeformableMaterialCfg._usd_namespace == "physxDeformableMaterial" + assert PhysXDeformableMaterialCfg._usd_applied_schema == "PhysxDeformableMaterialAPI" + assert "_usd_applied_schema" not in type(material).__dict__ + assert type(surface_material)._usd_namespace == "physxDeformableMaterial" + assert type(surface_material)._usd_applied_schema == "PhysxSurfaceDeformableMaterialAPI" + + +def test_newton_deformable_cfgs_use_core_schema_and_material_functions(): + """Newton deformable cfgs own Newton fields while schema and material functions stay in core.""" + props = NewtonDeformableBodyPropertiesCfg() + material = NewtonDeformableBodyMaterialCfg() + surface_material = NewtonSurfaceDeformableBodyMaterialCfg() + + assert not hasattr(props, "define_func") + assert not hasattr(props, "modify_func") + assert NewtonDeformableBodyPropertiesCfg._usd_namespace == "newton" + assert NewtonDeformableBodyPropertiesCfg._usd_applied_schema is None + assert str(material.func) == "isaaclab.sim.spawners.materials.physics_materials:spawn_deformable_body_material" + assert str(surface_material.func) == str(material.func) + _assert_no_property_prefix_field(type(props)) + _assert_no_property_prefix_field(type(material)) + _assert_no_property_prefix_field(type(surface_material)) + assert "deformable_body_enabled" not in _field_names(type(props)) + assert "kinematic_enabled" not in _field_names(type(props)) + assert "mass" not in _field_names(type(props)) + assert "youngs_modulus" not in _field_names(type(material)) + assert "poissons_ratio" not in _field_names(type(material)) + assert {"density", "particle_radius", "k_mu", "k_lambda", "k_damp"}.issubset(_field_names(type(material))) + assert NewtonDeformableMaterialCfg._usd_namespace == "newton" + assert NewtonDeformableMaterialCfg._usd_applied_schema is None diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 1fb03ede1433..32143a8bb430 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -28,9 +28,6 @@ PhysxJointDrivePropertiesCfg, PhysxRigidBodyPropertiesCfg, ) -from isaaclab_physx.sim.schemas import ( - PhysXCollisionPropertiesCfg as PhysxDeformableCollisionAliasCfg, -) from isaaclab_physx.sim.spawners.materials import PhysxRigidBodyMaterialCfg, RigidBodyMaterialCfg from pxr import UsdPhysics @@ -418,19 +415,6 @@ def test_collision_deprecation_alias(setup_simulation): assert "5.0" in str(deprecations[0].message) -@pytest.mark.isaacsim_ci -def test_physx_capitalx_collision_deprecation_alias(setup_simulation): - """Instantiating the legacy ``PhysXCollisionPropertiesCfg`` (capital X, deformable) - name emits exactly one ``DeprecationWarning`` pointing to - ``PhysxDeformableCollisionPropertiesCfg``.""" - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - PhysxDeformableCollisionAliasCfg() - deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] - assert len(deprecations) == 1, f"expected exactly one DeprecationWarning, got {len(deprecations)}" - assert "PhysxDeformableCollisionPropertiesCfg" in str(deprecations[0].message) - - @pytest.mark.isaacsim_ci def test_articulation_root_base_cfg_writes_articulation_enabled(setup_simulation): """Setting ``articulation_enabled`` on the base ``ArticulationRootBaseCfg`` must author diff --git a/source/isaaclab/test/sim/test_schemas_shim.py b/source/isaaclab/test/sim/test_schemas_shim.py index 11e6e5d1ba24..9a9c510e09bd 100644 --- a/source/isaaclab/test/sim/test_schemas_shim.py +++ b/source/isaaclab/test/sim/test_schemas_shim.py @@ -29,8 +29,9 @@ "PhysxJointDrivePropertiesCfg", "CollisionPropertiesCfg", "PhysxCollisionPropertiesCfg", + "DeformableBodyPropertiesCfg", + "PhysxDeformableBodyPropertiesCfg", "PhysxDeformableCollisionPropertiesCfg", - "PhysXCollisionPropertiesCfg", "ArticulationRootPropertiesCfg", "PhysxArticulationRootPropertiesCfg", "MeshCollisionPropertiesCfg", @@ -54,7 +55,7 @@ "RigidBodyPropertiesCfg", "JointDrivePropertiesCfg", "CollisionPropertiesCfg", - "PhysXCollisionPropertiesCfg", + "DeformableBodyPropertiesCfg", "ArticulationRootPropertiesCfg", "MeshCollisionPropertiesCfg", "ConvexHullPropertiesCfg", @@ -67,8 +68,18 @@ ] FORWARDED_MATERIAL_NAMES = [ + "DeformableBodyMaterialCfg", "RigidBodyMaterialCfg", + "SurfaceDeformableBodyMaterialCfg", "PhysxRigidBodyMaterialCfg", + "PhysxDeformableBodyMaterialCfg", + "PhysxSurfaceDeformableBodyMaterialCfg", +] + +DEPRECATED_FORWARDED_MATERIAL_NAMES = [ + "DeformableBodyMaterialCfg", + "RigidBodyMaterialCfg", + "SurfaceDeformableBodyMaterialCfg", ] @@ -132,13 +143,14 @@ def test_deprecated_aliases_emit_deprecation_warning(name): assert len(deprecations) == 1, f"{name}: expected one DeprecationWarning, got {len(deprecations)}" -def test_deprecated_material_alias_emits_deprecation_warning(): - """Instantiating ``RigidBodyMaterialCfg`` via the shim still emits ``DeprecationWarning``.""" +@pytest.mark.parametrize("name", DEPRECATED_FORWARDED_MATERIAL_NAMES) +def test_deprecated_material_aliases_emit_deprecation_warning(name): + """Instantiating a deprecated material alias via the shim still emits ``DeprecationWarning``.""" with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - materials.RigidBodyMaterialCfg() + getattr(materials, name)() deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] - assert len(deprecations) == 1 + assert len(deprecations) == 1, f"{name}: expected one DeprecationWarning, got {len(deprecations)}" assert "5.0" in str(deprecations[0].message) @@ -158,6 +170,13 @@ def test_new_material_class_does_not_emit_deprecation_warning(): assert not any(issubclass(w.category, DeprecationWarning) for w in caught) +def test_deformable_component_cfg_is_not_forwarded_from_core(): + """Component deformable cfgs are backend-owned and not forwarded from ``isaaclab``.""" + assert not hasattr(schemas, "PhysXDeformableBodyPropertiesCfg") + assert not hasattr(sim_utils, "PhysXDeformableBodyPropertiesCfg") + assert not hasattr(schemas_cfg_submodule, "PhysXDeformableBodyPropertiesCfg") + + def test_dir_lists_forwarded_names(): """``dir(isaaclab.sim.schemas)`` includes the forwarded names so IDE autocomplete works.""" listing = dir(schemas) diff --git a/source/isaaclab/test/sim/test_spawn_meshes.py b/source/isaaclab/test/sim/test_spawn_meshes.py index 7b4c4678ca54..97aca4a23f78 100644 --- a/source/isaaclab/test/sim/test_spawn_meshes.py +++ b/source/isaaclab/test/sim/test_spawn_meshes.py @@ -120,18 +120,19 @@ def test_spawn_sphere(sim): @pytest.mark.parametrize("resolution", [(1, 1), (3, 2)]) -def test_spawn_square(sim, resolution): - """Test spawning of UsdGeomMesh as a square prim.""" - # Spawn square - cfg = sim_utils.MeshSquareCfg(size=1.0, resolution=resolution) - prim = cfg.func("/World/Square", cfg) +@pytest.mark.parametrize("size", [(1.0, 1.0), (1.5, 0.8)]) +def test_spawn_rectangle(sim, resolution, size): + """Test spawning of UsdGeomMesh as a rectangle prim.""" + # Spawn rectangle + cfg = sim_utils.MeshRectangleCfg(size=size, resolution=resolution) + prim = cfg.func("/World/Rectangle", cfg) # Check validity assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Square").IsValid() + assert sim.stage.GetPrimAtPath("/World/Rectangle").IsValid() assert prim.GetPrimTypeInfo().GetTypeName() == "Xform" # Check properties - prim = sim.stage.GetPrimAtPath("/World/Square/geometry/mesh") + prim = sim.stage.GetPrimAtPath("/World/Rectangle/geometry/mesh") assert prim.GetPrimTypeInfo().GetTypeName() == "Mesh" diff --git a/source/isaaclab_contrib/changelog.d/mym-deformable-backend-split.skip b/source/isaaclab_contrib/changelog.d/mym-deformable-backend-split.skip new file mode 100644 index 000000000000..37d59e91ab7b --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/mym-deformable-backend-split.skip @@ -0,0 +1 @@ +Test-only updates for backend-specific deformable cfg imports. diff --git a/source/isaaclab_contrib/changelog.d/mym-deformable_experimental.minor.rst b/source/isaaclab_contrib/changelog.d/mym-deformable_experimental.minor.rst new file mode 100644 index 000000000000..4167f0a33328 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/mym-deformable_experimental.minor.rst @@ -0,0 +1,16 @@ +Added +^^^^^ + +* Added :mod:`isaaclab_contrib.deformable` with contributed Newton deformable + asset and VBD solver support, including + :class:`~isaaclab_contrib.deformable.DeformableObject`, + :class:`~isaaclab_contrib.deformable.VBDSolverCfg`, + :class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg`, and + :class:`~isaaclab_contrib.deformable.CoupledFeatherstoneVBDSolverCfg` for + one- and two-way rigid-deformable coupling. +* Added :class:`~isaaclab_contrib.deformable.NewtonModelCfg` for shared Newton + deformable contact parameters. +* Added Newton deformable coupling documentation with Franka soft-body lift + tuning guidance for + :class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg` and + :class:`~isaaclab_contrib.deformable.NewtonModelCfg`. diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/__init__.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/__init__.py new file mode 100644 index 000000000000..02c5059784e6 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/__init__.py @@ -0,0 +1,13 @@ +# 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 + +"""Sub-package for externally contributed assets. + +This package contains contributed code that depends on Isaac Lab's public API but is not required for core functionality. This includes implementations of Newton solvers for deformables. +""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/__init__.pyi b/source/isaaclab_contrib/isaaclab_contrib/deformable/__init__.pyi new file mode 100644 index 000000000000..b2438a247a44 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/__init__.pyi @@ -0,0 +1,22 @@ +# 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__ = [ + "CoupledFeatherstoneVBDSolverCfg", + "CoupledMJWarpVBDSolverCfg", + "DeformableObject", + "DeformableObjectData", + "NewtonModelCfg", + "VBDSolverCfg", +] + +from .deformable_object import DeformableObject +from .deformable_object_data import DeformableObjectData +from .newton_manager_cfg import ( + CoupledFeatherstoneVBDSolverCfg, + CoupledMJWarpVBDSolverCfg, + NewtonModelCfg, + VBDSolverCfg, +) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py new file mode 100644 index 000000000000..312d9fc69f10 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py @@ -0,0 +1,509 @@ +# 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 + +"""Coupled Featherstone + VBD Newton manager.""" + +from __future__ import annotations + +import inspect +import logging +from typing import TYPE_CHECKING + +import warp as wp +from isaaclab_newton.physics.newton_manager import NewtonManager +from newton import Contacts, Control, Model, ModelBuilder, State +from newton._src.usd.schemas import SchemaResolverNewton, SchemaResolverPhysx +from newton.solvers import SolverBase, SolverFeatherstone, SolverVBD + +from isaaclab.sim.utils.stage import get_current_stage + +from .deformable_object import ( + add_deformable_entry_to_builder, + clear_deformable_builder_hooks, + install_deformable_builder_hooks, +) +from .kernels import _kernel_body_particle_reaction +from .newton_manager_cfg import CoupledFeatherstoneVBDSolverCfg + +if TYPE_CHECKING: + from isaaclab.sim.simulation_context import SimulationContext + +logger = logging.getLogger(__name__) + + +class NewtonCoupledFeatherstoneVBDManager(NewtonManager): + """:class:`NewtonManager` specialization for the coupled Featherstone + VBD + solver. Due to Newton deformables not being properly integrated yet, this + manager uses the same temporary solutions from VBD Manager. + + Always uses Newton's :class:`CollisionPipeline` for contact handling. + """ + + _rigid_solver: SolverFeatherstone + _soft_solver: SolverVBD + _coupling_mode: str | None = None + + @classmethod + def initialize(cls, sim_context: SimulationContext) -> None: + """Initialize the manager with simulation context. + + Args: + sim_context: Parent simulation context. + + TODO: Subclass should not override this method, once deformables + supported on Newton import_usd, this can be unified with NewtonManager's + implementation. + """ + + # Deformable body registry and extension hooks. + # Experimental deformable support registers callbacks here so the manager + # and cloner can invoke them without hard-coding deformable logic. + install_deformable_builder_hooks() + + super().initialize(sim_context) + + @classmethod + def step(cls) -> None: + """Step the physics simulation.""" + from isaaclab.physics import PhysicsManager + + sim = PhysicsManager._sim + if sim is None or not sim.is_playing(): + return + + # Notify solver of model changes + if cls._model_changes: + with wp.ScopedDevice(PhysicsManager._device): + for change in cls._model_changes: + cls._rigid_solver.notify_model_changed(change) + cls._soft_solver.notify_model_changed(change) + NewtonManager._model_changes = set() + super().step() + + @classmethod + def _solver_specific_clear(cls): + """Clear VBD-specific state.""" + clear_deformable_builder_hooks() + + @classmethod + def _get_deformable_ignore_paths(cls) -> list[str]: + """Return USD prim paths to skip when calling ``builder.add_usd``. + + For each registered deformable body, both the simulation mesh (which + carries ``UsdPhysics.CollisionAPI``) and the visual mesh are returned. + The sim mesh must be skipped so Newton does not create a redundant + static mesh collider alongside the particles produced by + ``add_soft_mesh``. The visual mesh is skipped so Newton does not + treat it as a collider — Kit reads it directly from USD for rendering. + + Paths may contain regex patterns; Newton's ``add_usd`` matches them + via :func:`re.match`. + """ + paths: list[str] = [] + for entry in cls._deformable_registry: + paths.append(entry.sim_mesh_prim_path) + paths.append(entry.vis_mesh_prim_path) + return paths + + @classmethod + def start_simulation(cls) -> None: + """Start simulation by finalizing model and initializing state. + + This function finalizes the model and initializes the simulation state. + Note: Collision pipeline is initialized later in initialize_solver() after + we determine whether the solver needs external collision detection. + + TODO: Subclass should not override this method, missing piece is + having Newton bind a surface mesh to volume deformable tetrahedral mesh + in addition to removing the deformable_registry data structure. + """ + super().start_simulation() + + # Apply global model parameters from :class:`NewtonModelCfg` to the finalized model. + # Sets ``soft_contact_ke/kd/mu`` and optionally overrides per-shape + # ``shape_material_ke/kd/mu`` on the Newton model. + from isaaclab.physics import PhysicsManager + + cfg = PhysicsManager._cfg + if cfg is not None and hasattr(cfg, "model_cfg") and cfg.model_cfg is not None: + model = cls._model + if model is None: + return + + model_cfg = cfg.model_cfg + model.soft_contact_ke = float(model_cfg.soft_contact_ke) + model.soft_contact_kd = float(model_cfg.soft_contact_kd) + model.soft_contact_mu = float(model_cfg.soft_contact_mu) + + if model_cfg.shape_material_ke is not None: + model.shape_material_ke.fill_(float(model_cfg.shape_material_ke)) + if model_cfg.shape_material_kd is not None: + model.shape_material_kd.fill_(float(model_cfg.shape_material_kd)) + if model_cfg.shape_material_mu is not None: + model.shape_material_mu.fill_(float(model_cfg.shape_material_mu)) + + # Setup USD/Fabric sync for Kit viewport deformable rendering + if not cls._clone_physics_only and cls._deformable_registry: + import re + + import usdrt + + if NewtonManager._usdrt_stage is None: + NewtonManager._usdrt_stage = get_current_stage(fabric=True) + + stage = get_current_stage() + for entry in cls._deformable_registry: + for inst_idx, offset in enumerate(entry.particle_offsets): + # Resolve regex pattern to concrete instance path of visual mesh + resolved_vis = re.sub(r"(?<=[Ee]nv_)\.\*", str(inst_idx), entry.vis_mesh_prim_path) + resolved_vis = re.sub(r"\.\*", str(inst_idx), resolved_vis) + vis_prim = stage.GetPrimAtPath(resolved_vis) + + if not vis_prim or not vis_prim.IsValid(): + logger.warning("[setup_fabric_particle_sync] vis prim not found at %s", resolved_vis) + continue + + # Create per-instance particle offset and count attributes on the visual mesh + # prim so the Fabric sync kernel can find the right slice of particle_q + # and iterate only over this body's particles (counts vary across bodies). + fab_prim = NewtonManager._usdrt_stage.GetPrimAtPath(vis_prim.GetPath().pathString) + fab_prim.CreateAttribute( + NewtonManager._newton_particle_offset_attr, usdrt.Sdf.ValueTypeNames.UInt, True + ) + fab_prim.GetAttribute(NewtonManager._newton_particle_offset_attr).Set(offset) + fab_prim.CreateAttribute( + NewtonManager._newton_particle_count_attr, usdrt.Sdf.ValueTypeNames.UInt, True + ) + fab_prim.GetAttribute(NewtonManager._newton_particle_count_attr).Set(entry.particles_per_body) + + cls._mark_particles_dirty() + cls.sync_particles_to_usd() + + @classmethod + def instantiate_builder_from_stage(cls): + """Create builder from USD stage with special treatment for deformable + bodies, as these are not read from USD yet. + + Detects env Xforms (e.g. ``/World/Env_0``, ``/World/Env_1``) and builds + each as a separate Newton world via ``begin_world``/``end_world``. + Falls back to a flat ``add_usd`` when no env Xforms are found. + + TODO: Subclass should not override this method, once deformables + supported on Newton import_usd, this can be unified with NewtonManager's + implementation. + """ + import re + + from pxr import UsdGeom + + stage = get_current_stage() + up_axis = UsdGeom.GetStageUpAxis(stage) + + # Scan /World children for env-like Xforms (Env_0, env_1, ...) + env_pattern = re.compile(r"^[Ee]nv_(\d+)$") + world_prim = stage.GetPrimAtPath("/World") + env_paths: list[tuple[int, str]] = [] + if world_prim and world_prim.IsValid(): + for child in world_prim.GetChildren(): + m = env_pattern.match(child.GetName()) + if m: + env_paths.append((int(m.group(1)), child.GetPath().pathString)) + env_paths.sort(key=lambda x: x[0]) + + builder = ModelBuilder(up_axis=up_axis) + + schema_resolvers = [SchemaResolverNewton(), SchemaResolverPhysx()] + + # Deformable sim/visual mesh paths must be skipped by ``add_usd`` + # so they don't get duplicated as static colliders. + deformable_ignore_paths = cls._get_deformable_ignore_paths() + + if not env_paths: + # No env Xforms — flat loading + builder.add_usd(stage, ignore_paths=deformable_ignore_paths, schema_resolvers=schema_resolvers) + + # Add deformable bodies from the registry (single world at origin). + for entry in cls._deformable_registry: + add_deformable_entry_to_builder(builder, entry, 0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]) + else: + # Load everything except the env subtrees (ground plane, lights, etc.) + ignore_paths = [path for _, path in env_paths] + deformable_ignore_paths + builder.add_usd(stage, ignore_paths=ignore_paths, schema_resolvers=schema_resolvers) + + # Build a prototype from the first env (all envs assumed identical) + _, proto_path = env_paths[0] + proto = ModelBuilder(up_axis=up_axis) + proto.add_usd( + stage, + root_path=proto_path, + ignore_paths=deformable_ignore_paths, + schema_resolvers=schema_resolvers, + ) + + # Inject registered sites into the proto before replication + global_sites, proto_sites = cls._cl_inject_sites(builder, {proto_path: proto}) + global_site_map: dict[str, tuple[int, None]] = {label: (idx, None) for label, idx in global_sites.items()} + num_worlds = len(env_paths) + local_site_map: dict[str, list[list[int]]] = {} + site_entries = proto_sites.get(id(proto), {}) + + # Add each env as a separate Newton world + xform_cache = UsdGeom.XformCache() + for col, (_, env_path) in enumerate(env_paths): + builder.begin_world() + offset = builder.shape_count + world_xform = xform_cache.GetLocalToWorldTransform(stage.GetPrimAtPath(env_path)) + translation = world_xform.ExtractTranslation() + rotation = world_xform.ExtractRotationQuat() + pos = (translation[0], translation[1], translation[2]) + quat = ( + rotation.GetImaginary()[0], + rotation.GetImaginary()[1], + rotation.GetImaginary()[2], + rotation.GetReal(), + ) + builder.add_builder(proto, xform=wp.transform(pos, quat)) + for label, proto_shape_indices in site_entries.items(): + if label not in local_site_map: + local_site_map[label] = [[] for _ in range(num_worlds)] + for proto_shape_idx in proto_shape_indices: + local_site_map[label][col].append(offset + proto_shape_idx) + + # Add deformable bodies from the registry into this world. + for entry in cls._deformable_registry: + add_deformable_entry_to_builder(builder, entry, col, list(pos), quat) + + builder.end_world() + + NewtonManager._cl_site_index_map = { + **global_site_map, + **{label: (None, per_world) for label, per_world in local_site_map.items()}, + } + NewtonManager._num_envs = len(env_paths) + + # Call builder.color() if any deformable entries were added (required by VBD solver) + if cls._deformable_registry: + builder.color() + + cls.set_builder(builder) + + @classmethod + def _build_solver(cls, model: Model, solver_cfg: CoupledFeatherstoneVBDSolverCfg) -> None: + """Construct a custom coupling between two solvers and populate the + base-class slots. + + VBD always uses Newton's :class:`CollisionPipeline` and steps with + separate input/output states, so the flags are fixed. + """ + cls._coupling_mode = solver_cfg.coupling_mode + + valid = set(inspect.signature(SolverFeatherstone.__init__).parameters) - {"self", "model"} + kwargs = {k: v for k, v in solver_cfg.rigid_solver_cfg.to_dict().items() if k in valid} + cls._rigid_solver = SolverFeatherstone(model, **kwargs) + + valid = set(inspect.signature(SolverVBD.__init__).parameters) - {"self", "model"} + kwargs = {k: v for k, v in solver_cfg.soft_solver_cfg.to_dict().items() if k in valid} + cls._soft_solver = SolverVBD(model, **kwargs) + + # Dummy solver for the newtonmanager + NewtonManager._solver = SolverBase(model) + + NewtonManager._use_single_state = False + NewtonManager._needs_collision_pipeline = True + + if solver_cfg.coupling_mode == "kinematic": + cls._gravity_zero = wp.zeros(1, dtype=wp.vec3) + cls._gravity_saved = wp.clone(model.gravity) + # Save original PD gains and create zeroed versions for kinematic step + cls._ke_saved = wp.clone(model.joint_target_ke) + cls._kd_saved = wp.clone(model.joint_target_kd) + cls._ke_zero = wp.zeros_like(model.joint_target_ke) + cls._kd_zero = wp.zeros_like(model.joint_target_kd) + + @classmethod + def _step_solver( + cls, state_in: State, state_out: State, control: Control, contacts: Contacts | None, substep_dt: float + ) -> None: + """One coupled substep. + + Args: + state_in: Current state (read/write). + state_out: Next state (write). + control: Joint-level control inputs. + contacts: Ignored -- the solver uses its own internal contacts. + dt: Substep timestep [s]. + """ + if cls._coupling_mode == "kinematic": + cls._step_kinematic(state_in, state_out, control, substep_dt) + elif cls._coupling_mode == "one_way": + cls._step_one_way(state_in, state_out, control, substep_dt) + else: + cls._step_two_way(state_in, state_out, control, substep_dt) + + @classmethod + def _simulate_physics_only(cls) -> None: + # Rebuild BVH once per step for solvers that require it (e.g. VBD cloth). + if hasattr(cls._soft_solver, "rebuild_bvh"): + cls._soft_solver.rebuild_bvh(cls._state_0) + super()._simulate_physics_only() + + @classmethod + def _step_kinematic(cls, state_in: State, state_out: State, control: Control, dt: float) -> None: + """Kinematic coupling: mirrors some Newton examples (e.g. softbody_franka) exactly. + + 1. Clear forces. + 2. Assign joint_qd from control targets (velocity = (target - current) / frame_dt). + 3. Disable gravity and rigid contacts for the rigid solver step. + 4. Step rigid solver as kinematic integrator (q += qd * dt). + 5. Restore gravity, collision detect, VBD step. + """ + model = cls._model + + # 1. Clear forces + state_in.clear_forces() + state_out.clear_forces() + + # 2. Kinematic rigid step: assign qd, disable gravity/contacts/PD gains + saved_particle_count = model.particle_count + saved_shape_contact_pair_count = model.shape_contact_pair_count + model.particle_count = 0 + model.gravity.assign(cls._gravity_zero) + model.shape_contact_pair_count = 0 + + # Zero out PD gains so rigid solver (Featherstone) acts as a pure kinematic integrator + model.joint_target_ke.assign(cls._ke_zero) + model.joint_target_kd.assign(cls._kd_zero) + + # Assign joint velocities from control targets + state_in.joint_qd.assign(control.joint_target_vel) + + cls._rigid_solver.step(state_in, state_out, control, None, dt) + + # 3. Restore everything + state_in.particle_f.zero_() + model.particle_count = saved_particle_count + model.gravity.assign(cls._gravity_saved) + model.shape_contact_pair_count = saved_shape_contact_pair_count + model.joint_target_ke.assign(cls._ke_saved) + model.joint_target_kd.assign(cls._kd_saved) + + # 4. Collision detection + cls._collision_pipeline.collide(state_in, cls._contacts) + + # 5. VBD step + cls._soft_solver.step(state_in, state_out, control, cls._contacts, dt) + + @classmethod + def _step_one_way(cls, state_in: State, state_out: State, control: Control, dt: float) -> None: + """One-way coupling: collide, then rigid step, then VBD.""" + # 1. Clear forces + state_in.clear_forces() + state_out.clear_forces() + + # 2. Collision detection (cloth-body contacts) + cls._collision_pipeline.collide(state_in, cls._contacts) + + # 3. Rigid-body step (does not read soft-contact reactions) + cls._rigid_step(state_in, state_out, control, dt) + + # 4. Clear spurious particle forces from rigid step + state_in.particle_f.zero_() + + # 5. VBD step -- particles only, reads updated rigid poses + cls._soft_solver.step(state_in, state_out, control, cls._contacts, dt) + + @classmethod + def _step_two_way(cls, state_in: State, state_out: State, control: Control, dt: float) -> None: + """Two-way coupling: collide, inject reactions into body_f, rigid step, VBD step.""" + # 1. Clear forces + state_in.clear_forces() + state_out.clear_forces() + + # 2. Collision detection BEFORE rigid step + cls._collision_pipeline.collide(state_in, cls._contacts) + + # 3. Inject contact reaction forces into body_f. + # state_out holds the previous substep's body_q (states swap each + # substep), used for finite-difference body velocity in friction. + # particle_q_prev is reconstructed from particle_qd inside the + # kernel because VBD mutates particle_q in place, so the swapped + # state's particle_q is not a clean prior-substep snapshot. + if state_in.body_f is not None: + cls._apply_reactions(state_in, state_out, dt) + + # 4. Rigid-body step (reads body_f for soft-contact reactions) + cls._rigid_step(state_in, state_out, control, dt) + + # 5. Clear spurious particle forces from rigid step + state_in.particle_f.zero_() + + # 6. VBD step -- uses same contacts detected in step 2 + cls._soft_solver.step(state_in, state_out, control, cls._contacts, dt) + + @classmethod + def _rigid_step(cls, state_in: State, state_out: State, control: Control, dt: float) -> None: + """Advance rigid bodies with the configured sub-solver.""" + model = cls._model + + # set particle_count = 0 to disable particle simulation in robot solver + saved_particle_count = model.particle_count + model.particle_count = 0 + + cls._rigid_solver.step(state_in, state_out, control, None, dt) + + # restore original settings + model.particle_count = saved_particle_count + + @classmethod + def _apply_reactions(cls, state: State, state_prev: State, dt: float) -> None: + """Launch the reaction kernel to inject normal + friction forces into body_f. + + Args: + state: Current state with particle positions/velocities and body state. + state_prev: Previous substep state whose ``body_q`` provides + the reference poses for finite-difference body velocity. + dt: Substep timestep [s]. + """ + model = cls._model + contacts = cls._contacts + + if contacts is None: + return + + contact_capacity = int(contacts.soft_contact_particle.shape[0]) + if contact_capacity == 0: + return + + # The kernel reconstructs particle_q_prev from particle_qd internally: + # state_prev.particle_q is unreliable because VBD mutates particle_q + # in place during its iteration, so the swapped state's particle_q is + # not a clean snapshot of the prior substep. + wp.launch( + _kernel_body_particle_reaction, + dim=contact_capacity, + inputs=[ + contacts.soft_contact_count, + contacts.soft_contact_particle, + contacts.soft_contact_shape, + contacts.soft_contact_body_pos, + contacts.soft_contact_body_vel, + contacts.soft_contact_normal, + state.particle_q, + state.particle_qd, + model.particle_radius, + state.body_q, + state_prev.body_q, + state.body_qd, + model.body_com, + model.shape_body, + model.shape_material_mu, + float(model.soft_contact_ke), + float(model.soft_contact_kd), + float(model.soft_contact_mu), + float(cls._soft_solver.friction_epsilon), + float(dt), + state.body_f, + ], + ) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py new file mode 100644 index 000000000000..5097284bafc7 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py @@ -0,0 +1,452 @@ +# 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 + +"""Coupled MJWarp + VBD Newton manager.""" + +from __future__ import annotations + +import inspect +import logging +from typing import TYPE_CHECKING + +import warp as wp +from isaaclab_newton.physics.newton_manager import NewtonManager +from newton import Contacts, Control, Model, ModelBuilder, State +from newton._src.usd.schemas import SchemaResolverNewton, SchemaResolverPhysx +from newton.solvers import SolverBase, SolverMuJoCo, SolverVBD + +from isaaclab.sim.utils.stage import get_current_stage + +from .deformable_object import ( + add_deformable_entry_to_builder, + clear_deformable_builder_hooks, + install_deformable_builder_hooks, +) +from .kernels import _kernel_body_particle_reaction +from .newton_manager_cfg import CoupledMJWarpVBDSolverCfg + +if TYPE_CHECKING: + from isaaclab.sim.simulation_context import SimulationContext + +logger = logging.getLogger(__name__) + + +class NewtonCoupledMJWarpVBDManager(NewtonManager): + """:class:`NewtonManager` specialization for the coupled MJWarp + VBD + solver. Due to Newton deformables not being properly integrated yet, this + manager uses the same temporary solutions from VBD Manager. + + Always uses Newton's :class:`CollisionPipeline` for contact handling. + """ + + _rigid_solver: SolverMuJoCo + _soft_solver: SolverVBD + _coupling_mode: str | None = None + + @classmethod + def initialize(cls, sim_context: SimulationContext) -> None: + """Initialize the manager with simulation context. + + Args: + sim_context: Parent simulation context. + + TODO: Subclass should not override this method, once deformables + supported on Newton import_usd, this can be unified with NewtonManager's + implementation. + """ + + # Deformable body registry and extension hooks. + # Experimental deformable support registers callbacks here so the manager + # and cloner can invoke them without hard-coding deformable logic. + install_deformable_builder_hooks() + + super().initialize(sim_context) + + @classmethod + def step(cls) -> None: + """Step the physics simulation.""" + from isaaclab.physics import PhysicsManager + + sim = PhysicsManager._sim + if sim is None or not sim.is_playing(): + return + + # Notify solver of model changes + if cls._model_changes: + with wp.ScopedDevice(PhysicsManager._device): + for change in cls._model_changes: + cls._rigid_solver.notify_model_changed(change) + cls._soft_solver.notify_model_changed(change) + NewtonManager._model_changes = set() + super().step() + + @classmethod + def _solver_specific_clear(cls): + """Clear VBD-specific state.""" + clear_deformable_builder_hooks() + + @classmethod + def _get_deformable_ignore_paths(cls) -> list[str]: + """Return USD prim paths to skip when calling ``builder.add_usd``. + + For each registered deformable body, both the simulation mesh (which + carries ``UsdPhysics.CollisionAPI``) and the visual mesh are returned. + The sim mesh must be skipped so Newton does not create a redundant + static mesh collider alongside the particles produced by + ``add_soft_mesh``. The visual mesh is skipped so Newton does not + treat it as a collider — Kit reads it directly from USD for rendering. + + Paths may contain regex patterns; Newton's ``add_usd`` matches them + via :func:`re.match`. + """ + paths: list[str] = [] + for entry in cls._deformable_registry: + paths.append(entry.sim_mesh_prim_path) + paths.append(entry.vis_mesh_prim_path) + return paths + + @classmethod + def start_simulation(cls) -> None: + """Start simulation by finalizing model and initializing state. + + This function finalizes the model and initializes the simulation state. + Note: Collision pipeline is initialized later in initialize_solver() after + we determine whether the solver needs external collision detection. + + TODO: Subclass should not override this method, missing piece is + having Newton bind a surface mesh to volume deformable tetrahedral mesh + in addition to removing the deformable_registry data structure. + """ + super().start_simulation() + + # Apply global model parameters from :class:`NewtonModelCfg` to the finalized model. + # Sets ``soft_contact_ke/kd/mu`` and optionally overrides per-shape + # ``shape_material_ke/kd/mu`` on the Newton model. + from isaaclab.physics import PhysicsManager + + cfg = PhysicsManager._cfg + if cfg is not None and hasattr(cfg, "model_cfg") and cfg.model_cfg is not None: + model = cls._model + if model is None: + return + + model_cfg = cfg.model_cfg + model.soft_contact_ke = float(model_cfg.soft_contact_ke) + model.soft_contact_kd = float(model_cfg.soft_contact_kd) + model.soft_contact_mu = float(model_cfg.soft_contact_mu) + + if model_cfg.shape_material_ke is not None: + model.shape_material_ke.fill_(float(model_cfg.shape_material_ke)) + if model_cfg.shape_material_kd is not None: + model.shape_material_kd.fill_(float(model_cfg.shape_material_kd)) + if model_cfg.shape_material_mu is not None: + model.shape_material_mu.fill_(float(model_cfg.shape_material_mu)) + + # Setup USD/Fabric sync for Kit viewport deformable rendering + if not cls._clone_physics_only and cls._deformable_registry: + import re + + import usdrt + + if NewtonManager._usdrt_stage is None: + NewtonManager._usdrt_stage = get_current_stage(fabric=True) + + stage = get_current_stage() + for entry in cls._deformable_registry: + for inst_idx, offset in enumerate(entry.particle_offsets): + # Resolve regex pattern to concrete instance path of visual mesh + resolved_vis = re.sub(r"(?<=[Ee]nv_)\.\*", str(inst_idx), entry.vis_mesh_prim_path) + resolved_vis = re.sub(r"\.\*", str(inst_idx), resolved_vis) + vis_prim = stage.GetPrimAtPath(resolved_vis) + + if not vis_prim or not vis_prim.IsValid(): + logger.warning("[setup_fabric_particle_sync] vis prim not found at %s", resolved_vis) + continue + + # Create per-instance particle offset and count attributes on the visual mesh + # prim so the Fabric sync kernel can find the right slice of particle_q + # and iterate only over this body's particles (counts vary across bodies). + fab_prim = NewtonManager._usdrt_stage.GetPrimAtPath(vis_prim.GetPath().pathString) + fab_prim.CreateAttribute( + NewtonManager._newton_particle_offset_attr, usdrt.Sdf.ValueTypeNames.UInt, True + ) + fab_prim.GetAttribute(NewtonManager._newton_particle_offset_attr).Set(offset) + fab_prim.CreateAttribute( + NewtonManager._newton_particle_count_attr, usdrt.Sdf.ValueTypeNames.UInt, True + ) + fab_prim.GetAttribute(NewtonManager._newton_particle_count_attr).Set(entry.particles_per_body) + + cls._mark_particles_dirty() + cls.sync_particles_to_usd() + + @classmethod + def instantiate_builder_from_stage(cls): + """Create builder from USD stage with special treatment for deformable + bodies, as these are not read from USD yet. + + Detects env Xforms (e.g. ``/World/Env_0``, ``/World/Env_1``) and builds + each as a separate Newton world via ``begin_world``/``end_world``. + Falls back to a flat ``add_usd`` when no env Xforms are found. + + TODO: Subclass should not override this method, once deformables + supported on Newton import_usd, this can be unified with NewtonManager's + implementation. + """ + import re + + from pxr import UsdGeom + + stage = get_current_stage() + up_axis = UsdGeom.GetStageUpAxis(stage) + + # Scan /World children for env-like Xforms (Env_0, env_1, ...) + env_pattern = re.compile(r"^[Ee]nv_(\d+)$") + world_prim = stage.GetPrimAtPath("/World") + env_paths: list[tuple[int, str]] = [] + if world_prim and world_prim.IsValid(): + for child in world_prim.GetChildren(): + m = env_pattern.match(child.GetName()) + if m: + env_paths.append((int(m.group(1)), child.GetPath().pathString)) + env_paths.sort(key=lambda x: x[0]) + + builder = ModelBuilder(up_axis=up_axis) + + schema_resolvers = [SchemaResolverNewton(), SchemaResolverPhysx()] + + # Deformable sim/visual mesh paths must be skipped by ``add_usd`` + # so they don't get duplicated as static colliders. + deformable_ignore_paths = cls._get_deformable_ignore_paths() + + if not env_paths: + # No env Xforms — flat loading + builder.add_usd(stage, ignore_paths=deformable_ignore_paths, schema_resolvers=schema_resolvers) + + # Add deformable bodies from the registry (single world at origin). + for entry in cls._deformable_registry: + add_deformable_entry_to_builder(builder, entry, 0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]) + else: + # Load everything except the env subtrees (ground plane, lights, etc.) + ignore_paths = [path for _, path in env_paths] + deformable_ignore_paths + builder.add_usd(stage, ignore_paths=ignore_paths, schema_resolvers=schema_resolvers) + + # Build a prototype from the first env (all envs assumed identical) + _, proto_path = env_paths[0] + proto = ModelBuilder(up_axis=up_axis) + proto.add_usd( + stage, + root_path=proto_path, + ignore_paths=deformable_ignore_paths, + schema_resolvers=schema_resolvers, + ) + + # Inject registered sites into the proto before replication + global_sites, proto_sites = cls._cl_inject_sites(builder, {proto_path: proto}) + global_site_map: dict[str, tuple[int, None]] = {label: (idx, None) for label, idx in global_sites.items()} + num_worlds = len(env_paths) + local_site_map: dict[str, list[list[int]]] = {} + site_entries = proto_sites.get(id(proto), {}) + + # Add each env as a separate Newton world + xform_cache = UsdGeom.XformCache() + for col, (_, env_path) in enumerate(env_paths): + builder.begin_world() + offset = builder.shape_count + world_xform = xform_cache.GetLocalToWorldTransform(stage.GetPrimAtPath(env_path)) + translation = world_xform.ExtractTranslation() + rotation = world_xform.ExtractRotationQuat() + pos = (translation[0], translation[1], translation[2]) + quat = ( + rotation.GetImaginary()[0], + rotation.GetImaginary()[1], + rotation.GetImaginary()[2], + rotation.GetReal(), + ) + builder.add_builder(proto, xform=wp.transform(pos, quat)) + for label, proto_shape_indices in site_entries.items(): + if label not in local_site_map: + local_site_map[label] = [[] for _ in range(num_worlds)] + for proto_shape_idx in proto_shape_indices: + local_site_map[label][col].append(offset + proto_shape_idx) + + # Add deformable bodies from the registry into this world. + for entry in cls._deformable_registry: + add_deformable_entry_to_builder(builder, entry, col, list(pos), quat) + + builder.end_world() + + NewtonManager._cl_site_index_map = { + **global_site_map, + **{label: (None, per_world) for label, per_world in local_site_map.items()}, + } + NewtonManager._num_envs = len(env_paths) + + # Call builder.color() if any deformable entries were added (required by VBD solver) + if cls._deformable_registry: + builder.color() + + cls.set_builder(builder) + + @classmethod + def _build_solver(cls, model: Model, solver_cfg: CoupledMJWarpVBDSolverCfg) -> None: + """Construct a custom coupling between two solvers and populate the + base-class slots. + + VBD always uses Newton's :class:`CollisionPipeline` and steps with + separate input/output states, so the flags are fixed. + """ + cls._coupling_mode = solver_cfg.coupling_mode + + valid = set(inspect.signature(SolverMuJoCo.__init__).parameters) - {"self", "model"} + kwargs = {k: v for k, v in solver_cfg.rigid_solver_cfg.to_dict().items() if k in valid} + cls._rigid_solver = SolverMuJoCo(model, **kwargs) + + valid = set(inspect.signature(SolverVBD.__init__).parameters) - {"self", "model"} + kwargs = {k: v for k, v in solver_cfg.soft_solver_cfg.to_dict().items() if k in valid} + cls._soft_solver = SolverVBD(model, **kwargs) + + # Dummy solver for the newtonmanager + NewtonManager._solver = SolverBase(model) + + NewtonManager._use_single_state = False + NewtonManager._needs_collision_pipeline = True + + @classmethod + def _step_solver( + cls, state_in: State, state_out: State, control: Control, contacts: Contacts | None, substep_dt: float + ) -> None: + """One coupled substep. + + Args: + state_in: Current state (read/write). + state_out: Next state (write). + control: Joint-level control inputs. + contacts: Ignored -- the solver uses its own internal contacts. + dt: Substep timestep [s]. + """ + if cls._coupling_mode == "one_way": + cls._step_one_way(state_in, state_out, control, substep_dt) + else: + cls._step_two_way(state_in, state_out, control, substep_dt) + + @classmethod + def _simulate_physics_only(cls) -> None: + # Rebuild BVH once per step for solvers that require it (e.g. VBD cloth). + if hasattr(cls._soft_solver, "rebuild_bvh"): + cls._soft_solver.rebuild_bvh(cls._state_0) + super()._simulate_physics_only() + + @classmethod + def _step_one_way(cls, state_in: State, state_out: State, control: Control, dt: float) -> None: + """One-way coupling: collide, then rigid step, then VBD.""" + # 1. Clear forces + state_in.clear_forces() + state_out.clear_forces() + + # 2. Collision detection (cloth-body contacts) + cls._collision_pipeline.collide(state_in, cls._contacts) + + # 3. Rigid-body step (does not read soft-contact reactions) + cls._rigid_step(state_in, state_out, control, dt) + + # 4. Clear spurious particle forces from rigid step + state_in.particle_f.zero_() + + # 5. VBD step -- particles only, reads updated rigid poses + cls._soft_solver.step(state_in, state_out, control, cls._contacts, dt) + + @classmethod + def _step_two_way(cls, state_in: State, state_out: State, control: Control, dt: float) -> None: + """Two-way coupling: collide, inject reactions into body_f, rigid step, VBD step.""" + # 1. Clear forces + state_in.clear_forces() + state_out.clear_forces() + + # 2. Collision detection BEFORE rigid step + cls._collision_pipeline.collide(state_in, cls._contacts) + + # 3. Inject contact reaction forces into body_f. + # state_out holds the previous substep's body_q (states swap each + # substep), used for finite-difference body velocity in friction. + # particle_q_prev is reconstructed from particle_qd inside the + # kernel because VBD mutates particle_q in place, so the swapped + # state's particle_q is not a clean prior-substep snapshot. + if state_in.body_f is not None: + cls._apply_reactions(state_in, state_out, dt) + + # 4. Rigid-body step (reads body_f for soft-contact reactions) + cls._rigid_step(state_in, state_out, control, dt) + + # 5. Clear spurious particle forces from rigid step + state_in.particle_f.zero_() + + # 6. VBD step -- uses same contacts detected in step 2 + cls._soft_solver.step(state_in, state_out, control, cls._contacts, dt) + + @classmethod + def _rigid_step(cls, state_in: State, state_out: State, control: Control, dt: float) -> None: + """Advance rigid bodies with the configured sub-solver.""" + model = cls._model + + # set particle_count = 0 to disable particle simulation in robot solver + saved_particle_count = model.particle_count + model.particle_count = 0 + + cls._rigid_solver.step(state_in, state_out, control, None, dt) + + # restore original settings + model.particle_count = saved_particle_count + + @classmethod + def _apply_reactions(cls, state: State, state_prev: State, dt: float) -> None: + """Launch the reaction kernel to inject normal + friction forces into body_f. + + Args: + state: Current state with particle positions/velocities and body state. + state_prev: Previous substep state whose ``body_q`` provides + the reference poses for finite-difference body velocity. + dt: Substep timestep [s]. + """ + model = cls._model + contacts = cls._contacts + + if contacts is None: + return + + contact_capacity = int(contacts.soft_contact_particle.shape[0]) + if contact_capacity == 0: + return + + # The kernel reconstructs particle_q_prev from particle_qd internally: + # state_prev.particle_q is unreliable because VBD mutates particle_q + # in place during its iteration, so the swapped state's particle_q is + # not a clean snapshot of the prior substep. + wp.launch( + _kernel_body_particle_reaction, + dim=contact_capacity, + inputs=[ + contacts.soft_contact_count, + contacts.soft_contact_particle, + contacts.soft_contact_shape, + contacts.soft_contact_body_pos, + contacts.soft_contact_body_vel, + contacts.soft_contact_normal, + state.particle_q, + state.particle_qd, + model.particle_radius, + state.body_q, + state_prev.body_q, + state.body_qd, + model.body_com, + model.shape_body, + model.shape_material_mu, + float(model.soft_contact_ke), + float(model.soft_contact_kd), + float(model.soft_contact_mu), + float(cls._soft_solver.friction_epsilon), + float(dt), + state.body_f, + ], + ) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py new file mode 100644 index 000000000000..3c7fd10eb03e --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py @@ -0,0 +1,962 @@ +# 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 +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import numpy as np +import torch +import warp as wp +from isaaclab_newton.physics import NewtonManager as SimulationManager + +import isaaclab.sim as sim_utils +from isaaclab.assets.deformable_object.base_deformable_object import BaseDeformableObject +from isaaclab.markers import VisualizationMarkers +from isaaclab.physics import PhysicsEvent +from isaaclab.utils.warp import ProxyArray + +from .deformable_object_data import DeformableObjectData +from .kernels import ( + compute_nodal_state_w, + enforce_kinematic_targets, + scatter_particles_state_vec6f_mask, + scatter_particles_vec3f_index, + scatter_particles_vec3f_mask, + set_kinematic_flags_to_one, + vec6f, + write_nodal_kinematic_target_index, + write_nodal_kinematic_target_mask, +) + + +@dataclass +class DeformableRegistryEntry: + """Entry in the deformable body registry. + + Registered by :class:`DeformableObject` during ``__init__``, consumed by + ``newton_physics_replicate`` inside the per-world ``begin_world``/``end_world`` loop. + After replication, ``particle_offsets`` and ``particles_per_body`` are filled in + so the asset can bind to the correct particle ranges. + """ + + prim_path: str + sim_mesh_prim_path: str + vis_mesh_prim_path: str + vertices: list + indices: list + init_pos: tuple[float, float, float] + init_rot: tuple[float, float, float, float] # (x, y, z, w) + deformable_type: str | None = None # "volume" or "surface" + # Cloth params + density: float = 1.0 + tri_ke: float = 1e4 + tri_ka: float = 1e4 + tri_kd: float = 1.5e-6 + edge_ke: float = 5.0 + edge_kd: float = 1e-2 + particle_radius: float = 0.008 + # Tet params + k_mu: float = 1e5 + k_lambda: float = 1e5 + k_damp: float = 0.0 + # Filled by newton_physics_replicate: + particle_offsets: list[int] = field(default_factory=list) + particles_per_body: int = 0 + + +if TYPE_CHECKING: + from isaaclab.assets.deformable_object.deformable_object_cfg import DeformableObjectCfg + +logger = logging.getLogger(__name__) + + +def add_deformable_entry_to_builder( + builder, + entry: DeformableRegistryEntry, + env_idx: int, + env_position: list[float], + env_rotation: list[float] | tuple[float, float, float, float], +) -> None: + """Add a deformable registry entry to a Newton ``ModelBuilder`` for one environment. + + Depending on the deformable type (``"volume"`` or ``"surface"``), calls + ``builder.add_soft_mesh()`` or ``builder.add_cloth_mesh()`` with the mesh + data and material properties stored in the registry entry. + + Also records the particle offset for the instance and, on the first + environment, records the per-body particle count. + + Args: + builder: The Newton ``ModelBuilder``. + entry: A :class:`DeformableRegistryEntry` with mesh data and config. + env_idx: The environment index. + env_position: World position [x, y, z] [m] for this environment. + env_rotation: World orientation as quaternion ``(x, y, z, w)`` for this environment. + """ + if env_idx == 0: + entry.particle_offsets.clear() + entry.particles_per_body = 0 + + before_count = getattr(builder, "particle_count", 0) + + env_pos = wp.vec3(float(env_position[0]), float(env_position[1]), float(env_position[2])) + env_rot = wp.quat( + float(env_rotation[0]), + float(env_rotation[1]), + float(env_rotation[2]), + float(env_rotation[3]), + ) + init_pos = wp.vec3(float(entry.init_pos[0]), float(entry.init_pos[1]), float(entry.init_pos[2])) + init_rot = wp.quat( + float(entry.init_rot[0]), + float(entry.init_rot[1]), + float(entry.init_rot[2]), + float(entry.init_rot[3]), + ) + body_pos = env_pos + wp.quat_rotate(env_rot, init_pos) + body_rot = env_rot * init_rot + + if entry.deformable_type == "volume": + builder.add_soft_mesh( + pos=body_pos, + rot=body_rot, + scale=1.0, + vel=wp.vec3(0.0, 0.0, 0.0), + vertices=entry.vertices, + indices=entry.indices, + density=entry.density, + k_mu=entry.k_mu, + k_lambda=entry.k_lambda, + k_damp=entry.k_damp, + particle_radius=entry.particle_radius, + ) + elif entry.deformable_type == "surface": + builder.add_cloth_mesh( + pos=body_pos, + rot=body_rot, + scale=1.0, + vel=wp.vec3(0.0, 0.0, 0.0), + vertices=entry.vertices, + indices=entry.indices, + density=entry.density, + tri_ke=entry.tri_ke, + tri_ka=entry.tri_ka, + tri_kd=entry.tri_kd, + edge_ke=entry.edge_ke, + edge_kd=entry.edge_kd, + particle_radius=entry.particle_radius, + ) + else: + raise ValueError( + f"Invalid deformable type '{entry.deformable_type}' for registry entry with prim path '{entry.prim_path}'" + ) + + after_count = getattr(builder, "particle_count", 0) + delta = after_count - before_count + + entry.particle_offsets.append(before_count) + if env_idx == 0: + entry.particles_per_body = delta + elif entry.particles_per_body != delta: + raise RuntimeError( + f"Deformable body '{entry.prim_path}' produced {delta} particles in env {env_idx}, " + f"but env 0 produced {entry.particles_per_body}." + ) + + +def add_registered_deformables_to_builder( + builder, + world_idx: int, + env_position: list[float], + env_rotation: list[float] | tuple[float, float, float, float], +) -> None: + """Add all registered deformable entries to one Newton builder world.""" + for entry in SimulationManager._deformable_registry: + add_deformable_entry_to_builder(builder, entry, world_idx, env_position, env_rotation) + + +def color_registered_deformables(builder) -> None: + """Color the Newton builder when deformables were registered.""" + if SimulationManager._deformable_registry: + builder.color() + + +def install_deformable_builder_hooks() -> None: + """Install deformable builder hooks without removing hooks owned by other extensions.""" + SimulationManager._deformable_registry = [] + if not hasattr(SimulationManager, "_per_world_builder_hooks"): + SimulationManager._per_world_builder_hooks = [] + if not hasattr(SimulationManager, "_post_replicate_hooks"): + SimulationManager._post_replicate_hooks = [] + if add_registered_deformables_to_builder not in SimulationManager._per_world_builder_hooks: + SimulationManager._per_world_builder_hooks.append(add_registered_deformables_to_builder) + if color_registered_deformables not in SimulationManager._post_replicate_hooks: + SimulationManager._post_replicate_hooks.append(color_registered_deformables) + + +def clear_deformable_builder_hooks() -> None: + """Clear deformable registry state and remove only deformable-owned builder hooks.""" + SimulationManager._deformable_registry = [] + if hasattr(SimulationManager, "_per_world_builder_hooks"): + SimulationManager._per_world_builder_hooks = [ + hook + for hook in SimulationManager._per_world_builder_hooks + if hook is not add_registered_deformables_to_builder + ] + if hasattr(SimulationManager, "_post_replicate_hooks"): + SimulationManager._post_replicate_hooks = [ + hook for hook in SimulationManager._post_replicate_hooks if hook is not color_registered_deformables + ] + + +class DeformableObject(BaseDeformableObject): + """A deformable object asset class (Newton backend). + + This class manages cloth/deformable bodies in the Newton physics engine. Newton stores all + particles in flat arrays (``state.particle_q``, ``state.particle_qd``). This class builds + a per-instance indexing layer on top of those flat arrays, enabling the standard + :class:`BaseDeformableObject` interface for reading/writing nodal state. + + The cloth mesh is added to the Newton :class:`ModelBuilder` during the ``MODEL_INIT`` phase. + The mesh data is read from the USD prim at :attr:`cfg.prim_path`, and cloth simulation + parameters (density, stiffness, etc.) come from :attr:`DeformableObjectCfg`. + """ + + cfg: DeformableObjectCfg + """Configuration instance for the deformable object.""" + + __backend_name__: str = "newton" + """The name of the backend for the deformable object.""" + + def __init__(self, cfg: DeformableObjectCfg): + """Initialize the deformable object. + + Args: + cfg: A configuration instance. + """ + super().__init__(cfg) + + # initialize deformable type to None, should be set to either surface or volume on initialization + self._deformable_type: str | None = None + + # Read mesh from the spawned USD prim and register in the deformable registry. + self._registry_entry = self._register_deformable() + + # Register custom vec6f type for nodal state validation. + self._DTYPE_TO_TORCH_TRAILING_DIMS = {**self._DTYPE_TO_TORCH_TRAILING_DIMS, vec6f: (6,)} + + """ + Properties + """ + + @property + def data(self) -> DeformableObjectData: + return self._data + + @property + def num_instances(self) -> int: + return self._num_instances + + @property + def num_bodies(self) -> int: + """Number of bodies in the asset. + + This is always 1 since each object is a single deformable body. + """ + return 1 + + @property + def max_sim_vertices_per_body(self) -> int: + """The maximum number of simulation mesh vertices per deformable body.""" + return self._particles_per_body + + """ + Operations. + """ + + def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None) -> None: + """Reset the deformable object. + + No-op to match the PhysX deformable object convention. + + Args: + env_ids: Environment indices. If None, then all indices are used. + env_mask: Environment mask. If None, then all the instances are updated. + Shape is (num_instances,). + """ + pass + + def write_data_to_sim(self): + """Apply kinematic targets to the Newton simulation. + + Reads the stored kinematic target buffer and enforces it on particles: + kinematic particles (flag=0) get inv_mass=0, particle_flags=0, target position, + and zero velocity; free particles (flag=1) get their original inv_mass and + particle_flags=1 (ACTIVE) restored. + + Writes to both ``state_0`` and ``state_1`` so kinematic positions survive + the state swaps that happen between substeps. + """ + if ( + self._data.nodal_kinematic_target is None + or self._default_particle_inv_mass is None + or self._default_particle_flags is None + ): + return + + model = SimulationManager.get_model() + if model is None: + return + + for state in self._iter_particle_states(): + wp.launch( + enforce_kinematic_targets, + dim=(self._num_instances, self._particles_per_body), + inputs=[ + self._data.nodal_kinematic_target.warp, + self._particle_offsets, + self._default_particle_inv_mass, + self._default_particle_flags, + ], + outputs=[ + state.particle_q, + state.particle_qd, + model.particle_inv_mass, + model.particle_flags, + ], + device=self.device, + ) + + def update(self, dt: float): + self._data.update(dt) + + """ + Operations - Write to simulation. + """ + + def write_nodal_pos_to_sim_index( + self, + nodal_pos: torch.Tensor | wp.array | ProxyArray, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + full_data: bool = False, + ) -> None: + """Set the nodal positions over selected environment indices into the simulation. + + Args: + nodal_pos: Nodal positions in simulation frame [m]. + Shape is (len(env_ids), max_sim_vertices_per_body, 3) + or (num_instances, max_sim_vertices_per_body, 3). + env_ids: Environment indices. If None, then all indices are used. + full_data: Whether to expect full data. Defaults to False. + """ + env_ids = self._resolve_env_ids(env_ids) + if isinstance(nodal_pos, ProxyArray): + nodal_pos = nodal_pos.warp + if full_data: + self.assert_shape_and_dtype( + nodal_pos, (self.num_instances, self._particles_per_body), wp.vec3f, "nodal_pos" + ) + else: + self.assert_shape_and_dtype(nodal_pos, (env_ids.shape[0], self._particles_per_body), wp.vec3f, "nodal_pos") + if isinstance(nodal_pos, torch.Tensor): + nodal_pos = wp.from_torch(nodal_pos.contiguous(), dtype=wp.vec3f) + + for state in self._iter_particle_states(): + wp.launch( + scatter_particles_vec3f_index, + dim=(env_ids.shape[0], self._particles_per_body), + inputs=[nodal_pos, env_ids, self._particle_offsets, full_data], + outputs=[state.particle_q], + device=self.device, + ) + + self._invalidate_nodal_pos_cache() + + def write_nodal_velocity_to_sim_index( + self, + nodal_vel: torch.Tensor | wp.array | ProxyArray, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + full_data: bool = False, + ) -> None: + """Set the nodal velocity over selected environment indices into the simulation. + + Args: + nodal_vel: Nodal velocities in simulation frame [m/s]. + Shape is (len(env_ids), max_sim_vertices_per_body, 3) + or (num_instances, max_sim_vertices_per_body, 3). + env_ids: Environment indices. If None, then all indices are used. + full_data: Whether to expect full data. Defaults to False. + """ + env_ids = self._resolve_env_ids(env_ids) + if isinstance(nodal_vel, ProxyArray): + nodal_vel = nodal_vel.warp + if full_data: + self.assert_shape_and_dtype( + nodal_vel, (self.num_instances, self._particles_per_body), wp.vec3f, "nodal_vel" + ) + else: + self.assert_shape_and_dtype(nodal_vel, (env_ids.shape[0], self._particles_per_body), wp.vec3f, "nodal_vel") + if isinstance(nodal_vel, torch.Tensor): + nodal_vel = wp.from_torch(nodal_vel.contiguous(), dtype=wp.vec3f) + + for state in self._iter_particle_states(): + wp.launch( + scatter_particles_vec3f_index, + dim=(env_ids.shape[0], self._particles_per_body), + inputs=[nodal_vel, env_ids, self._particle_offsets, full_data], + outputs=[state.particle_qd], + device=self.device, + ) + + self._invalidate_nodal_vel_cache() + + def write_nodal_kinematic_target_to_sim_index( + self, + targets: torch.Tensor | wp.array | ProxyArray, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + full_data: bool = False, + ) -> None: + """Set the kinematic targets of the simulation mesh for the deformable bodies. + + Newton has no native kinematic target API. Instead: + - Kinematic (flag=0.0): set ``particle_inv_mass`` to 0, write target pos, zero vel + - Free (flag=1.0): restore original ``particle_inv_mass`` + + Args: + targets: The kinematic targets comprising of nodal positions and flags [m]. + Shape is (len(env_ids), max_sim_vertices_per_body, 4) + or (num_instances, max_sim_vertices_per_body, 4). + env_ids: Environment indices. If None, then all indices are used. + full_data: Whether to expect full data. Defaults to False. + """ + env_ids = self._resolve_env_ids(env_ids) + if isinstance(targets, ProxyArray): + targets = targets.warp + if full_data: + self.assert_shape_and_dtype(targets, (self.num_instances, self._particles_per_body), wp.vec4f, "targets") + else: + self.assert_shape_and_dtype(targets, (env_ids.shape[0], self._particles_per_body), wp.vec4f, "targets") + if isinstance(targets, torch.Tensor): + if targets.dim() == 2: + targets = targets.unsqueeze(0) + targets = wp.from_torch(targets.contiguous(), dtype=wp.vec4f) + + # Store kinematic targets in our data buffer + if self._data.nodal_kinematic_target is not None: + wp.launch( + write_nodal_kinematic_target_index, + dim=(env_ids.shape[0], self._particles_per_body), + inputs=[targets, env_ids, full_data], + outputs=[self._data.nodal_kinematic_target.warp], + device=self.device, + ) + + """ + Operations - Write to simulation (mask variants). + """ + + def write_nodal_state_to_sim_mask( + self, + nodal_state: torch.Tensor | wp.array | ProxyArray, + env_mask: wp.array | torch.Tensor | None = None, + ) -> None: + """Set the nodal state over selected environment mask into the simulation. + + Args: + nodal_state: Nodal state in simulation frame [m, m/s]. + Shape is (num_instances, max_sim_vertices_per_body, 6). + env_mask: Environment mask. If None, then all indices are used. + Shape is (num_instances,). + """ + env_mask = self._resolve_mask(env_mask, self._ALL_ENV_MASK) + if isinstance(nodal_state, ProxyArray): + nodal_state = nodal_state.warp + self.assert_shape_and_dtype(nodal_state, (env_mask.shape[0], self._particles_per_body), vec6f, "nodal_state") + if isinstance(nodal_state, torch.Tensor): + nodal_state = wp.from_torch(nodal_state.contiguous(), dtype=vec6f) + + for state in self._iter_particle_states(): + wp.launch( + scatter_particles_state_vec6f_mask, + dim=(env_mask.shape[0], self._particles_per_body), + inputs=[nodal_state, env_mask, self._particle_offsets], + outputs=[state.particle_q, state.particle_qd], + device=self.device, + ) + + self._invalidate_nodal_state_cache() + + def write_nodal_pos_to_sim_mask( + self, + nodal_pos: torch.Tensor | wp.array | ProxyArray, + env_mask: wp.array | torch.Tensor | None = None, + ) -> None: + """Set the nodal positions over selected environment mask into the simulation. + + Args: + nodal_pos: Nodal positions in simulation frame [m]. + Shape is (num_instances, max_sim_vertices_per_body, 3). + env_mask: Environment mask. If None, then all indices are used. + Shape is (num_instances,). + """ + env_mask = self._resolve_mask(env_mask, self._ALL_ENV_MASK) + if isinstance(nodal_pos, ProxyArray): + nodal_pos = nodal_pos.warp + self.assert_shape_and_dtype(nodal_pos, (env_mask.shape[0], self._particles_per_body), wp.vec3f, "nodal_pos") + if isinstance(nodal_pos, torch.Tensor): + nodal_pos = wp.from_torch(nodal_pos.contiguous(), dtype=wp.vec3f) + + for state in self._iter_particle_states(): + wp.launch( + scatter_particles_vec3f_mask, + dim=(env_mask.shape[0], self._particles_per_body), + inputs=[nodal_pos, env_mask, self._particle_offsets], + outputs=[state.particle_q], + device=self.device, + ) + + self._invalidate_nodal_pos_cache() + + def write_nodal_velocity_to_sim_mask( + self, + nodal_vel: torch.Tensor | wp.array | ProxyArray, + env_mask: wp.array | torch.Tensor | None = None, + ) -> None: + """Set the nodal velocity over selected environment mask into the simulation. + + Args: + nodal_vel: Nodal velocities in simulation frame [m/s]. + Shape is (num_instances, max_sim_vertices_per_body, 3). + env_mask: Environment mask. If None, then all indices are used. + Shape is (num_instances,). + """ + env_mask = self._resolve_mask(env_mask, self._ALL_ENV_MASK) + if isinstance(nodal_vel, ProxyArray): + nodal_vel = nodal_vel.warp + self.assert_shape_and_dtype(nodal_vel, (env_mask.shape[0], self._particles_per_body), wp.vec3f, "nodal_vel") + if isinstance(nodal_vel, torch.Tensor): + nodal_vel = wp.from_torch(nodal_vel.contiguous(), dtype=wp.vec3f) + + for state in self._iter_particle_states(): + wp.launch( + scatter_particles_vec3f_mask, + dim=(env_mask.shape[0], self._particles_per_body), + inputs=[nodal_vel, env_mask, self._particle_offsets], + outputs=[state.particle_qd], + device=self.device, + ) + + self._invalidate_nodal_vel_cache() + + def write_nodal_kinematic_target_to_sim_mask( + self, + targets: torch.Tensor | wp.array | ProxyArray, + env_mask: wp.array | torch.Tensor | None = None, + ) -> None: + """Set the kinematic targets over selected environment mask into the target buffer. + + Args: + targets: The kinematic targets comprising of nodal positions and flags [m]. + Shape is (num_instances, max_sim_vertices_per_body, 4). + env_mask: Environment mask. If None, then all indices are used. + Shape is (num_instances,). + """ + env_mask = self._resolve_mask(env_mask, self._ALL_ENV_MASK) + if isinstance(targets, ProxyArray): + targets = targets.warp + self.assert_shape_and_dtype(targets, (env_mask.shape[0], self._particles_per_body), wp.vec4f, "targets") + if isinstance(targets, torch.Tensor): + targets = wp.from_torch(targets.contiguous(), dtype=wp.vec4f) + + if self._data.nodal_kinematic_target is not None: + wp.launch( + write_nodal_kinematic_target_mask, + dim=(env_mask.shape[0], self._particles_per_body), + inputs=[targets, env_mask], + outputs=[self._data.nodal_kinematic_target.warp], + device=self.device, + ) + + """ + Internal helper. + """ + + def _resolve_env_ids(self, env_ids): + """Resolve environment indices to a warp int32 array.""" + if env_ids is None or (isinstance(env_ids, slice) and env_ids == slice(None)): + return self._ALL_INDICES + elif isinstance(env_ids, list): + return wp.array(env_ids, dtype=wp.int32, device=self.device) + elif isinstance(env_ids, torch.Tensor): + return wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) + return env_ids + + def _resolve_mask(self, mask: wp.array | torch.Tensor | None, full_mask: wp.array) -> wp.array: + """Resolve an environment mask to a warp bool array.""" + if mask is None: + return full_mask + if isinstance(mask, torch.Tensor): + if mask.dtype != torch.bool: + mask = mask.to(torch.bool) + return wp.from_torch(mask, dtype=wp.bool) + return mask + + def _iter_particle_states(self): + """Yield active Newton states.""" + for state in (SimulationManager.get_state_0(), SimulationManager.get_state_1()): + if state is None: + continue + yield state + + def _invalidate_nodal_pos_cache(self) -> None: + """Invalidate cached position-derived deformable data.""" + self._data._nodal_pos_w.timestamp = -1.0 + self._data._nodal_state_w.timestamp = -1.0 + self._data._root_pos_w.timestamp = -1.0 + + def _invalidate_nodal_vel_cache(self) -> None: + """Invalidate cached velocity-derived deformable data.""" + self._data._nodal_vel_w.timestamp = -1.0 + self._data._nodal_state_w.timestamp = -1.0 + self._data._root_vel_w.timestamp = -1.0 + + def _invalidate_nodal_state_cache(self) -> None: + """Invalidate all cached nodal state data.""" + self._invalidate_nodal_pos_cache() + self._invalidate_nodal_vel_cache() + + def _register_deformable(self) -> DeformableRegistryEntry: + """Read mesh from the spawned USD prim and register in NewtonManager's deformable registry. + + Returns: + The registry entry (also stored on NewtonManager._deformable_registry). + + Note: + pxr imports are deferred to this method (not module level) so that + ``resolve_task_config`` can import the env-cfg module before Kit + starts without polluting the ``pxr`` module cache. + """ + from pxr import Gf, UsdGeom, UsdShade + + # Resolve the path of the actually-spawned template prim. This must mirror + # :meth:`AssetBase.__init__`: ``spawn_path`` is set by ``InteractiveScene`` + # when the asset is part of the template-based cloning flow (the spawn + # lives at ``/World/template//proto_asset_*`` and per-env clones at + # ``/World/envs/env_*/`` are not yet authored). For Direct envs + # that spawn straight at the cloned regex, ``spawn_path`` is unset, so + # we fall back to ``prim_path`` — which already matches the spawned prim. + # The cloned-regex ``cfg.prim_path`` is still used below to build the + # registry entry's :attr:`sim_mesh_prim_path` / :attr:`vis_mesh_prim_path` + # so post-replicate consumers resolve all per-env clones. + lookup_path = ( + self.cfg.spawn.spawn_path + if self.cfg.spawn is not None and self.cfg.spawn.spawn_path is not None + else self.cfg.prim_path + ) + template_prim = sim_utils.find_first_matching_prim(lookup_path) + if template_prim is None: + raise RuntimeError(f"Failed to find prim for expression: '{lookup_path}'.") + template_prim_path = template_prim.GetPrimPath() + + # Discover sim / visual mesh prims under the template. + # The spawner authors a visual UsdGeom.Mesh and a separate simulation mesh + # (UsdGeom.TetMesh for volume, UsdGeom.Mesh for surface) with a + # ``*DeformableSimAPI`` applied, so we split candidates by that schema. + def _is_sim_mesh(prim) -> bool: + return any("DeformableSimAPI" in api for api in prim.GetAppliedSchemas()) + + tet_prims = sim_utils.get_all_matching_child_prims(template_prim_path, lambda p: p.GetTypeName() == "TetMesh") + mesh_prims = sim_utils.get_all_matching_child_prims(template_prim_path, lambda p: p.GetTypeName() == "Mesh") + + if len(tet_prims) > 1: + raise ValueError( + f"Found multiple TetMesh prims under '{template_prim_path}': " + f"{[p.GetPrimPath() for p in tet_prims]}." + " Deformable body schema supports only one simulation mesh per asset." + ) + + # Pick simulation and visual mesh prims. + if len(tet_prims) == 1: + deformable_type = "volume" + mesh_prim = tet_prims[0] + vis_candidates = [p for p in mesh_prims if not _is_sim_mesh(p)] + elif len(mesh_prims) > 0: + deformable_type = "surface" + sim_candidates = [p for p in mesh_prims if _is_sim_mesh(p)] + vis_candidates = [p for p in mesh_prims if not _is_sim_mesh(p)] + if len(sim_candidates) > 1: + raise ValueError( + f"Found multiple simulation Mesh prims under '{template_prim_path}': " + f"{[p.GetPrimPath() for p in sim_candidates]}." + " Deformable body schema supports only one simulation mesh per asset." + ) + # Fall back to the single authored Mesh when no explicit sim mesh was tagged + # (legacy / self-simulated surfaces where the visual mesh *is* the sim mesh). + mesh_prim = sim_candidates[0] if sim_candidates else vis_candidates[0] + if not sim_candidates: + vis_candidates = [] # visual == sim, no separate embedding target + else: + raise ValueError( + f"Could not find any surface or volume mesh in '{template_prim_path}'. Please check asset." + ) + + # Revert visual and simulation mesh prim paths back to template-relative form for registry storage, + # since the actual prim paths will differ per world instance after replication. + # When vis_candidates is empty the visual mesh IS the simulation mesh + # (e.g. a plain surface cloth with no separate visual embedding). + vis_mesh_prim = vis_candidates[0] if vis_candidates else mesh_prim + vis_mesh_prim_path = str(vis_mesh_prim.GetPrimPath()) + vis_mesh_prim_path = self.cfg.prim_path + vis_mesh_prim_path[len(template_prim_path.pathString) :] + sim_mesh_prim_path = str(mesh_prim.GetPrimPath()) + sim_mesh_prim_path = self.cfg.prim_path + sim_mesh_prim_path[len(template_prim_path.pathString) :] + logger.info("Registered visual UsdGeom.Mesh at %s.", vis_mesh_prim_path) + + # Bake the template prim's xform directly into the vertex positions. + xform_cache = UsdGeom.XformCache() + mesh_to_parent_frame = ( + xform_cache.GetLocalToWorldTransform(mesh_prim) + * xform_cache.GetLocalToWorldTransform(template_prim.GetParent()).GetInverse() + ) + + def _bake_points(raw_pts) -> list[wp.vec3]: + out = [] + for p in raw_pts: + q = mesh_to_parent_frame.Transform(Gf.Vec3d(float(p[0]), float(p[1]), float(p[2]))) + out.append(wp.vec3(float(q[0]), float(q[1]), float(q[2]))) + return out + + if deformable_type == "volume": + tet_mesh = UsdGeom.TetMesh(mesh_prim) + pts = tet_mesh.GetPointsAttr().Get() + vertices = _bake_points(pts) + raw_tet_indices = tet_mesh.GetTetVertexIndicesAttr().Get() + indices = [] + for vec4i in raw_tet_indices: + indices.extend([int(vec4i[0]), int(vec4i[1]), int(vec4i[2]), int(vec4i[3])]) + logger.info("Registered UsdGeom.TetMesh: %d vertices, %d tetrahedra.", len(pts), len(indices) // 4) + else: # surface + usd_mesh = UsdGeom.Mesh(mesh_prim) + pts = usd_mesh.GetPointsAttr().Get() + vertices = _bake_points(pts) + indices = list(usd_mesh.GetFaceVertexIndicesAttr().Get()) + logger.info("Registered UsdGeom.Mesh: %d vertices.", len(pts)) + + # init_pos/init_rot are already baked into the vertices by the Xform + # transform above. Setting them to identity prevents add_cloth_mesh/add_soft_mesh + # from applying them a second time. + # Note: add_deformable_entry_to_builder passes init_rot directly to + # wp.quat(x, y, z, w), so identity must be (0, 0, 0, 1) not (1, 0, 0, 0). + init_pos = (0.0, 0.0, 0.0) + init_rot = (0.0, 0.0, 0.0, 1.0) + + # Look up the bound deformable physics material + if not template_prim.HasAPI(UsdShade.MaterialBindingAPI): + raise ValueError( + f"Template prim '{template_prim_path}' must have a UsdShade.MaterialBindingAPI applied" + " with a Newton deformable physics material target." + ) + material_targets = UsdShade.MaterialBindingAPI(template_prim).GetDirectBindingRel("physics").GetTargets() + stage = template_prim.GetStage() + material_prim = None + for mat_path in material_targets: + mat_prim = stage.GetPrimAtPath(mat_path) + if mat_prim.GetAttribute("newton:density").IsValid(): + material_prim = mat_prim + break + if material_prim is None: + raise ValueError( + f"Could not find a Newton deformable physics material" + f" among the physics material targets of '{template_prim_path}'." + ) + + def _get_material_attr(name: str, default): + attr = material_prim.GetAttribute(name) + return attr.Get() if attr.IsValid() else default + + density = _get_material_attr("newton:density", DeformableRegistryEntry.density) + particle_radius = _get_material_attr("newton:particleRadius", DeformableRegistryEntry.particle_radius) + k_mu = _get_material_attr("newton:kMu", DeformableRegistryEntry.k_mu) + k_lambda = _get_material_attr("newton:kLambda", DeformableRegistryEntry.k_lambda) + k_damp = _get_material_attr("newton:kDamp", DeformableRegistryEntry.k_damp) + + tri_ke = _get_material_attr("newton:triKe", DeformableRegistryEntry.tri_ke) + tri_ka = _get_material_attr("newton:triKa", DeformableRegistryEntry.tri_ka) + tri_kd = _get_material_attr("newton:triKd", DeformableRegistryEntry.tri_kd) + edge_ke = _get_material_attr("newton:edgeKe", DeformableRegistryEntry.edge_ke) + edge_kd = _get_material_attr("newton:edgeKd", DeformableRegistryEntry.edge_kd) + + entry = DeformableRegistryEntry( + prim_path=self.cfg.prim_path, + sim_mesh_prim_path=sim_mesh_prim_path, + vis_mesh_prim_path=vis_mesh_prim_path, + vertices=vertices, + indices=indices, + deformable_type=deformable_type, + init_pos=init_pos, + init_rot=init_rot, + density=density, + tri_ke=tri_ke, + tri_ka=tri_ka, + tri_kd=tri_kd, + edge_ke=edge_ke, + edge_kd=edge_kd, + particle_radius=particle_radius, + k_mu=k_mu, + k_lambda=k_lambda, + k_damp=k_damp, + ) + SimulationManager._deformable_registry.append(entry) + self._deformable_type = deformable_type + return entry + + def _initialize_impl(self): + """Initialize physics handles and buffers after the Newton model is ready.""" + entry = self._registry_entry + self._num_instances = len(entry.particle_offsets) + self._particles_per_body = entry.particles_per_body + self._recorded_particle_offsets = entry.particle_offsets + + if self._num_instances == 0: + raise RuntimeError( + f"No deformable body instances found for '{self.cfg.prim_path}'. " + "Ensure newton_physics_replicate or MODEL_INIT processed the registry." + ) + + logger.info("Newton deformable object initialized at: %s", self.cfg.prim_path) + logger.info("Number of instances: %d", self._num_instances) + logger.info("Particles per body: %d", self._particles_per_body) + + # Build particle offset array on device + self._particle_offsets = wp.array(self._recorded_particle_offsets, dtype=wp.int32, device=self.device) + + # Create data container + self._data = DeformableObjectData( + particle_offsets=self._particle_offsets, + particles_per_body=self._particles_per_body, + num_instances=self._num_instances, + device=self.device, + ) + + # Create buffers + self._create_buffers() + + # Update data once + self.update(0.0) + + # Register rebind callback for full resets + self._physics_ready_handle = SimulationManager.register_callback( + lambda _: self._data._create_simulation_bindings(), + PhysicsEvent.PHYSICS_READY, + name=f"deformable_object_rebind_{self.cfg.prim_path}", + ) + + def _create_buffers(self): + """Create buffers for storing data.""" + # Constants + self._ALL_INDICES = wp.array(np.arange(self._num_instances, dtype=np.int32), device=self.device) + self._ALL_ENV_MASK = wp.ones((self._num_instances,), dtype=wp.bool, device=self.device) + + # Snapshot default positions from current state (after finalize + FK) + state = SimulationManager.get_state_0() + if state is not None and state.particle_q is not None: + from .kernels import gather_particles_vec3f + + self._default_nodal_pos_w = wp.zeros( + (self._num_instances, self._particles_per_body), dtype=wp.vec3f, device=self.device + ) + wp.launch( + gather_particles_vec3f, + dim=(self._num_instances, self._particles_per_body), + inputs=[state.particle_q, self._particle_offsets, self._particles_per_body], + outputs=[self._default_nodal_pos_w], + device=self.device, + ) + + # Compute default nodal state as vec6f (positions + zero velocities) + nodal_velocities = wp.zeros( + (self._num_instances, self._particles_per_body), dtype=wp.vec3f, device=self.device + ) + default_nodal_state_w = wp.zeros( + (self._num_instances, self._particles_per_body), dtype=vec6f, device=self.device + ) + wp.launch( + compute_nodal_state_w, + dim=(self._num_instances, self._particles_per_body), + inputs=[self._default_nodal_pos_w, nodal_velocities], + outputs=[default_nodal_state_w], + device=self.device, + ) + self._data.default_nodal_state_w = ProxyArray(default_nodal_state_w) + else: + self._default_nodal_pos_w = None + + # Snapshot default particle_inv_mass for kinematic target restoration + model = SimulationManager.get_model() + if model is not None and hasattr(model, "particle_inv_mass") and model.particle_inv_mass is not None: + self._default_particle_inv_mass = wp.clone(model.particle_inv_mass) + else: + self._default_particle_inv_mass = None + if model is not None and hasattr(model, "particle_flags") and model.particle_flags is not None: + self._default_particle_flags = wp.clone(model.particle_flags) + else: + self._default_particle_flags = None + + # Kinematic targets -- allocate and initialize with free flags + nodal_kinematic_target = wp.zeros( + (self._num_instances, self._particles_per_body), dtype=wp.vec4f, device=self.device + ) + wp.launch( + set_kinematic_flags_to_one, + dim=(self._num_instances * self._particles_per_body,), + inputs=[nodal_kinematic_target.reshape((self._num_instances * self._particles_per_body,))], + device=self.device, + ) + self._data.nodal_kinematic_target = ProxyArray(nodal_kinematic_target) + + # Set up the model parameters + model = SimulationManager.get_model() + if model is not None: + if hasattr(model, "edge_rest_angle"): + model.edge_rest_angle.zero_() + + """ + Internal simulation callbacks. + """ + + def _set_debug_vis_impl(self, debug_vis: bool): + if debug_vis: + if not hasattr(self, "target_visualizer"): + self.target_visualizer = VisualizationMarkers(self.cfg.visualizer_cfg) + self.target_visualizer.set_visibility(True) + else: + if hasattr(self, "target_visualizer"): + self.target_visualizer.set_visibility(False) + + def _debug_vis_callback(self, event): + num_enabled = 0 + if self._deformable_type == "volume": + kinematic_target_torch = self.data.nodal_kinematic_target.torch + targets_enabled = kinematic_target_torch[:, :, 3] == 0.0 + num_enabled = int(torch.sum(targets_enabled).item()) + if num_enabled == 0: + positions = torch.tensor([[0.0, 0.0, -10.0]], device=self.device) + else: + positions = kinematic_target_torch[targets_enabled][..., :3] + self.target_visualizer.visualize(positions) + + def _clear_callbacks(self) -> None: + """Clears all registered callbacks.""" + super()._clear_callbacks() + if hasattr(self, "_physics_ready_handle") and self._physics_ready_handle is not None: + self._physics_ready_handle.deregister() + self._physics_ready_handle = None + + def _invalidate_initialize_callback(self, event): + """Invalidates the scene elements.""" + super()._invalidate_initialize_callback(event) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object_data.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object_data.py new file mode 100644 index 000000000000..d516f1fc76d9 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object_data.py @@ -0,0 +1,206 @@ +# 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 warp as wp +from isaaclab_newton.physics import NewtonManager as SimulationManager + +from isaaclab.assets.deformable_object.base_deformable_object_data import BaseDeformableObjectData +from isaaclab.utils.buffers import TimestampedBufferWarp as TimestampedBuffer +from isaaclab.utils.warp import ProxyArray + +from .kernels import compute_mean_vec3f_over_vertices, compute_nodal_state_w, gather_particles_vec3f, vec6f + + +class DeformableObjectData(BaseDeformableObjectData): + """Data container for a deformable object (Newton backend). + + Newton stores all particles in flat arrays (``model.particle_q``, ``state.particle_qd``). + This data class builds a per-instance view by gathering from the flat arrays using + precomputed offsets. + + The data is lazily updated, meaning that the data is only updated when it is accessed. + """ + + def __init__( + self, + particle_offsets: wp.array, + particles_per_body: int, + num_instances: int, + device: str, + ): + """Initialize the Newton deformable object data. + + Args: + particle_offsets: Per-instance start offset into the flat particle array. + Shape is (num_instances,) with dtype int32. + particles_per_body: Number of particles per deformable body instance. + num_instances: Number of deformable body instances. + device: The device used for processing. + """ + super().__init__(device) + + # Store dimensions and indexing + self._particle_offsets = particle_offsets + self._particles_per_body = particles_per_body + self._num_instances = num_instances + + # Initialize lazy buffers + self._nodal_pos_w = TimestampedBuffer((num_instances, particles_per_body), device, wp.vec3f) + self._nodal_vel_w = TimestampedBuffer((num_instances, particles_per_body), device, wp.vec3f) + self._nodal_state_w = TimestampedBuffer((num_instances, particles_per_body), device, vec6f) + self._root_pos_w = TimestampedBuffer((num_instances,), device, wp.vec3f) + self._root_vel_w = TimestampedBuffer((num_instances,), device, wp.vec3f) + self._nodal_pos_w_ta: ProxyArray | None = None + self._nodal_vel_w_ta: ProxyArray | None = None + self._nodal_state_w_ta: ProxyArray | None = None + self._root_pos_w_ta: ProxyArray | None = None + self._root_vel_w_ta: ProxyArray | None = None + + self._create_simulation_bindings() + + ## + # Defaults. + ## + + default_nodal_state_w: ProxyArray = None + """Default nodal state ``[nodal_pos, nodal_vel]`` in simulation world frame. + Shape is (num_instances, particles_per_body) with dtype vec6f. + """ + + ## + # Kinematic commands. + ## + + nodal_kinematic_target: ProxyArray = None + """Simulation mesh kinematic targets for the deformable bodies. + Shape is (num_instances, particles_per_body) with dtype vec4f. + """ + + def _create_simulation_bindings(self) -> None: + """Validate the current Newton particle state and invalidate gathered buffers. + + Newton may swap :attr:`state_0` and :attr:`state_1` across substeps, so deformable data does not keep + long-lived particle array bindings. Read properties query :meth:`SimulationManager.get_state_0` at gather time + and materialize object-local views from the current flat particle arrays. + """ + self._get_current_particle_state() + + # Invalidate lazy buffers gathered from the previous simulation state. + self._nodal_pos_w.timestamp = -1.0 + self._nodal_vel_w.timestamp = -1.0 + self._nodal_state_w.timestamp = -1.0 + self._root_pos_w.timestamp = -1.0 + self._root_vel_w.timestamp = -1.0 + + def _get_current_particle_state(self): + """Return the current Newton state containing deformable particle arrays.""" + state = SimulationManager.get_state_0() + if state is None or state.particle_q is None or state.particle_qd is None: + raise RuntimeError( + "Failed to access Newton deformable particle state. Ensure the Newton model has been finalized and " + "contains particle position and velocity arrays." + ) + return state + + ## + # Properties. + ## + + @property + def nodal_pos_w(self) -> ProxyArray: + """Nodal positions in simulation world frame [m]. Shape is (num_instances, particles_per_body) vec3f.""" + if self._nodal_pos_w.timestamp < self._sim_timestamp: + state = self._get_current_particle_state() + wp.launch( + gather_particles_vec3f, + dim=(self._num_instances, self._particles_per_body), + inputs=[state.particle_q, self._particle_offsets, self._particles_per_body], + outputs=[self._nodal_pos_w.data], + device=self.device, + ) + self._nodal_pos_w.timestamp = self._sim_timestamp + if self._nodal_pos_w_ta is None: + self._nodal_pos_w_ta = ProxyArray(self._nodal_pos_w.data) + return self._nodal_pos_w_ta + + @property + def nodal_vel_w(self) -> ProxyArray: + """Nodal velocities in simulation world frame [m/s]. Shape is (num_instances, particles_per_body) vec3f.""" + if self._nodal_vel_w.timestamp < self._sim_timestamp: + state = self._get_current_particle_state() + wp.launch( + gather_particles_vec3f, + dim=(self._num_instances, self._particles_per_body), + inputs=[state.particle_qd, self._particle_offsets, self._particles_per_body], + outputs=[self._nodal_vel_w.data], + device=self.device, + ) + self._nodal_vel_w.timestamp = self._sim_timestamp + if self._nodal_vel_w_ta is None: + self._nodal_vel_w_ta = ProxyArray(self._nodal_vel_w.data) + return self._nodal_vel_w_ta + + @property + def nodal_state_w(self) -> ProxyArray: + """Nodal state ``[nodal_pos, nodal_vel]`` in simulation world frame [m, m/s]. + + Shape is (num_instances, particles_per_body) vec6f. + """ + if self._nodal_state_w.timestamp < self._sim_timestamp: + wp.launch( + compute_nodal_state_w, + dim=(self._num_instances, self._particles_per_body), + inputs=[self.nodal_pos_w.warp, self.nodal_vel_w.warp], + outputs=[self._nodal_state_w.data], + device=self.device, + ) + self._nodal_state_w.timestamp = self._sim_timestamp + if self._nodal_state_w_ta is None: + self._nodal_state_w_ta = ProxyArray(self._nodal_state_w.data) + return self._nodal_state_w_ta + + ## + # Derived properties. + ## + + @property + def root_pos_w(self) -> ProxyArray: + """Root position from nodal positions [m]. Shape is (num_instances,) vec3f. + + This quantity is computed as the mean of the nodal positions. + """ + if self._root_pos_w.timestamp < self._sim_timestamp: + wp.launch( + compute_mean_vec3f_over_vertices, + dim=(self._num_instances,), + inputs=[self.nodal_pos_w.warp, self._particles_per_body], + outputs=[self._root_pos_w.data], + device=self.device, + ) + self._root_pos_w.timestamp = self._sim_timestamp + if self._root_pos_w_ta is None: + self._root_pos_w_ta = ProxyArray(self._root_pos_w.data) + return self._root_pos_w_ta + + @property + def root_vel_w(self) -> ProxyArray: + """Root velocity from nodal velocities [m/s]. Shape is (num_instances,) vec3f. + + This quantity is computed as the mean of the nodal velocities. + """ + if self._root_vel_w.timestamp < self._sim_timestamp: + wp.launch( + compute_mean_vec3f_over_vertices, + dim=(self._num_instances,), + inputs=[self.nodal_vel_w.warp, self._particles_per_body], + outputs=[self._root_vel_w.data], + device=self.device, + ) + self._root_vel_w.timestamp = self._sim_timestamp + if self._root_vel_w_ta is None: + self._root_vel_w_ta = ProxyArray(self._root_vel_w.data) + return self._root_vel_w_ta diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py new file mode 100644 index 000000000000..179f62e53367 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py @@ -0,0 +1,384 @@ +# 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 Newton deformable object gather/scatter operations.""" + +import warp as wp +from newton._src.solvers.vbd.rigid_vbd_kernels import ( + evaluate_body_particle_contact as _evaluate_body_particle_contact, +) + +vec6f = wp.types.vector(length=6, dtype=wp.float32) + + +@wp.kernel +def gather_particles_vec3f( + src: wp.array(dtype=wp.vec3f), + offsets: wp.array(dtype=wp.int32), + num_particles: int, + dst: wp.array2d(dtype=wp.vec3f), +): + """Gather particle data from a flat array into a per-instance 2D array. + + Args: + src: Flat source particle array (all instances concatenated). Shape is (total_particles,). + offsets: Per-instance start offset into the flat array. Shape is (num_instances,). + num_particles: Number of particles per instance. + dst: Output 2D array. Shape is (num_instances, num_particles). + """ + i, j = wp.tid() + dst[i, j] = src[offsets[i] + j] + + +@wp.kernel +def scatter_particles_vec3f_index( + src: wp.array2d(dtype=wp.vec3f), + env_ids: wp.array(dtype=wp.int32), + offsets: wp.array(dtype=wp.int32), + full_data: bool, + dst: wp.array(dtype=wp.vec3f), +): + """Scatter per-instance particle data into the flat simulation array using indices. + + Args: + src: Input 2D array. Shape is (len(env_ids), num_particles) or (num_instances, num_particles). + env_ids: Environment indices to scatter to. Shape is (num_selected,). + offsets: Per-instance start offset into the flat array. Shape is (num_instances,). + full_data: If True, index src with env_ids[i]; otherwise index src with i. + dst: Flat destination particle array. Shape is (total_particles,). + """ + i, j = wp.tid() + env_id = env_ids[i] + if full_data: + dst[offsets[env_id] + j] = src[env_id, j] + else: + dst[offsets[env_id] + j] = src[i, j] + + +@wp.kernel +def scatter_particles_vec3f_mask( + src: wp.array2d(dtype=wp.vec3f), + env_mask: wp.array(dtype=wp.bool), + offsets: wp.array(dtype=wp.int32), + dst: wp.array(dtype=wp.vec3f), +): + """Scatter per-instance particle data into the flat simulation array using a mask. + + Args: + src: Input particle data [m] or [m/s]. Shape is (num_instances, num_particles). + env_mask: Environment mask. Shape is (num_instances,). + offsets: Per-instance start offset into the flat array. Shape is (num_instances,). + dst: Flat destination particle array. Shape is (total_particles,). + """ + i, j = wp.tid() + if env_mask[i]: + dst[offsets[i] + j] = src[i, j] + + +@wp.kernel +def scatter_particles_state_vec6f_mask( + src: wp.array2d(dtype=vec6f), + env_mask: wp.array(dtype=wp.bool), + offsets: wp.array(dtype=wp.int32), + particle_q: wp.array(dtype=wp.vec3f), + particle_qd: wp.array(dtype=wp.vec3f), +): + """Scatter per-instance nodal state into the flat simulation arrays using a mask. + + Args: + src: Input nodal state data [m, m/s]. Shape is (num_instances, num_particles). + env_mask: Environment mask. Shape is (num_instances,). + offsets: Per-instance start offset into the flat arrays. Shape is (num_instances,). + particle_q: Flat destination particle positions [m]. Shape is (total_particles,). + particle_qd: Flat destination particle velocities [m/s]. Shape is (total_particles,). + """ + i, j = wp.tid() + if env_mask[i]: + state = src[i, j] + flat_idx = offsets[i] + j + particle_q[flat_idx] = wp.vec3f(state[0], state[1], state[2]) + particle_qd[flat_idx] = wp.vec3f(state[3], state[4], state[5]) + + +@wp.kernel +def write_nodal_kinematic_target_mask( + src: wp.array2d(dtype=wp.vec4f), + env_mask: wp.array(dtype=wp.bool), + dst: wp.array2d(dtype=wp.vec4f), +): + """Write kinematic target data into the per-instance target buffer using a mask. + + Args: + src: Input kinematic targets [m]. Shape is (num_instances, num_particles). + env_mask: Environment mask. Shape is (num_instances,). + dst: Destination kinematic target buffer [m]. Shape is (num_instances, num_particles). + """ + i, j = wp.tid() + if env_mask[i]: + dst[i, j] = src[i, j] + + +@wp.kernel +def write_nodal_kinematic_target_index( + src: wp.array2d(dtype=wp.vec4f), + env_ids: wp.array(dtype=wp.int32), + full_data: bool, + dst: wp.array2d(dtype=wp.vec4f), +): + """Write kinematic target data into the per-instance target buffer using indices. + + Args: + src: Input kinematic targets [m]. Shape is (len(env_ids), num_particles) + or (num_instances, num_particles). + env_ids: Environment indices to write. Shape is (num_selected,). + full_data: If True, index src with env_ids[i]; otherwise index src with i. + dst: Destination kinematic target buffer [m]. Shape is (num_instances, num_particles). + """ + i, j = wp.tid() + env_id = env_ids[i] + if full_data: + dst[env_id, j] = src[env_id, j] + else: + dst[env_id, j] = src[i, j] + + +@wp.kernel +def compute_nodal_state_w( + nodal_pos: wp.array2d(dtype=wp.vec3f), + nodal_vel: wp.array2d(dtype=wp.vec3f), + nodal_state: wp.array2d(dtype=vec6f), +): + """Concatenate nodal positions and velocities into a 6-element state vector. + + Args: + nodal_pos: Input array of nodal positions. Shape is (num_instances, num_vertices). + nodal_vel: Input array of nodal velocities. Shape is (num_instances, num_vertices). + nodal_state: Output array where concatenated state vectors are written. + Shape is (num_instances, num_vertices). + """ + i, j = wp.tid() + p = nodal_pos[i, j] + v = nodal_vel[i, j] + nodal_state[i, j] = vec6f(p[0], p[1], p[2], v[0], v[1], v[2]) + + +@wp.kernel +def compute_mean_vec3f_over_vertices( + data: wp.array2d(dtype=wp.vec3f), + num_vertices: int, + result: wp.array(dtype=wp.vec3f), +): + """Compute the mean of vec3f data over the vertex dimension. + + Args: + data: Input array of vec3f data. Shape is (num_instances, num_vertices). + num_vertices: Number of vertices per instance. + result: Output array where mean values are written. Shape is (num_instances,). + """ + i = wp.tid() + acc = wp.vec3f(0.0, 0.0, 0.0) + for j in range(num_vertices): + acc = acc + data[i, j] + result[i] = acc / float(num_vertices) + + +@wp.kernel +def scatter_zero_vel_index( + env_ids: wp.array(dtype=wp.int32), + offsets: wp.array(dtype=wp.int32), + num_particles: int, + dst: wp.array(dtype=wp.vec3f), +): + """Zero the velocity of particles for selected environments. + + Args: + env_ids: Environment indices to zero velocities for. Shape is (num_selected,). + offsets: Per-instance start offset into the flat array. Shape is (num_instances,). + num_particles: Number of particles per instance. + dst: Flat destination velocity array. Shape is (total_particles,). + """ + i, j = wp.tid() + env_id = env_ids[i] + dst[offsets[env_id] + j] = wp.vec3f(0.0, 0.0, 0.0) + + +@wp.kernel +def scatter_default_pos_index( + default_pos: wp.array2d(dtype=wp.vec3f), + env_ids: wp.array(dtype=wp.int32), + offsets: wp.array(dtype=wp.int32), + dst: wp.array(dtype=wp.vec3f), +): + """Scatter default positions for selected environments into the flat simulation array. + + Args: + default_pos: Default positions per instance. Shape is (num_instances, num_particles). + env_ids: Environment indices to reset. Shape is (num_selected,). + offsets: Per-instance start offset into the flat array. Shape is (num_instances,). + dst: Flat destination particle array. Shape is (total_particles,). + """ + i, j = wp.tid() + env_id = env_ids[i] + dst[offsets[env_id] + j] = default_pos[env_id, j] + + +@wp.kernel +def set_kinematic_flags_to_one( + data: wp.array(dtype=wp.vec4f), +): + """Set the w-component (kinematic flag) of all vec4f entries to 1.0. + + This is used to initialize all vertices as non-kinematic (free) nodes. + + Args: + data: Input/output array of vec4f kinematic targets. Shape is (N*V,). + """ + i = wp.tid() + v = data[i] + data[i] = wp.vec4f(v[0], v[1], v[2], 1.0) + + +@wp.kernel +def enforce_kinematic_targets( + targets: wp.array2d(dtype=wp.vec4f), + offsets: wp.array(dtype=wp.int32), + default_inv_mass: wp.array(dtype=wp.float32), + default_flags: wp.array(dtype=wp.int32), + particle_q: wp.array(dtype=wp.vec3f), + particle_qd: wp.array(dtype=wp.vec3f), + particle_inv_mass: wp.array(dtype=wp.float32), + particle_flags: wp.array(dtype=wp.int32), +): + """Enforce kinematic targets on Newton particles. + + For each particle, reads the kinematic target flag (w-component): + - flag == 0.0 (kinematic): set inv_mass to 0, particle_flags to 0, write target position, zero velocity. + - flag != 0.0 (free): restore the default inv_mass and set particle_flags to 1 (ACTIVE). + + Args: + targets: Per-instance kinematic targets. Shape is (num_instances, particles_per_body). + Each vec4f contains (target_x, target_y, target_z, flag). + offsets: Per-instance start offset into the flat particle array. + default_inv_mass: Saved default inverse masses. Shape is (total_particles,). + default_flags: Saved default particle flags. Shape is (total_particles,). + particle_q: Flat particle positions to write. Shape is (total_particles,). + particle_qd: Flat particle velocities to write. Shape is (total_particles,). + particle_inv_mass: Flat particle inverse masses to write. Shape is (total_particles,). + particle_flags: Flat particle flags to write. Shape is (total_particles,). + 0 = kinematic (solver skips integration), 1 = ACTIVE. + """ + i, j = wp.tid() + t = targets[i, j] + flat_idx = offsets[i] + j + flag = t[3] + if flag == 0.0: + particle_inv_mass[flat_idx] = 0.0 + particle_flags[flat_idx] = 0 + particle_q[flat_idx] = wp.vec3f(t[0], t[1], t[2]) + particle_qd[flat_idx] = wp.vec3f(0.0, 0.0, 0.0) + else: + particle_inv_mass[flat_idx] = default_inv_mass[flat_idx] + particle_flags[flat_idx] = default_flags[flat_idx] + + +@wp.kernel +def _kernel_body_particle_reaction( + contact_count: wp.array(dtype=wp.int32), + contact_particle: wp.array(dtype=wp.int32), + contact_shape: wp.array(dtype=wp.int32), + contact_body_pos: wp.array(dtype=wp.vec3), + contact_body_vel: wp.array(dtype=wp.vec3), + contact_normal: wp.array(dtype=wp.vec3), + particle_q: wp.array(dtype=wp.vec3), + particle_qd: wp.array(dtype=wp.vec3), + particle_radius: wp.array(dtype=wp.float32), + body_q: wp.array(dtype=wp.transform), + body_q_prev: wp.array(dtype=wp.transform), + body_qd: wp.array(dtype=wp.spatial_vector), + body_com: wp.array(dtype=wp.vec3), + shape_body: wp.array(dtype=wp.int32), + shape_material_mu: wp.array(dtype=wp.float32), + soft_contact_ke: float, + soft_contact_kd: float, + soft_contact_mu: float, + friction_epsilon: float, + dt: float, + body_f: wp.array(dtype=wp.spatial_vector), +): + """Newton's-third-law reaction from soft particles onto rigid bodies. + + Delegates to Newton's ``evaluate_body_particle_contact()`` for the contact + force computation (normal + damping + Coulomb friction) so the model stays + in sync with the VBD solver. The force on the particle is negated and + applied as a wrench on the rigid body via ``body_f``. + + One thread per contact slot; threads beyond the actual contact count + early-exit. + + The "previous" particle position required by the contact model is + reconstructed from the current velocity (``particle_q - particle_qd * dt``) + rather than read from a stored previous-state array. VBD mutates + ``particle_q`` in place during its iteration, so the swapped state's + ``particle_q`` is no longer a reliable snapshot of the prior substep. + """ + tid = wp.tid() + if tid >= contact_count[0]: + return + + p_idx = contact_particle[tid] + s_idx = contact_shape[tid] + body_idx = shape_body[s_idx] + if body_idx < 0: + return + + # Reconstruct previous particle position from velocity so that + # dx = particle_qd * dt regardless of what VBD wrote into stored states. + p_pos = particle_q[p_idx] + p_pos_prev = p_pos - particle_qd[p_idx] * dt + + # Delegate to Newton's canonical contact model + f_on_particle, _ = _evaluate_body_particle_contact( + p_idx, + p_pos, + p_pos_prev, + tid, + soft_contact_ke, + soft_contact_kd, + soft_contact_mu, + friction_epsilon, + particle_radius, + shape_material_mu, + shape_body, + body_q, + body_q_prev, + body_qd, + body_com, + contact_shape, + contact_body_pos, + contact_body_vel, + contact_normal, + dt, + ) + + # Newton's third law: negate particle force → rigid body wrench + X_wb = body_q[body_idx] + bx = wp.transform_point(X_wb, contact_body_pos[tid]) + com_w = wp.transform_point(X_wb, body_com[body_idx]) + + reaction = -f_on_particle + torque = wp.cross(bx - com_w, reaction) + + wp.atomic_add( + body_f, + body_idx, + wp.spatial_vector( + reaction[0], + reaction[1], + reaction[2], + torque[0], + torque[1], + torque[2], + ), + ) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py new file mode 100644 index 000000000000..e9358ff0be23 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py @@ -0,0 +1,211 @@ +# 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 + +"""Configuration classes for VBD, coupled solver, and global Newton model parameters.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from isaaclab_newton.physics import FeatherstoneSolverCfg, MJWarpSolverCfg, NewtonSolverCfg + +from isaaclab.utils.configclass import configclass + +if TYPE_CHECKING: + from isaaclab_newton.physics import NewtonManager + + +@configclass +class VBDSolverCfg(NewtonSolverCfg): + """Configuration for the Vertex Block Descent (VBD) solver. + + Supports particle simulation (cloth, soft bodies) and coupled rigid-body systems. + Requires ``ModelBuilder.color()`` to be called before ``finalize()`` to build + the parallel vertex colouring needed by the solver. + """ + + class_type: type[NewtonManager] | str = "{DIR}.vbd_manager:NewtonVBDManager" + """Manager class for the VBD solver.""" + + solver_type: str = "vbd" + + iterations: int = 10 + """Number of VBD iterations per substep.""" + + integrate_with_external_rigid_solver: bool = False + """Whether rigid bodies are integrated by an external solver (one-way coupling). + + Set to ``True`` when coupling cloth with a separate rigid-body solver + (e.g. ``SolverFeatherstone``) so that VBD only integrates the cloth particles. + """ + + particle_enable_self_contact: bool = False + """Whether to enable VBD deformable's self-contact.""" + + particle_self_contact_radius: float = 0.005 + """Particle radius used for self-contact detection [m].""" + + particle_self_contact_margin: float = 0.005 + """Self-contact detection margin [m]. Should be >= particle_self_contact_radius.""" + + particle_collision_detection_interval: int = -1 + """Controls how frequently particle self-contact detection is applied. + + If set to a value < 0, collision detection is only performed once before the + initialization step. If set to 0, collision detection is applied twice: once + before and once immediately after initialization. If set to a value ``k`` >= 1, + collision detection is applied before every ``k`` VBD iterations. + """ + + particle_vertex_contact_buffer_size: int = 32 + """Preallocation size for each vertex's vertex-triangle collision buffer.""" + + particle_edge_contact_buffer_size: int = 64 + """Preallocation size for each edge's edge-edge collision buffer.""" + + particle_topological_contact_filter_threshold: int = 2 + """Maximum topological distance (in rings) below which self-contacts are discarded. + + Only used when ``particle_enable_self_contact`` is ``True``. + Increase to suppress contacts between closely connected mesh elements. + Values > 3 significantly increase computation time. + """ + + particle_rest_shape_contact_exclusion_radius: float = 0.0 + """World-space distance threshold for filtering topologically close primitives [m]. + + Candidate self-contacts whose rest-configuration separation is shorter than + this value are ignored. Only used when ``particle_enable_self_contact`` is ``True``. + """ + + rigid_contact_k_start: float = 1.0e2 + """Initial stiffness seed for all rigid body contacts (body-body and body-particle) [N/m]. + + Used by the AVBD rigid contact solver. Increase to make rigid contacts stiffer. + """ + + +@configclass +class CoupledMJWarpVBDSolverCfg(NewtonSolverCfg): + """Configuration for the coupled rigid-body MJWarp + VBD solver. + + Alternates a rigid-body solver (:class:`MJWarpSolverCfg`) and a soft-body solver (:class:`SolverVBD`) per + substep. The coupling direction is controlled by :attr:`coupling_mode`: + + - ``"one_way"`` (default): Rigid solver advances first, then VBD reads + the updated body poses. The rigid solver does not feel particle contacts. + - ``"two_way"``: Same-substep two-way coupling with normal + Coulomb + friction. Contact detection runs first, reaction forces are injected + into ``body_f``, then the rigid solver reads ``body_f`` and feels + resistance from the deformable object. The friction reaction lets + actuators carry the object against gravity during a lift. + """ + + class_type: type[NewtonManager] | str = "{DIR}.coupled_mjwarp_vbd_manager:NewtonCoupledMJWarpVBDManager" + """Manager class for the VBD solver.""" + + solver_type: str = "coupledmjwarpvbd" + + rigid_solver_cfg: MJWarpSolverCfg = MJWarpSolverCfg() + """Rigid-body sub-solver configuration for :class:`MJWarpSolverCfg`.""" + + soft_solver_cfg: VBDSolverCfg = VBDSolverCfg(integrate_with_external_rigid_solver=True) + """VBD sub-solver configuration for cloth/particle dynamics.""" + + coupling_mode: str = "two_way" + """Coupling direction between the rigid and VBD solvers. + + - ``"one_way"``: Rigid -> soft only (default, existing behavior). + - ``"two_way"``: Same-substep two-way coupling with normal + Coulomb friction. + """ + + +@configclass +class CoupledFeatherstoneVBDSolverCfg(NewtonSolverCfg): + """Configuration for the coupled rigid-body Featherstone + VBD solver. + + Alternates a rigid-body solver (:class:`FeatherstoneSolverCfg`) and a soft-body solver (:class:`SolverVBD`) per + substep. The coupling direction is controlled by :attr:`coupling_mode`: + + - ``"kinematic"`` (default): Rigid -> soft only. Rigid bodies are kinematically updated by the rigid solver, + then VBD reads the updated body poses and reacts to them. The rigid solver does not feel particle contacts. + - ``"one_way"``: Rigid solver advances first, then VBD reads + the updated body poses. The rigid solver does not feel particle contacts. + - ``"two_way"``: Same-substep two-way coupling with normal + Coulomb + friction. Contact detection runs first, reaction forces are injected + into ``body_f``, then the rigid solver reads ``body_f`` and feels + resistance from the deformable object. The friction reaction lets + actuators carry the object against gravity during a lift. + """ + + class_type: type[NewtonManager] | str = "{DIR}.coupled_featherstone_vbd_manager:NewtonCoupledFeatherstoneVBDManager" + """Manager class for the VBD solver.""" + + solver_type: str = "coupledfeatherstonevbd" + + rigid_solver_cfg: FeatherstoneSolverCfg = FeatherstoneSolverCfg() + """Rigid-body sub-solver configuration for :class:`FeatherstoneSolverCfg`.""" + + soft_solver_cfg: VBDSolverCfg = VBDSolverCfg(integrate_with_external_rigid_solver=True) + """VBD sub-solver configuration for cloth/particle dynamics.""" + + coupling_mode: str = "kinematic" + """Coupling direction between the rigid and VBD solvers. + + - ``"kinematic"``: Rigid -> soft only (default) + - ``"one_way"``: Rigid -> soft only (existing behavior). + - ``"two_way"``: Same-substep two-way coupling with normal + Coulomb friction. + """ + + +@configclass +class NewtonModelCfg: + """Global Newton model parameters. + + These parameters are applied to the ``newton.Model`` after finalization. + They control model-level contact behavior shared across all objects. + """ + + soft_contact_ke: float = 1.0e3 + """Body-particle contact stiffness [N/m]. + + Controls the stiffness of the penalty force of contacts between cloth/soft-body particles + and rigid body shapes, and self-contacts of cloth/soft-body particles. The effective stiffness per contact is the + average of this value and the rigid shape's material stiffness. + """ + + soft_contact_kd: float = 1.0e-2 + """Body-particle contact damping [N*s/m].""" + + soft_contact_mu: float = 0.5 + """Body-particle contact friction coefficient. + + The effective friction per contact is ``sqrt(soft_contact_mu * shape_material_mu)``. + Increase for better grip (e.g. gripper picking up cloth). + """ + + shape_material_ke: float | None = None + """Per-shape contact stiffness override [N/m]. + + When set, all collision shapes in the model will have their contact + stiffness overwritten to this value. If ``None`` (default), the + per-shape values parsed from USD/MJCF are kept. + """ + + shape_material_kd: float | None = None + """Per-shape contact damping override [N*s/m]. + + When set, all collision shapes in the model will have their contact + damping overwritten to this value. If ``None`` (default), the + per-shape values parsed from USD/MJCF are kept. + """ + + shape_material_mu: float | None = None + """Per-shape friction coefficient override [dimensionless]. + + When set, all collision shapes in the model will have their friction + coefficient overwritten to this value. If ``None`` (default), the + per-shape values parsed from USD/MJCF are kept. + """ diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py new file mode 100644 index 000000000000..88380f078673 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -0,0 +1,285 @@ +# 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 + +"""VBD Newton manager.""" + +from __future__ import annotations + +import inspect +import logging +from typing import TYPE_CHECKING + +import warp as wp +from isaaclab_newton.physics.newton_manager import NewtonManager +from newton import Model, ModelBuilder +from newton._src.usd.schemas import SchemaResolverNewton, SchemaResolverPhysx +from newton.solvers import SolverVBD + +from isaaclab.sim.utils.stage import get_current_stage + +from .deformable_object import ( + add_deformable_entry_to_builder, + clear_deformable_builder_hooks, + install_deformable_builder_hooks, +) +from .newton_manager_cfg import VBDSolverCfg + +if TYPE_CHECKING: + from isaaclab.sim.simulation_context import SimulationContext + +logger = logging.getLogger(__name__) + + +class NewtonVBDManager(NewtonManager): + """:class:`NewtonManager` specialization for the VBD solver. + + Always uses Newton's :class:`CollisionPipeline` for contact handling. + """ + + @classmethod + def initialize(cls, sim_context: SimulationContext) -> None: + """Initialize the manager with simulation context. + + Args: + sim_context: Parent simulation context. + + TODO: Subclass should not override this method, once deformables + supported on Newton import_usd, this can be unified with NewtonManager's + implementation. + """ + + # Deformable body registry and extension hooks. + # Experimental deformable support registers callbacks here so the manager + # and cloner can invoke them without hard-coding deformable logic. + install_deformable_builder_hooks() + + super().initialize(sim_context) + + @classmethod + def _solver_specific_clear(cls): + """Clear VBD-specific state.""" + clear_deformable_builder_hooks() + + @classmethod + def _get_deformable_ignore_paths(cls) -> list[str]: + """Return USD prim paths to skip when calling ``builder.add_usd``. + + For each registered deformable body, both the simulation mesh (which + carries ``UsdPhysics.CollisionAPI``) and the visual mesh are returned. + The sim mesh must be skipped so Newton does not create a redundant + static mesh collider alongside the particles produced by + ``add_soft_mesh``. The visual mesh is skipped so Newton does not + treat it as a collider — Kit reads it directly from USD for rendering. + + Paths may contain regex patterns; Newton's ``add_usd`` matches them + via :func:`re.match`. + """ + paths: list[str] = [] + for entry in cls._deformable_registry: + paths.append(entry.sim_mesh_prim_path) + paths.append(entry.vis_mesh_prim_path) + return paths + + @classmethod + def start_simulation(cls) -> None: + """Start simulation by finalizing model and initializing state. + + This function finalizes the model and initializes the simulation state. + Note: Collision pipeline is initialized later in initialize_solver() after + we determine whether the solver needs external collision detection. + + TODO: Subclass should not override this method, missing piece is + having Newton bind a surface mesh to volume deformable tetrahedral mesh + in addition to removing the deformable_registry data structure. + """ + super().start_simulation() + + # Apply global model parameters from :class:`NewtonModelCfg` to the finalized model. + # Sets ``soft_contact_ke/kd/mu`` and optionally overrides per-shape + # ``shape_material_ke/kd/mu`` on the Newton model. + from isaaclab.physics import PhysicsManager + + cfg = PhysicsManager._cfg + if cfg is not None and hasattr(cfg, "model_cfg") and cfg.model_cfg is not None: + model = cls._model + if model is None: + return + + model_cfg = cfg.model_cfg + model.soft_contact_ke = float(model_cfg.soft_contact_ke) + model.soft_contact_kd = float(model_cfg.soft_contact_kd) + model.soft_contact_mu = float(model_cfg.soft_contact_mu) + + if model_cfg.shape_material_ke is not None: + model.shape_material_ke.fill_(float(model_cfg.shape_material_ke)) + if model_cfg.shape_material_kd is not None: + model.shape_material_kd.fill_(float(model_cfg.shape_material_kd)) + if model_cfg.shape_material_mu is not None: + model.shape_material_mu.fill_(float(model_cfg.shape_material_mu)) + + # Setup USD/Fabric sync for Kit viewport deformable rendering + if not cls._clone_physics_only and cls._deformable_registry: + import re + + import usdrt + + if NewtonManager._usdrt_stage is None: + NewtonManager._usdrt_stage = get_current_stage(fabric=True) + + stage = get_current_stage() + for entry in cls._deformable_registry: + for inst_idx, offset in enumerate(entry.particle_offsets): + # Resolve regex pattern to concrete instance path of visual mesh + resolved_vis = re.sub(r"(?<=[Ee]nv_)\.\*", str(inst_idx), entry.vis_mesh_prim_path) + resolved_vis = re.sub(r"\.\*", str(inst_idx), resolved_vis) + vis_prim = stage.GetPrimAtPath(resolved_vis) + + if not vis_prim or not vis_prim.IsValid(): + logger.warning("[setup_fabric_particle_sync] vis prim not found at %s", resolved_vis) + continue + + # Create per-instance particle offset and count attributes on the visual mesh + # prim so the Fabric sync kernel can find the right slice of particle_q + # and iterate only over this body's particles (counts vary across bodies). + fab_prim = NewtonManager._usdrt_stage.GetPrimAtPath(vis_prim.GetPath().pathString) + fab_prim.CreateAttribute( + NewtonManager._newton_particle_offset_attr, usdrt.Sdf.ValueTypeNames.UInt, True + ) + fab_prim.GetAttribute(NewtonManager._newton_particle_offset_attr).Set(offset) + fab_prim.CreateAttribute( + NewtonManager._newton_particle_count_attr, usdrt.Sdf.ValueTypeNames.UInt, True + ) + fab_prim.GetAttribute(NewtonManager._newton_particle_count_attr).Set(entry.particles_per_body) + + cls._mark_particles_dirty() + cls.sync_particles_to_usd() + + @classmethod + def instantiate_builder_from_stage(cls): + """Create builder from USD stage with special treatment for deformable + bodies, as these are not read from USD yet. + + Detects env Xforms (e.g. ``/World/Env_0``, ``/World/Env_1``) and builds + each as a separate Newton world via ``begin_world``/``end_world``. + Falls back to a flat ``add_usd`` when no env Xforms are found. + + TODO: Subclass should not override this method, once deformables + supported on Newton import_usd, this can be unified with NewtonManager's + implementation. + """ + import re + + from pxr import UsdGeom + + stage = get_current_stage() + up_axis = UsdGeom.GetStageUpAxis(stage) + + # Scan /World children for env-like Xforms (Env_0, env_1, ...) + env_pattern = re.compile(r"^[Ee]nv_(\d+)$") + world_prim = stage.GetPrimAtPath("/World") + env_paths: list[tuple[int, str]] = [] + if world_prim and world_prim.IsValid(): + for child in world_prim.GetChildren(): + m = env_pattern.match(child.GetName()) + if m: + env_paths.append((int(m.group(1)), child.GetPath().pathString)) + env_paths.sort(key=lambda x: x[0]) + + builder = ModelBuilder(up_axis=up_axis) + + schema_resolvers = [SchemaResolverNewton(), SchemaResolverPhysx()] + + # Deformable sim/visual mesh paths must be skipped by ``add_usd`` + # so they don't get duplicated as static colliders. + deformable_ignore_paths = cls._get_deformable_ignore_paths() + + if not env_paths: + # No env Xforms — flat loading + builder.add_usd(stage, ignore_paths=deformable_ignore_paths, schema_resolvers=schema_resolvers) + + # Add deformable bodies from the registry (single world at origin). + for entry in cls._deformable_registry: + add_deformable_entry_to_builder(builder, entry, 0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]) + else: + # Load everything except the env subtrees (ground plane, lights, etc.) + ignore_paths = [path for _, path in env_paths] + deformable_ignore_paths + builder.add_usd(stage, ignore_paths=ignore_paths, schema_resolvers=schema_resolvers) + + # Build a prototype from the first env (all envs assumed identical) + _, proto_path = env_paths[0] + proto = ModelBuilder(up_axis=up_axis) + proto.add_usd( + stage, + root_path=proto_path, + ignore_paths=deformable_ignore_paths, + schema_resolvers=schema_resolvers, + ) + + # Inject registered sites into the proto before replication + global_sites, proto_sites = cls._cl_inject_sites(builder, {proto_path: proto}) + global_site_map: dict[str, tuple[int, None]] = {label: (idx, None) for label, idx in global_sites.items()} + num_worlds = len(env_paths) + local_site_map: dict[str, list[list[int]]] = {} + site_entries = proto_sites.get(id(proto), {}) + + # Add each env as a separate Newton world + xform_cache = UsdGeom.XformCache() + for col, (_, env_path) in enumerate(env_paths): + builder.begin_world() + offset = builder.shape_count + world_xform = xform_cache.GetLocalToWorldTransform(stage.GetPrimAtPath(env_path)) + translation = world_xform.ExtractTranslation() + rotation = world_xform.ExtractRotationQuat() + pos = (translation[0], translation[1], translation[2]) + quat = ( + rotation.GetImaginary()[0], + rotation.GetImaginary()[1], + rotation.GetImaginary()[2], + rotation.GetReal(), + ) + builder.add_builder(proto, xform=wp.transform(pos, quat)) + for label, proto_shape_indices in site_entries.items(): + if label not in local_site_map: + local_site_map[label] = [[] for _ in range(num_worlds)] + for proto_shape_idx in proto_shape_indices: + local_site_map[label][col].append(offset + proto_shape_idx) + + # Add deformable bodies from the registry into this world. + for entry in cls._deformable_registry: + add_deformable_entry_to_builder(builder, entry, col, list(pos), quat) + + builder.end_world() + + NewtonManager._cl_site_index_map = { + **global_site_map, + **{label: (None, per_world) for label, per_world in local_site_map.items()}, + } + NewtonManager._num_envs = len(env_paths) + + # Call builder.color() if any deformable entries were added (required by VBD solver) + if cls._deformable_registry: + builder.color() + + cls.set_builder(builder) + + @classmethod + def _build_solver(cls, model: Model, solver_cfg: VBDSolverCfg) -> None: + """Construct :class:`SolverVBD` and populate the base-class slots. + + VBD always uses Newton's :class:`CollisionPipeline` and steps with + separate input/output states, so the flags are fixed. + """ + valid = set(inspect.signature(SolverVBD.__init__).parameters) - {"self", "model"} + kwargs = {k: v for k, v in solver_cfg.to_dict().items() if k in valid} + NewtonManager._solver = SolverVBD(model, **kwargs) + NewtonManager._use_single_state = False + NewtonManager._needs_collision_pipeline = True + + @classmethod + def _simulate_physics_only(cls) -> None: + # Rebuild BVH once per step for solvers that require it (e.g. VBD cloth). + if hasattr(cls._solver, "rebuild_bvh"): + cls._solver.rebuild_bvh(cls._state_0) + super()._simulate_physics_only() diff --git a/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py b/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py new file mode 100644 index 000000000000..4a9c03932c5b --- /dev/null +++ b/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py @@ -0,0 +1,100 @@ +# 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 + +import math + +import pytest +import warp as wp +from isaaclab_newton.sim.spawners.materials import NewtonDeformableMaterialCfg + +from isaaclab_contrib.deformable import DeformableObject, VBDSolverCfg +from isaaclab_contrib.deformable.deformable_object import DeformableRegistryEntry, add_deformable_entry_to_builder + + +class _FakeBuilder: + def __init__(self): + self.particle_count = 0 + self.cloth_meshes = [] + + def add_cloth_mesh(self, **kwargs) -> None: + self.cloth_meshes.append(kwargs) + self.particle_count += len(kwargs["vertices"]) + + +def _make_surface_entry() -> DeformableRegistryEntry: + half_sqrt = math.sqrt(0.5) + return DeformableRegistryEntry( + prim_path="/World/envs/env_.*/cloth", + sim_mesh_prim_path="/World/envs/env_.*/cloth/mesh", + vis_mesh_prim_path="/World/envs/env_.*/cloth/mesh", + vertices=[ + wp.vec3(0.0, 0.0, 0.0), + wp.vec3(1.0, 0.0, 0.0), + wp.vec3(0.0, 1.0, 0.0), + ], + indices=[0, 1, 2], + init_pos=(1.0, 0.0, 0.0), + init_rot=(0.0, 0.0, half_sqrt, half_sqrt), + deformable_type="surface", + ) + + +def _vec3_as_tuple(value) -> tuple[float, float, float]: + return (float(value[0]), float(value[1]), float(value[2])) + + +def test_deformable_package_exports_public_symbols(): + """Test that deformable symbols are exported from the package root.""" + assert DeformableObject.__name__ == "DeformableObject" + assert VBDSolverCfg.__name__ == "VBDSolverCfg" + + +def test_newton_material_defaults_match_registry_defaults(): + """Test that Newton material cfg defaults match the deformable registry defaults.""" + material_cfg = NewtonDeformableMaterialCfg() + + assert material_cfg.density == DeformableRegistryEntry.density + assert material_cfg.particle_radius == DeformableRegistryEntry.particle_radius + + +def test_builder_hook_applies_env_quaternion_to_deformable_entry(): + """Test that deformable builder placement honors the environment quaternion.""" + entry = _make_surface_entry() + builder = _FakeBuilder() + half_sqrt = math.sqrt(0.5) + + add_deformable_entry_to_builder( + builder, + entry, + env_idx=0, + env_position=[10.0, 20.0, 30.0], + env_rotation=[0.0, 0.0, half_sqrt, half_sqrt], + ) + + mesh = builder.cloth_meshes[0] + rotated_x_axis = wp.quat_rotate(mesh["rot"], wp.vec3(1.0, 0.0, 0.0)) + + assert _vec3_as_tuple(mesh["pos"]) == pytest.approx((10.0, 21.0, 30.0)) + assert _vec3_as_tuple(rotated_x_axis) == pytest.approx((-1.0, 0.0, 0.0), abs=1e-6) + assert entry.particle_offsets == [0] + assert entry.particles_per_body == 3 + + +def test_builder_hook_resets_entry_offsets_on_first_environment(): + """Test that repeated model rebuilds do not accumulate stale particle offsets.""" + entry = _make_surface_entry() + builder = _FakeBuilder() + identity = [0.0, 0.0, 0.0, 1.0] + + add_deformable_entry_to_builder(builder, entry, 0, [0.0, 0.0, 0.0], identity) + add_deformable_entry_to_builder(builder, entry, 1, [1.0, 0.0, 0.0], identity) + + assert entry.particle_offsets == [0, 3] + + rebuilt_builder = _FakeBuilder() + add_deformable_entry_to_builder(rebuilt_builder, entry, 0, [0.0, 0.0, 0.0], identity) + + assert entry.particle_offsets == [0] + assert entry.particles_per_body == 3 diff --git a/source/isaaclab_contrib/test/deformable/test_deformable_object.py b/source/isaaclab_contrib/test/deformable/test_deformable_object.py new file mode 100644 index 000000000000..d64cc39331bd --- /dev/null +++ b/source/isaaclab_contrib/test/deformable/test_deformable_object.py @@ -0,0 +1,717 @@ +# 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 + +# ignore private usage of variables warning +# pyright: reportPrivateUsage=none + + +"""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 +from flaky import flaky +from isaaclab_newton.physics import NewtonCfg +from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg +from isaaclab_newton.sim.spawners.materials import ( + NewtonDeformableBodyMaterialCfg, + NewtonSurfaceDeformableBodyMaterialCfg, +) + +import isaaclab.sim as sim_utils +import isaaclab.utils.math as math_utils +from isaaclab.assets import DeformableObject, DeformableObjectCfg +from isaaclab.sim import SimulationCfg, build_simulation_context + +from isaaclab_contrib.deformable.newton_manager_cfg import VBDSolverCfg + +NEWTON_VBD_CFG = SimulationCfg( + physics=NewtonCfg( + solver_cfg=VBDSolverCfg(iterations=3), + num_substeps=2, + ), +) + + +def _newton_sim_context(device="cuda:0", gravity_enabled=True): + """Helper to create a Newton VBD simulation context.""" + NEWTON_VBD_CFG.device = device + NEWTON_VBD_CFG.gravity = (0.0, 0.0, -9.81) if gravity_enabled else (0.0, 0.0, 0.0) + return build_simulation_context(device=device, sim_cfg=NEWTON_VBD_CFG, auto_add_lighting=True) + + +def generate_cubes_scene( + num_cubes: int = 1, + height: float = 1.0, + device: str = "cuda:0", +) -> DeformableObject: + """Generate a scene with deformable tet-mesh cubes. + + Args: + num_cubes: Number of cubes to generate. + height: Height of the cubes. + device: Device to use for the simulation. + + Returns: + The deformable object representing the 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/env_{i}", "Xform", translation=origin) + + cube_object_cfg = DeformableObjectCfg( + prim_path="/World/env_.*/Cube", + spawn=sim_utils.MeshCuboidCfg( + size=(0.1, 0.1, 0.1), + deformable_props=NewtonDeformableBodyPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.8, 0.2)), + physics_material=NewtonDeformableBodyMaterialCfg( + density=500.0, + k_mu=1e4, + k_lambda=1e4, + ), + ), + init_state=DeformableObjectCfg.InitialStateCfg( + pos=(0.0, 0.0, height), + rot=(1.0, 0.0, 0.0, 0.0), + ), + ) + cube_object = DeformableObject(cfg=cube_object_cfg) + return cube_object + + +def generate_cloth_scene( + num_cloths: int = 1, + height: float = 1.0, + device: str = "cuda:0", +) -> DeformableObject: + """Generate a scene with surface deformable cloth squares. + + Args: + num_cloths: Number of cloths to generate. + height: Height of the cloths. + device: Device to use for the simulation. + + Returns: + The deformable object representing the cloths. + """ + origins = torch.tensor([(i * 1.0, 0, height) for i in range(num_cloths)]).to(device) + for i, origin in enumerate(origins): + sim_utils.create_prim(f"/World/env_{i}", "Xform", translation=origin) + + cloth_object_cfg = DeformableObjectCfg( + prim_path="/World/env_.*/Cloth", + spawn=sim_utils.MeshRectangleCfg( + size=(0.2, 0.2), + resolution=(3, 3), + deformable_props=NewtonDeformableBodyPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.2, 0.8)), + physics_material=NewtonSurfaceDeformableBodyMaterialCfg(density=0.02, particle_radius=0.005), + ), + init_state=DeformableObjectCfg.InitialStateCfg( + pos=(0.0, 0.0, height), + rot=(1.0, 0.0, 0.0, 0.0), + ), + ) + return DeformableObject(cfg=cloth_object_cfg) + + +def generate_cuboid_and_cylinder_scene(height: float = 1.0) -> tuple[DeformableObject, DeformableObject]: + """Generate two independent deformable assets with different mesh shapes.""" + sim_utils.create_prim("/World/env_0", "Xform", translation=(0.0, 0.0, 0.0)) + + cuboid_cfg = DeformableObjectCfg( + prim_path="/World/env_.*/Cuboid", + spawn=sim_utils.MeshCuboidCfg( + size=(0.16, 0.08, 0.12), + deformable_props=NewtonDeformableBodyPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.8, 0.2)), + physics_material=NewtonDeformableBodyMaterialCfg( + density=500.0, + k_mu=1e4, + k_lambda=1e4, + ), + ), + init_state=DeformableObjectCfg.InitialStateCfg(pos=(0.0, 0.0, height)), + ) + cylinder_cfg = DeformableObjectCfg( + prim_path="/World/env_.*/Cylinder", + spawn=sim_utils.MeshCylinderCfg( + radius=0.06, + height=0.14, + deformable_props=NewtonDeformableBodyPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.8, 0.2, 0.2)), + physics_material=NewtonDeformableBodyMaterialCfg( + density=500.0, + k_mu=1e4, + k_lambda=1e4, + ), + ), + init_state=DeformableObjectCfg.InitialStateCfg(pos=(0.4, 0.0, height + 0.2)), + ) + return DeformableObject(cfg=cuboid_cfg), DeformableObject(cfg=cylinder_cfg) + + +@pytest.fixture +def sim(): + """Create Newton VBD simulation context.""" + with _newton_sim_context() as sim: + sim._app_control_on_stop_handle = None + yield sim + + +def test_initialization(sim): + """Test initialization of Newton deformable objects.""" + num_cubes = 2 + cube_object = generate_cubes_scene(num_cubes=num_cubes) + + sim.reset() + + assert cube_object.is_initialized + assert cube_object.num_instances == num_cubes + assert cube_object.max_sim_vertices_per_body > 0 + + particles_per_body = cube_object.max_sim_vertices_per_body + + # nodal_state_w: (N, V, 6) + nodal_state = cube_object.data.nodal_state_w.torch + assert nodal_state.shape == (num_cubes, particles_per_body, 6) + + # nodal_pos_w: (N, V, 3) + nodal_pos = cube_object.data.nodal_pos_w.torch + assert nodal_pos.shape == (num_cubes, particles_per_body, 3) + + # nodal_vel_w: (N, V, 3) + nodal_vel = cube_object.data.nodal_vel_w.torch + assert nodal_vel.shape == (num_cubes, particles_per_body, 3) + + # root_pos_w: (N, 3) + root_pos = cube_object.data.root_pos_w.torch + assert root_pos.shape == (num_cubes, 3) + + # root_vel_w: (N, 3) + root_vel = cube_object.data.root_vel_w.torch + assert root_vel.shape == (num_cubes, 3) + + +def test_surface_initialization_and_freefall(sim): + """Test initialization and stepping for surface deformable objects.""" + num_cloths = 2 + cloth_object = generate_cloth_scene(num_cloths=num_cloths, height=5.0) + + sim.reset() + + assert cloth_object.is_initialized + assert cloth_object.num_instances == num_cloths + assert cloth_object._deformable_type == "surface" + assert cloth_object.max_sim_vertices_per_body > 0 + assert cloth_object.data.nodal_pos_w.torch.shape == ( + num_cloths, + cloth_object.max_sim_vertices_per_body, + 3, + ) + + initial_root_z = cloth_object.data.root_pos_w.torch[:, 2].clone() + for _ in range(5): + sim.step() + cloth_object.update(sim.cfg.dt) + + assert torch.all(cloth_object.data.root_pos_w.torch[:, 2] < initial_root_z) + + +def test_set_nodal_state(sim): + """Test setting the state of the deformable object.""" + num_cubes = 2 + cube_object = generate_cubes_scene(num_cubes=num_cubes) + + sim.reset() + + for state_type_to_randomize in ["nodal_pos_w", "nodal_vel_w"]: + state_dict = { + "nodal_pos_w": torch.zeros_like(cube_object.data.nodal_pos_w.torch), + "nodal_vel_w": torch.zeros_like(cube_object.data.nodal_vel_w.torch), + } + + for _ in range(5): + state_dict[state_type_to_randomize] = torch.randn( + num_cubes, cube_object.max_sim_vertices_per_body, 3, device=sim.device + ) + + for _ in range(5): + nodal_state = torch.cat( + [ + state_dict["nodal_pos_w"], + state_dict["nodal_vel_w"], + ], + dim=-1, + ) + cube_object.write_nodal_state_to_sim_index(nodal_state) + + torch.testing.assert_close(cube_object.data.nodal_state_w.torch, nodal_state, rtol=1e-5, atol=1e-5) + + sim.step() + cube_object.update(sim.cfg.dt) + + +def test_write_partial_env_ids(sim): + """Test writing to a subset of environments using env_ids.""" + num_cubes = 2 + cube_object = generate_cubes_scene(num_cubes=num_cubes) + + sim.reset() + + particles_per_body = cube_object.max_sim_vertices_per_body + default_pos = cube_object.data.nodal_pos_w.torch.clone() + + # Write new positions only for env 0 + new_pos = torch.randn(1, particles_per_body, 3, device=sim.device) + cube_object.write_nodal_pos_to_sim_index(new_pos, env_ids=torch.tensor([0], device=sim.device)) + cube_object.update(sim.cfg.dt) + + read_pos = cube_object.data.nodal_pos_w.torch + + # env 0 should have new positions + torch.testing.assert_close(read_pos[0], new_pos[0], rtol=1e-5, atol=1e-5) + + # other envs should be unchanged + torch.testing.assert_close(read_pos[1:], default_pos[1:], rtol=1e-5, atol=1e-5) + + +def test_write_partial_velocity_env_ids(sim): + """Test writing nodal velocities to a subset of environments.""" + num_cubes = 4 + cube_object = generate_cubes_scene(num_cubes=num_cubes) + + sim.reset() + + particles_per_body = cube_object.max_sim_vertices_per_body + default_vel = cube_object.data.nodal_vel_w.torch.clone() + + env_ids = torch.tensor([1], device=sim.device) + new_vel = torch.full((1, particles_per_body, 3), 0.25, device=sim.device) + new_vel[..., 2] = 1.0 + cube_object.write_nodal_velocity_to_sim_index(new_vel, env_ids=env_ids) + cube_object.update(sim.cfg.dt) + + read_vel = cube_object.data.nodal_vel_w.torch + torch.testing.assert_close(read_vel[1], new_vel[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(cube_object.data.root_vel_w.torch[1], new_vel[0].mean(dim=0), rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_vel[0], default_vel[0], rtol=1e-5, atol=1e-5) + if num_cubes > 2: + torch.testing.assert_close(read_vel[2:], default_vel[2:], rtol=1e-5, atol=1e-5) + + +def test_full_data_writes_selected_env(sim): + """Test full-sized write buffers with selected environment ids.""" + num_cubes = 3 + cube_object = generate_cubes_scene(num_cubes=num_cubes) + + sim.reset() + + particles_per_body = cube_object.max_sim_vertices_per_body + env_ids = torch.tensor([1], device=sim.device) + + default_pos = cube_object.data.nodal_pos_w.torch.clone() + full_pos = default_pos + torch.linspace(0.1, 0.3, num_cubes, device=sim.device).view(num_cubes, 1, 1) + cube_object.write_nodal_pos_to_sim_index(full_pos, env_ids=env_ids, full_data=True) + cube_object.update(sim.cfg.dt) + + read_pos = cube_object.data.nodal_pos_w.torch + torch.testing.assert_close(read_pos[1], full_pos[1], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_pos[0], default_pos[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_pos[2], default_pos[2], rtol=1e-5, atol=1e-5) + + default_vel = cube_object.data.nodal_vel_w.torch.clone() + full_vel = torch.zeros(num_cubes, particles_per_body, 3, device=sim.device) + full_vel[0, :, 0] = 0.5 + full_vel[1, :, 1] = 0.75 + full_vel[2, :, 2] = 1.0 + cube_object.write_nodal_velocity_to_sim_index(full_vel, env_ids=env_ids, full_data=True) + cube_object.update(sim.cfg.dt) + + read_vel = cube_object.data.nodal_vel_w.torch + torch.testing.assert_close(read_vel[1], full_vel[1], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_vel[0], default_vel[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_vel[2], default_vel[2], rtol=1e-5, atol=1e-5) + + default_targets = cube_object.data.nodal_kinematic_target.torch.clone() + full_targets = torch.zeros(num_cubes, particles_per_body, 4, device=sim.device) + full_targets[..., :3] = cube_object.data.default_nodal_state_w.torch[..., :3] + full_targets[..., 3] = 1.0 + full_targets[1, :, :3] += torch.tensor([0.0, 0.0, 0.1], device=sim.device) + full_targets[1, :, 3] = 0.0 + cube_object.write_nodal_kinematic_target_to_sim_index(full_targets, env_ids=env_ids, full_data=True) + + read_targets = cube_object.data.nodal_kinematic_target.torch + torch.testing.assert_close(read_targets[1], full_targets[1], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_targets[0], default_targets[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_targets[2], default_targets[2], rtol=1e-5, atol=1e-5) + + +def test_kinematic_target_partial_env_ids_with_warp_input(sim): + """Test indexed kinematic target writes with device-native input arrays.""" + num_cubes = 3 + cube_object = generate_cubes_scene(num_cubes=num_cubes) + + sim.reset() + + particles_per_body = cube_object.max_sim_vertices_per_body + env_ids = torch.tensor([2], device=sim.device) + + default_targets = cube_object.data.nodal_kinematic_target.torch.clone() + targets = torch.zeros(1, particles_per_body, 4, device=sim.device) + targets[0, :, :3] = cube_object.data.default_nodal_state_w.torch[2, :, :3] + targets[0, :, :3] += torch.tensor([0.0, 0.0, 0.1], device=sim.device) + targets[0, :, 3] = 0.0 + + cube_object.write_nodal_kinematic_target_to_sim_index( + wp.from_torch(targets.contiguous(), dtype=wp.vec4f), env_ids=env_ids + ) + + read_targets = cube_object.data.nodal_kinematic_target.torch + torch.testing.assert_close(read_targets[2], targets[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_targets[0], default_targets[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_targets[1], default_targets[1], rtol=1e-5, atol=1e-5) + + +def test_mask_writes_selected_env(sim): + """Test full-sized write buffers with selected environment masks.""" + num_cubes = 3 + cube_object = generate_cubes_scene(num_cubes=num_cubes) + + sim.reset() + + env_mask = wp.array([False, True, False], dtype=wp.bool, device=sim.device) + + default_state = cube_object.data.nodal_state_w.torch.clone() + full_state = default_state.clone() + full_state[:, :, :3] += torch.tensor([10.0, 10.0, 10.0], device=sim.device) + full_state[:, :, 3:] = 10.0 + full_state[1, :, :3] = default_state[1, :, :3] + torch.tensor([0.1, 0.2, 0.3], device=sim.device) + full_state[1, :, 3:] = torch.tensor([0.4, 0.5, 0.6], device=sim.device) + cube_object.write_nodal_state_to_sim_mask(full_state, env_mask=env_mask) + cube_object.update(sim.cfg.dt) + + read_state = cube_object.data.nodal_state_w.torch + torch.testing.assert_close(read_state[1], full_state[1], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_state[0], default_state[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_state[2], default_state[2], rtol=1e-5, atol=1e-5) + + pos_before = cube_object.data.nodal_pos_w.torch.clone() + full_pos = pos_before + torch.tensor([5.0, 5.0, 5.0], device=sim.device) + full_pos[1] = pos_before[1] + torch.tensor([0.0, -0.1, 0.2], device=sim.device) + cube_object.write_nodal_pos_to_sim_mask(full_pos, env_mask=env_mask) + cube_object.update(sim.cfg.dt) + + read_pos = cube_object.data.nodal_pos_w.torch + torch.testing.assert_close(read_pos[1], full_pos[1], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_pos[0], pos_before[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_pos[2], pos_before[2], rtol=1e-5, atol=1e-5) + + vel_before = cube_object.data.nodal_vel_w.torch.clone() + full_vel = torch.full_like(vel_before, 7.0) + full_vel[1] = torch.tensor([0.7, 0.8, 0.9], device=sim.device) + cube_object.write_nodal_velocity_to_sim_mask(full_vel, env_mask=env_mask) + cube_object.update(sim.cfg.dt) + + read_vel = cube_object.data.nodal_vel_w.torch + torch.testing.assert_close(read_vel[1], full_vel[1], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_vel[0], vel_before[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_vel[2], vel_before[2], rtol=1e-5, atol=1e-5) + + default_targets = cube_object.data.nodal_kinematic_target.torch.clone() + full_targets = default_targets.clone() + full_targets[:, :, :3] = 3.0 + full_targets[:, :, 3] = 0.0 + full_targets[1, :, :3] = read_pos[1] + torch.tensor([0.0, 0.0, 0.1], device=sim.device) + cube_object.write_nodal_kinematic_target_to_sim_mask(full_targets, env_mask=env_mask) + + read_targets = cube_object.data.nodal_kinematic_target.torch + torch.testing.assert_close(read_targets[1], full_targets[1], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_targets[0], default_targets[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_targets[2], default_targets[2], rtol=1e-5, atol=1e-5) + + +@pytest.mark.parametrize( + "num_cubes, randomize_pos, randomize_rot", + [ + (1, False, False), + (1, True, False), + (1, False, True), + (2, True, True), + ], +) +@flaky(max_runs=3, min_passes=1) +def test_set_nodal_state_with_applied_transform(num_cubes, randomize_pos, randomize_rot): + """Test setting the state of the deformable object with applied transform. + + Applies random position/rotation transforms to the default nodal state, + writes it to simulation, steps with no gravity, and verifies the mean + nodal position (root_pos_w) matches the expected transformed centroid. + """ + cfg = SimulationCfg( + physics=NewtonCfg( + solver_cfg=VBDSolverCfg(iterations=3), + num_substeps=2, + ), + ) + cfg.device = "cuda:0" + cfg.gravity = (0.0, 0.0, 0.0) + + with build_simulation_context(device="cuda:0", sim_cfg=cfg, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object = generate_cubes_scene(num_cubes=num_cubes, height=5.0) + sim.reset() + + for _ in range(5): + nodal_state = cube_object.data.default_nodal_state_w.torch.clone() + mean_nodal_pos_default = nodal_state[..., :3].mean(dim=1) + + if randomize_pos: + pos_w = 0.5 * torch.rand(cube_object.num_instances, 3, device=sim.device) + pos_w[:, 2] += 0.5 + else: + pos_w = None + if randomize_rot: + quat_w = math_utils.random_orientation(cube_object.num_instances, device=sim.device) + else: + quat_w = None + + # transform_nodal_pos: center, rotate, translate, un-center + nodal_pos = nodal_state[..., :3] + mean_pos = nodal_pos.mean(dim=1, keepdim=True) + centered = nodal_pos - mean_pos + nodal_state[..., :3] = math_utils.transform_points(centered, pos_w, quat_w) + mean_pos + mean_nodal_pos_init = nodal_state[..., :3].mean(dim=1) + + if pos_w is None: + torch.testing.assert_close(mean_nodal_pos_init, mean_nodal_pos_default, rtol=1e-5, atol=1e-5) + else: + torch.testing.assert_close(mean_nodal_pos_init, mean_nodal_pos_default + pos_w, rtol=1e-5, atol=1e-5) + + cube_object.write_nodal_state_to_sim_index(nodal_state) + + for _ in range(50): + sim.step() + cube_object.update(sim.cfg.dt) + + torch.testing.assert_close(cube_object.data.root_pos_w.torch, mean_nodal_pos_init, rtol=1e-4, atol=1e-4) + + +def test_freefall_analytical(sim): + """Test that one step of free-fall matches the inertia target prediction. + + VBD computes an inertia target per substep (h = sub_dt):: + + v_new = v + g * h + x_new = x + v_new * h + + then optimizes elastic + contact potentials around it. For free-fall + (no contacts, negligible elastic forces over one step), the final + position equals the inertia target. + + Starting from rest (v_0 = 0) with N substeps of h = dt/N: + + substep 1: v_1 = g*h, dx_1 = g*h^2 (1 * g*h^2) + substep 2: v_2 = 2*g*h, dx_2 = 2*g*h^2 (2 * g*h^2) + ... + substep k: v_k = k*g*h, dx_k = k*g*h^2 (k * g*h^2) + + total dz = g*h^2 * (1 + 2 + ... + N) = g*h^2 * N*(N+1)/2 + + """ + g = -9.81 + dt = 1.0 / 60.0 + num_substeps = 2 + sub_dt = dt / num_substeps + expected_dz = g * sub_dt**2 * num_substeps * (num_substeps + 1) / 2 + + cube_object = generate_cubes_scene(num_cubes=1, height=5.0) + sim.reset() + + x0 = cube_object.data.nodal_pos_w.torch.clone() + sim.step() + cube_object.update(sim.cfg.dt) + x1 = cube_object.data.nodal_pos_w.torch + + dz = x1[..., 2] - x0[..., 2] + # Every vertex should have the same Z displacement under uniform gravity + torch.testing.assert_close(dz, torch.full_like(dz, expected_dz), rtol=1e-2, atol=1e-5) + + +def test_nodal_pos_reads_current_state_after_odd_substep_swap(): + """Test deformable reads use the current Newton state after state swapping.""" + cfg = SimulationCfg( + physics=NewtonCfg( + solver_cfg=VBDSolverCfg(iterations=3), + num_substeps=1, + use_cuda_graph=False, + ), + ) + cfg.device = "cuda:0" + cfg.gravity = (0.0, 0.0, -9.81) + + with build_simulation_context(device="cuda:0", sim_cfg=cfg, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object = generate_cubes_scene(num_cubes=1, height=5.0) + + sim.reset() + + initial_pos = cube_object.data.nodal_pos_w.torch.clone() + sim.step() + cube_object.update(sim.cfg.dt) + + stepped_pos = cube_object.data.nodal_pos_w.torch + assert torch.all(stepped_pos[..., 2] < initial_pos[..., 2]) + + +def test_set_kinematic_targets(sim): + """Test setting kinematic targets for the deformable object. + + Env 0 is kinematically constrained (all vertices pinned at default positions, + flag=0). Other envs are free (flag=1) and fall under gravity. After several + steps, env 0 should stay in place while the others have fallen. + """ + num_cubes = 4 + cube_object = generate_cubes_scene(num_cubes=num_cubes, height=5.0) + + sim.reset() + + particles_per_body = cube_object.max_sim_vertices_per_body + default_state = cube_object.data.default_nodal_state_w.torch + default_pos = default_state[..., :3].clone() + + # Build kinematic target buffer: (N, V, 4) = [x, y, z, flag] + nodal_kinematic_targets = torch.zeros(num_cubes, particles_per_body, 4, device=sim.device) + + for _ in range(5): + # Restore default state + cube_object.write_nodal_state_to_sim_index(default_state) + + # Env 0: pin all vertices at default positions (flag=0 = kinematic) + nodal_kinematic_targets[0, :, :3] = default_pos[0] + nodal_kinematic_targets[0, :, 3] = 0.0 + + # Other envs: free (flag=1) + nodal_kinematic_targets[1:, :, :3] = default_pos[1:] + nodal_kinematic_targets[1:, :, 3] = 1.0 + + cube_object.write_nodal_kinematic_target_to_sim_index(nodal_kinematic_targets) + + for _ in range(20): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + # Env 0 should stay at default position (kinematically constrained) + torch.testing.assert_close( + cube_object.data.nodal_pos_w.torch[0], + default_pos[0], + rtol=1e-5, + atol=1e-5, + ) + + # Other envs should have fallen + final_root_z = cube_object.data.root_pos_w.torch[1:, 2] + default_root_z = default_pos[1:, :, 2].mean(dim=1) + assert torch.all(final_root_z < default_root_z) + + +def test_kinematic_target_release_restores_free_motion(sim): + """Test that a pinned deformable falls again after kinematic targets are released.""" + cube_object = generate_cubes_scene(num_cubes=1, height=5.0) + + sim.reset() + + particles_per_body = cube_object.max_sim_vertices_per_body + default_state = cube_object.data.default_nodal_state_w.torch + default_pos = default_state[..., :3].clone() + + targets = torch.zeros(1, particles_per_body, 4, device=sim.device) + targets[0, :, :3] = default_pos[0] + targets[0, :, 3] = 0.0 + cube_object.write_nodal_kinematic_target_to_sim_index(targets) + + for _ in range(5): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + torch.testing.assert_close(cube_object.data.nodal_pos_w.torch[0], default_pos[0], rtol=1e-5, atol=1e-5) + + targets[0, :, 3] = 1.0 + cube_object.write_nodal_kinematic_target_to_sim_index(targets) + + for _ in range(20): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + assert cube_object.data.root_pos_w.torch[0, 2] < default_pos[0, :, 2].mean() + + +def test_multiple_deformable_assets_do_not_alias(sim): + """Test independent writes for two different deformable assets in one scene.""" + cuboid, cylinder = generate_cuboid_and_cylinder_scene(height=2.0) + + sim.reset() + + cuboid_default = cuboid.data.nodal_pos_w.torch.clone() + cylinder_default = cylinder.data.nodal_pos_w.torch.clone() + + cuboid_pos = cuboid_default + torch.tensor([0.15, -0.05, 0.1], device=sim.device) + cuboid.write_nodal_pos_to_sim_index(cuboid_pos) + cuboid.update(sim.cfg.dt) + cylinder.update(sim.cfg.dt) + + torch.testing.assert_close(cuboid.data.nodal_pos_w.torch, cuboid_pos, rtol=1e-5, atol=1e-5) + torch.testing.assert_close(cylinder.data.nodal_pos_w.torch, cylinder_default, rtol=1e-5, atol=1e-5) + assert cuboid._recorded_particle_offsets != cylinder._recorded_particle_offsets + + +def test_rebind_after_sim_reset(sim): + """Test that deformable write paths remain valid after a simulation reset.""" + cube_object = generate_cubes_scene(num_cubes=1, height=2.0) + + sim.reset() + + initial_pos = cube_object.data.default_nodal_state_w.torch[..., :3].clone() + first_pos = initial_pos + torch.tensor([0.1, 0.0, 0.0], device=sim.device) + cube_object.write_nodal_pos_to_sim_index(first_pos) + cube_object.update(sim.cfg.dt) + torch.testing.assert_close(cube_object.data.nodal_pos_w.torch, first_pos, rtol=1e-5, atol=1e-5) + + sim.reset() + + second_pos = initial_pos + torch.tensor([0.0, -0.1, 0.2], device=sim.device) + cube_object.write_nodal_pos_to_sim_index(second_pos) + cube_object.update(sim.cfg.dt) + torch.testing.assert_close(cube_object.data.nodal_pos_w.torch, second_pos, rtol=1e-5, atol=1e-5) + + +def test_write_shape_validation(sim): + """Test public write APIs reject wrong tensor shapes.""" + cube_object = generate_cubes_scene(num_cubes=2) + + sim.reset() + + particles_per_body = cube_object.max_sim_vertices_per_body + wrong_pos = torch.zeros(particles_per_body, 3, device=sim.device) + wrong_vel = torch.zeros(1, particles_per_body, 2, device=sim.device) + wrong_targets = torch.zeros(2, particles_per_body, 3, device=sim.device) + + with pytest.raises(AssertionError, match="Shape mismatch"): + cube_object.write_nodal_pos_to_sim_index(wrong_pos) + with pytest.raises(AssertionError, match="Shape mismatch"): + cube_object.write_nodal_velocity_to_sim_index(wrong_vel, env_ids=torch.tensor([0], device=sim.device)) + with pytest.raises(AssertionError, match="Shape mismatch"): + cube_object.write_nodal_kinematic_target_to_sim_index(wrong_targets) diff --git a/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py b/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py new file mode 100644 index 000000000000..4b00c0d2e371 --- /dev/null +++ b/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py @@ -0,0 +1,332 @@ +# 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 + +# ignore private usage of variables warning +# pyright: reportPrivateUsage=none + + +"""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 +from isaaclab_newton.assets import Articulation, RigidObject +from isaaclab_newton.physics import FeatherstoneSolverCfg, MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg +from isaaclab_newton.sim.spawners.materials import NewtonDeformableBodyMaterialCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import RigidObjectCfg +from isaaclab.assets.deformable_object import DeformableObjectCfg +from isaaclab.sim import SimulationCfg, build_simulation_context + +from isaaclab_contrib.deformable import ( + CoupledFeatherstoneVBDSolverCfg, + CoupledMJWarpVBDSolverCfg, + DeformableObject, + VBDSolverCfg, +) + +from isaaclab_assets import FRANKA_PANDA_CFG # isort:skip + + +def _make_coupled_cfg(coupling_mode: str, rigid_solver: str = "mjwarp") -> SimulationCfg: + """Create a simulation config for a coupled rigid-deformable solver.""" + if rigid_solver == "mjwarp": + solver_cfg = CoupledMJWarpVBDSolverCfg( + rigid_solver_cfg=MJWarpSolverCfg( + njmax=40, + nconmax=20, + ls_iterations=20, + cone="pyramidal", + impratio=1, + ls_parallel=False, + integrator="implicitfast", + ), + soft_solver_cfg=VBDSolverCfg( + iterations=3, + integrate_with_external_rigid_solver=True, + particle_enable_self_contact=False, + particle_collision_detection_interval=-1, + ), + coupling_mode=coupling_mode, + ) + elif rigid_solver == "featherstone": + solver_cfg = CoupledFeatherstoneVBDSolverCfg( + rigid_solver_cfg=FeatherstoneSolverCfg(), + soft_solver_cfg=VBDSolverCfg( + iterations=3, + integrate_with_external_rigid_solver=True, + particle_enable_self_contact=False, + particle_collision_detection_interval=-1, + ), + coupling_mode=coupling_mode, + ) + else: + raise ValueError(f"Unknown rigid solver: {rigid_solver}") + + return SimulationCfg( + dt=1.0 / 60.0, + physics=NewtonCfg( + solver_cfg=solver_cfg, + num_substeps=5, + use_cuda_graph=True, + ), + ) + + +def _coupled_sim_context(cfg: SimulationCfg, device="cuda:0"): + """Helper to create a coupled solver simulation context.""" + cfg.device = device + return build_simulation_context(device=device, sim_cfg=cfg, auto_add_lighting=True) + + +@pytest.fixture +def sim(request): + """Create a coupled solver simulation context. + + Defaults to one-way coupling. Tests can parametrize this fixture with + ``"two_way"`` when both coupling paths should be exercised. + """ + param = getattr(request, "param", "one_way") + if isinstance(param, tuple): + rigid_solver, coupling_mode = param + else: + rigid_solver, coupling_mode = "mjwarp", param + with _coupled_sim_context(_make_coupled_cfg(coupling_mode, rigid_solver)) as sim: + sim._app_control_on_stop_handle = None + yield sim + + +def generate_robot_and_two_cubes( + colliding_cube_pos: tuple = (0.3, 0.0, 1.0), + free_cube_pos: tuple = (2.0, 0.0, 1.0), +) -> tuple[Articulation, DeformableObject, DeformableObject]: + """Generate a scene with one Franka robot and two deformable cubes. + + A single env contains a robot and two cube objects at different positions. + One cube is placed above the robot arm (will collide), the other is placed + far away (falls freely). + + Args: + colliding_cube_pos: Position of the cube above the robot arm. + free_cube_pos: Position of the cube that falls freely. + + Returns: + Tuple of (robot, colliding_cube, free_cube). + """ + sim_utils.create_prim("/World/env_0", "Xform", translation=(0.0, 0.0, 0.0)) + + cfg = sim_utils.GroundPlaneCfg() + cfg.func("/World/defaultGroundPlane", cfg) + + robot_cfg = FRANKA_PANDA_CFG.replace(prim_path="/World/env_.*/Robot") + robot = Articulation(robot_cfg) + + colliding_cube = DeformableObject( + cfg=DeformableObjectCfg( + prim_path="/World/env_.*/cube_collide", + spawn=sim_utils.MeshCuboidCfg( + size=(0.05, 0.05, 0.05), + deformable_props=NewtonDeformableBodyPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.8, 0.2)), + physics_material=NewtonDeformableBodyMaterialCfg( + density=500.0, + k_mu=1e5, + k_lambda=1e5, + particle_radius=0.005, + ), + ), + init_state=DeformableObjectCfg.InitialStateCfg(pos=colliding_cube_pos), + ) + ) + + free_cube = DeformableObject( + cfg=DeformableObjectCfg( + prim_path="/World/env_.*/cube_free", + spawn=sim_utils.MeshCuboidCfg( + size=(0.05, 0.05, 0.05), + deformable_props=NewtonDeformableBodyPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.8, 0.2, 0.2)), + physics_material=NewtonDeformableBodyMaterialCfg( + density=500.0, + k_mu=1e4, + k_lambda=1e4, + particle_radius=0.005, + ), + ), + init_state=DeformableObjectCfg.InitialStateCfg(pos=free_cube_pos), + ) + ) + + return robot, colliding_cube, free_cube + + +def generate_lateral_rigid_and_deformable_cubes( + rigid_cube_pos: tuple = (0.0, 0.0, 1.0), + deformable_cube_pos: tuple = (-0.16, 0.0, 1.0), +) -> tuple[RigidObject, DeformableObject]: + """Generate rigid and deformable cubes arranged for lateral contact. + + Args: + rigid_cube_pos: Initial position of the rigid cube. + deformable_cube_pos: Initial position of the deformable cube. + + Returns: + Tuple of (rigid cube, deformable cube). + """ + sim_utils.create_prim("/World/env_0", "Xform", translation=(0.0, 0.0, 0.0)) + + rigid_cube = RigidObject( + cfg=RigidObjectCfg( + prim_path="/World/env_.*/rigid_cube", + spawn=sim_utils.CuboidCfg( + size=(0.2, 0.2, 0.2), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=0.05), + collision_props=sim_utils.CollisionPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.2, 0.8)), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=rigid_cube_pos), + ) + ) + + deformable_cube = DeformableObject( + cfg=DeformableObjectCfg( + prim_path="/World/env_.*/deformable_cube", + spawn=sim_utils.MeshCuboidCfg( + size=(0.08, 0.08, 0.08), + deformable_props=NewtonDeformableBodyPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.8, 0.2, 0.2)), + physics_material=NewtonDeformableBodyMaterialCfg( + density=1000.0, + k_mu=1e5, + k_lambda=1e5, + particle_radius=0.005, + ), + ), + init_state=DeformableObjectCfg.InitialStateCfg(pos=deformable_cube_pos), + ) + ) + + return rigid_cube, deformable_cube + + +@pytest.mark.parametrize( + "sim", + [("featherstone", "kinematic")], + indirect=True, + ids=["featherstone_kinematic"], +) +def test_smoke_featherstone_kinematic(sim): + """Smoke test: Featherstone + VBD kinematic coupling initializes and steps.""" + robot, colliding_cube, free_cube = generate_robot_and_two_cubes() + sim.reset() + + assert robot.is_initialized + assert colliding_cube.is_initialized + assert free_cube.is_initialized + + initial_z_collide = colliding_cube.data.root_pos_w.torch[0, 2].item() + initial_z_free = free_cube.data.root_pos_w.torch[0, 2].item() + + for _ in range(10): + sim.step() + robot.update(sim.cfg.dt) + colliding_cube.update(sim.cfg.dt) + free_cube.update(sim.cfg.dt) + + assert colliding_cube.data.root_pos_w.torch[0, 2].item() < initial_z_collide - 0.01 + assert free_cube.data.root_pos_w.torch[0, 2].item() < initial_z_free - 0.01 + + +def _run_lateral_rigid_cube_response(coupling_mode: str) -> float: + """Run a compact lateral contact scene and return rigid cube X displacement.""" + with _coupled_sim_context(_make_coupled_cfg(coupling_mode)) as sim: + sim._app_control_on_stop_handle = None + rigid_cube, deformable_cube = generate_lateral_rigid_and_deformable_cubes() + sim.reset() + + initial_rigid_x = rigid_cube.data.root_pos_w.torch[0, 0].item() + nodal_vel = torch.zeros_like(deformable_cube.data.nodal_vel_w.torch) + nodal_vel[..., 0] = 2.0 + deformable_cube.write_nodal_velocity_to_sim_index(nodal_vel) + + for _ in range(60): + sim.step() + rigid_cube.update(sim.cfg.dt) + deformable_cube.update(sim.cfg.dt) + + return rigid_cube.data.root_pos_w.torch[0, 0].item() - initial_rigid_x + + +def test_two_way_coupling_applies_reaction_to_rigid_body(): + """Test that two-way coupling laterally pushes a rigid body.""" + one_way_dx = _run_lateral_rigid_cube_response("one_way") + two_way_dx = _run_lateral_rigid_cube_response("two_way") + + assert abs(one_way_dx) < 1e-2 + assert two_way_dx > one_way_dx + 1e-2 + + +def test_deformable_deflected_by_rigid_contact(sim): + """Test that a cube falling onto the robot is deflected horizontally. + + Two cubes start at the same height (Z=1.0m). Cube 0 (env 0) is placed + above the robot arm at X=0.3m. Cube 1 (env 1) is shifted +2m in X away + from the robot so it falls freely. + + Expected timeline (dt=1/60s): + + - Steps 0-15: Both cubes in free-fall, identical Z trajectories. + No horizontal motion. Z drops from ~1.0m to ~0.65m. + - Step ~20: Cube 0 hits the robot arm at Z~0.54m. Contact deflects it + horizontally (X starts increasing). Cube 1 is still in free-fall. + - Step ~28: Cube 1 reaches the ground (Z~0.005m) and stops. Zero + horizontal displacement. + - Steps 20-40: Cube 0 continues falling while sliding off the arm, + gaining horizontal velocity. Reaches the ground around step 40. + - Steps 40-70: Cube 0 slides along the ground, decelerating due to + friction. Settles around X~1.0m. + - Steps 70+: Both cubes at rest on the ground. + + The test asserts that cube 0 has a significantly larger horizontal + displacement than cube 1 (which should be ~zero). + """ + robot, colliding_cube, free_cube = generate_robot_and_two_cubes( + colliding_cube_pos=(0.3, 0.0, 1.0), # above robot arm + free_cube_pos=(2.0, 0.0, 1.0), # far from robot, same height + ) + sim.reset() + + initial_xy_collide = colliding_cube.data.root_pos_w.torch[0, :2].clone() + initial_xy_free = free_cube.data.root_pos_w.torch[0, :2].clone() + + # Free-fall from 1.0m takes sqrt(2*1.0/9.81) ~ 0.45s ~ 27 steps at dt=1/60. + # Collision with the robot arm happens around step 20 (Z~0.5m). + # 120 steps (2s) gives ample time for collision, bounce, and settling. + for _ in range(120): + sim.step() + robot.update(sim.cfg.dt) + colliding_cube.update(sim.cfg.dt) + free_cube.update(sim.cfg.dt) + + final_xy_collide = colliding_cube.data.root_pos_w.torch[0, :2] + final_xy_free = free_cube.data.root_pos_w.torch[0, :2] + + displacement_collide = (final_xy_collide - initial_xy_collide).norm().item() + displacement_free = (final_xy_free - initial_xy_free).norm().item() + + # Colliding cube should be deflected; free cube should fall straight + assert displacement_collide > displacement_free + 0.01, ( + f"Colliding cube should be deflected more than free cube: " + f"collide={displacement_collide:.4f}, free={displacement_free:.4f}" + ) diff --git a/source/isaaclab_newton/changelog.d/mym-deformable-backend-split.minor.rst b/source/isaaclab_newton/changelog.d/mym-deformable-backend-split.minor.rst new file mode 100644 index 000000000000..3dade7d6fb9f --- /dev/null +++ b/source/isaaclab_newton/changelog.d/mym-deformable-backend-split.minor.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added Newton-specific deformable property and material cfgs. diff --git a/source/isaaclab_newton/changelog.d/mym-deformable_experimental.minor.rst b/source/isaaclab_newton/changelog.d/mym-deformable_experimental.minor.rst new file mode 100644 index 000000000000..aacba4481af9 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/mym-deformable_experimental.minor.rst @@ -0,0 +1,33 @@ +Added +^^^^^ + +* Added Newton deformable asset exports under + :mod:`isaaclab_newton.assets.deformable_object`. +* Added deformable registration hooks to Newton cloning so deformable assets can + be added per replicated world while their USD proxy meshes are skipped by the + Newton USD importer. +* Added Newton manager abstraction documentation for adding solver managers and + custom coupled solvers. + +Changed +^^^^^^^ + +* Changed Newton solver configuration exports so + :class:`~isaaclab_newton.physics.MJWarpSolverCfg`, + :class:`~isaaclab_newton.physics.XPBDSolverCfg`, + :class:`~isaaclab_newton.physics.FeatherstoneSolverCfg`, and + :class:`~isaaclab_newton.physics.KaminoSolverCfg` are provided from + :mod:`isaaclab_newton.physics.newton_manager_cfg`. +* Changed :class:`~isaaclab_newton.physics.NewtonCfg` to use + :class:`~isaaclab_newton.physics.MJWarpSolverCfg` as its explicit default + solver configuration. +* Changed :class:`~isaaclab_newton.physics.NewtonCfg` validation to reject + :class:`~isaaclab_newton.physics.MJWarpSolverCfg` configurations that combine + ``use_mujoco_contacts=True`` with ``collision_cfg``. Remove ``collision_cfg`` + or set ``use_mujoco_contacts=False``. + +Fixed +^^^^^ + +* Fixed Newton Fabric synchronization for deformable particle meshes and + particle-only scenes. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/assets/__init__.pyi index b9359445960c..5476609e4eac 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/assets/__init__.pyi @@ -6,6 +6,8 @@ __all__ = [ "Articulation", "ArticulationData", + "DeformableObject", + "DeformableObjectData", "RigidObject", "RigidObjectData", "RigidObjectCollection", @@ -15,3 +17,4 @@ __all__ = [ from .articulation import Articulation, ArticulationData from .rigid_object import RigidObject, RigidObjectData from .rigid_object_collection import RigidObjectCollection, RigidObjectCollectionData +from .deformable_object import DeformableObject, DeformableObjectData diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index 690065980945..f921001cb8cf 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -712,6 +712,7 @@ def write_root_velocity_to_sim_index( """Set the root center of mass velocity over selected environment indices into the simulation. The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + .. note:: This sets the velocity of the root's center of mass rather than the root's frame. @@ -738,6 +739,7 @@ def write_root_velocity_to_sim_mask( """Set the root center of mass velocity over selected environment mask into the simulation. The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + .. note:: This sets the velocity of the root's center of mass rather than the root's frame. @@ -764,6 +766,7 @@ def write_root_com_velocity_to_sim_index( """Set the root center of mass velocity over selected environment indices into the simulation. The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + .. note:: This sets the velocity of the root's center of mass rather than the root's frame. @@ -812,6 +815,7 @@ def write_root_com_velocity_to_sim_mask( """Set the root center of mass velocity over selected environment mask into the simulation. The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + .. note:: This sets the velocity of the root's center of mass rather than the root's frame. @@ -858,6 +862,7 @@ def write_root_link_velocity_to_sim_index( """Set the root link velocity over selected environment indices into the simulation. The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + .. note:: This sets the velocity of the root's frame rather than the root's center of mass. @@ -912,6 +917,7 @@ def write_root_link_velocity_to_sim_mask( """Set the root link velocity over selected environment mask into the simulation. The velocity comprises linear velocity (x, y, z) and angular velocity (x, y, z) in that order. + .. note:: This sets the velocity of the root's frame rather than the root's center of mass. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/deformable_object/__init__.py b/source/isaaclab_newton/isaaclab_newton/assets/deformable_object/__init__.py new file mode 100644 index 000000000000..e14e0f6d52c5 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/assets/deformable_object/__init__.py @@ -0,0 +1,8 @@ +# 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 isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_newton/isaaclab_newton/assets/deformable_object/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/assets/deformable_object/__init__.pyi new file mode 100644 index 000000000000..5878e22680c6 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/assets/deformable_object/__init__.pyi @@ -0,0 +1,12 @@ +# 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__ = [ + "DeformableObject", + "DeformableObjectData", +] + +from isaaclab_contrib.deformable.deformable_object import DeformableObject +from isaaclab_contrib.deformable.deformable_object_data import DeformableObjectData diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py index e2751e2274f4..e99b1eb7abdd 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py @@ -62,6 +62,25 @@ def _build_newton_builder_from_mapping( # The prototype is built from env_0 in absolute world coordinates. # add_builder xforms are deltas from env_0 so positions don't get double-counted. env0_pos = positions[0] + + # Deformable prim paths are handled by per_world_builder_hooks, not add_usd. + # Resolve the regex prim_path patterns to concrete env_0 paths so add_usd + # can skip them via ignore_paths. + import re + + _deformable_ignore_paths: list[str] = [] + if hasattr(NewtonManager, "_deformable_registry"): + for entry in NewtonManager._deformable_registry: + pat = re.compile(entry.prim_path.replace(".*", "[^/]*") + "$") + for src_path in sources: + # Check if any prim under this source matches the deformable pattern + prim = stage.GetPrimAtPath(src_path) + if prim.IsValid(): + for child in Usd.PrimRange(prim): + child_path = str(child.GetPath()) + if pat.match(child_path): + _deformable_ignore_paths.append(child_path) + protos: dict[str, ModelBuilder] = {} for src_path in sources: p = NewtonManager.create_builder(up_axis=up_axis) @@ -72,6 +91,7 @@ def _build_newton_builder_from_mapping( load_visual_shapes=True, skip_mesh_approximation=True, schema_resolvers=schema_resolvers, + ignore_paths=_deformable_ignore_paths if _deformable_ignore_paths else None, ) if simplify_meshes: p.approximate_meshes("convex_hull", keep_visual_shapes=True) @@ -107,9 +127,20 @@ def _build_newton_builder_from_mapping( local_site_map[label] = [[] for _ in range(num_worlds)] for proto_shape_idx in proto_shape_indices: local_site_map[label][col].append(offset + proto_shape_idx) + + # Run per-world builder hooks (e.g. deformable body registration). + if hasattr(NewtonManager, "_per_world_builder_hooks"): + for hook in NewtonManager._per_world_builder_hooks: + hook(builder, col, positions[col].tolist(), quaternions[col].tolist()) + # end the world context builder.end_world() + # Run post-replicate hooks (e.g. builder.color() for deformable coloring). + if hasattr(NewtonManager, "_post_replicate_hooks"): + for hook in NewtonManager._post_replicate_hooks: + hook(builder) + site_index_map = { **global_site_map, **{label: (None, per_world) for label, per_world in local_site_map.items()}, diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py index 86014d13a885..9e75d3153308 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py @@ -19,9 +19,9 @@ class HydroelasticSDFCfg: Hydroelastic contacts generate distributed contact areas instead of point contacts, providing more realistic force distribution for manipulation and compliant surfaces. - For more details, see the `Newton Collisions Guide`_. + For more details, see the `Newton hydroelastic contacts guide`_. - .. _Newton Collisions Guide: https://newton-physics.github.io/newton/latest/concepts/collisions.html#hydroelastic-contacts + .. _Newton hydroelastic contacts guide: https://newton-physics.github.io/newton/latest/concepts/collisions.html#hydroelastic-contacts """ reduce_contacts: bool = True @@ -88,9 +88,9 @@ class NewtonCollisionPipelineCfg: - Mesh-mesh collision via SDF with contact reduction - Optional hydroelastic contact model for compliant surfaces - For more details, see the `Newton Collisions Guide`_ and `CollisionPipeline API`_. + For more details, see the `Newton collision pipeline guide`_ and `CollisionPipeline API`_. - .. _Newton Collisions Guide: https://newton-physics.github.io/newton/latest/concepts/collisions.html + .. _Newton collision pipeline guide: https://newton-physics.github.io/newton/latest/concepts/collisions.html .. _CollisionPipeline API: https://newton-physics.github.io/newton/api/_generated/newton.CollisionPipeline.html """ diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 24a99aba4282..73f1ac535049 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -40,7 +40,7 @@ from isaaclab.utils.string import resolve_matching_names from isaaclab.utils.timer import Timer -from .newton_manager_cfg import NewtonCfg, NewtonShapeCfg, NewtonSolverCfg +from .newton_manager_cfg import NewtonCfg, NewtonShapeCfg if TYPE_CHECKING: from isaaclab.sim.simulation_context import SimulationContext @@ -74,6 +74,35 @@ def _set_fabric_transforms( fabric_transforms[i] = wp.transpose(wp.mat44d(wp.transform_to_matrix(transform))) +@wp.kernel(enable_backward=False) +def _sync_particle_points( + fabric_points: wp.fabricarrayarray(dtype=wp.vec3f), + fabric_world_matrices: wp.fabricarray(dtype=wp.mat44d), + offsets: wp.fabricarray(dtype=wp.uint32), + counts: wp.fabricarray(dtype=wp.uint32), + particle_q: wp.array(dtype=wp.vec3f), +): + """Write Newton particle positions into Fabric mesh point arrays as local-frame points. + + Newton stores particle positions in world space in ``state.particle_q``. The Fabric + ``points`` attribute on a ``UsdGeom.Mesh`` is local-space -- Kit multiplies by the + mesh prim's resolved ``omni:fabric:worldMatrix`` at render time. + + This kernel inverts the mesh prim's world matrix to convert each world-space particle + position into local-space before writing. + """ + i = wp.tid() + offset = int(offsets[i]) + num_points = int(counts[i]) + + # Un-transpose Fabric's stored matrix to get the standard homogeneous form + world_matrix = wp.transpose(wp.mat44f(fabric_world_matrices[i])) + inv_world_matrix = wp.inverse(world_matrix) + + for j in range(num_points): + fabric_points[i][j] = wp.transform_point(inv_world_matrix, particle_q[offset + j]) + + @wp.kernel(enable_backward=False) def _or_reset_masks_from_mask( env_mask: wp.array(dtype=wp.bool), @@ -217,6 +246,9 @@ class NewtonManager(PhysicsManager): _newton_index_attr = "newton:index" _clone_physics_only = False _transforms_dirty: bool = False + _particles_dirty: bool = False + _newton_particle_offset_attr = "newton:particleOffset" + _newton_particle_count_attr = "newton:particleCount" # cubric GPU transform hierarchy (replaces CPU update_world_xforms) _cubric = None @@ -307,6 +339,7 @@ def forward(cls) -> None: def pre_render(cls) -> None: """Flush deferred Fabric writes before cameras/visualizers read the scene.""" cls.sync_transforms_to_usd() + cls.sync_particles_to_usd() @classmethod def sync_transforms_to_usd(cls) -> None: @@ -406,15 +439,79 @@ def sync_transforms_to_usd(cls) -> None: except Exception: logger.exception("[NewtonManager] sync_transforms_to_usd FAILED") + @classmethod + def sync_particles_to_usd(cls) -> None: + """Write Newton particle_q to Fabric mesh point arrays for Kit viewport rendering. + + For each deformable body whose mesh prim carries a ``newton:particleOffset`` + attribute, this function copies the corresponding slice of ``state_0.particle_q`` + into the Fabric ``points`` array so the Kit viewport reflects the current + deformation. + + No-op when there is no ``_usdrt_stage``, no simulation state, or no + deformable bodies registered. + """ + if cls._usdrt_stage is None or cls._state_0 is None or cls._state_0.particle_q is None: + return + if not cls._particles_dirty: + return + pq = cls._state_0.particle_q + try: + import usdrt + + selection = cls._usdrt_stage.SelectPrims( + require_attrs=[ + (usdrt.Sdf.ValueTypeNames.Point3fArray, "points", usdrt.Usd.Access.ReadWrite), + (usdrt.Sdf.ValueTypeNames.UInt, cls._newton_particle_offset_attr, usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.UInt, cls._newton_particle_count_attr, usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read), + ], + device=str(PhysicsManager._device), + ) + if selection.GetCount() == 0: + return + fabric_points = wp.fabricarrayarray(data=selection, attrib="points", dtype=wp.vec3f) + fabric_offsets = wp.fabricarray(data=selection, attrib=cls._newton_particle_offset_attr) + fabric_counts = wp.fabricarray(data=selection, attrib=cls._newton_particle_count_attr) + fabric_world_matrices = wp.fabricarray(data=selection, attrib="omni:fabric:worldMatrix") + wp.launch( + _sync_particle_points, + dim=selection.GetCount(), + inputs=[fabric_points, fabric_world_matrices, fabric_offsets, fabric_counts, pq], + device=PhysicsManager._device, + ) + NewtonManager._particles_dirty = False + except Exception as exc: + logger.debug("[sync_particles_to_usd] %s", exc) + @classmethod def _mark_transforms_dirty(cls) -> None: - """Flag that physics state has changed and Fabric needs re-sync. + """Flag that rigid-body transforms have changed and Fabric needs re-sync. - Called by :meth:`_simulate` after stepping. The actual sync is deferred - to :meth:`sync_transforms_to_usd`, which runs at render cadence. + The actual sync is deferred to :meth:`sync_transforms_to_usd`, + which runs at render cadence via :meth:`pre_render`. """ NewtonManager._transforms_dirty = True + @classmethod + def _mark_particles_dirty(cls) -> None: + """Flag that particle positions have changed and Fabric needs re-sync. + + The actual sync is deferred to the particle sync callback (if registered), + which runs at render cadence via :meth:`pre_render`. + """ + NewtonManager._particles_dirty = True + + @classmethod + def _mark_state_dirty(cls) -> None: + """Flag that all physics state has changed and Fabric needs re-sync. + + Convenience method that marks both transforms and particles dirty. + Called by :meth:`_simulate` after stepping. + """ + cls._mark_transforms_dirty() + cls._mark_particles_dirty() + @classmethod def step(cls) -> None: """Step the physics simulation. @@ -500,7 +597,7 @@ def step(cls) -> None: PhysicsManager._sim_time += physics_dt if cls._usdrt_stage is not None: - cls._mark_transforms_dirty() + cls._mark_state_dirty() # Launch solver-specific debug logging after stepping. cls._log_solver_debug() @@ -547,9 +644,7 @@ def is_fabric_enabled(cls) -> bool: @classmethod def clear(cls): - """Clear all Newton-specific state (callbacks cleared by super().close()). - Start with solver specific cleanup.""" - cls._solver_specific_clear() + """Clear all Newton-specific state (callbacks cleared by super().close()).""" if cls._cubric is not None and cls._cubric_adapter is not None: cls._cubric.release_adapter(cls._cubric_adapter) NewtonManager._cubric = None @@ -586,6 +681,7 @@ def clear(cls): NewtonManager._newton_stage_path = None NewtonManager._usdrt_stage = None NewtonManager._transforms_dirty = False + NewtonManager._particles_dirty = False NewtonManager._up_axis = "Z" NewtonManager._visualization_scene_data = None NewtonManager._visualization_mapping = None @@ -596,6 +692,7 @@ def clear(cls): NewtonManager._pending_extended_state_attributes = set() NewtonManager._pending_extended_contact_attributes = set() NewtonManager._views = [] + cls._solver_specific_clear() @classmethod def set_builder(cls, builder: ModelBuilder) -> None: @@ -877,6 +974,7 @@ def start_simulation(cls) -> None: if cls._pending_extended_contact_attributes: cls._model.request_contact_attributes(*cls._pending_extended_contact_attributes) NewtonManager._pending_extended_contact_attributes = set() + NewtonManager._state_0 = cls._model.state() NewtonManager._state_1 = cls._model.state() NewtonManager._control = cls._model.control() @@ -902,23 +1000,29 @@ def start_simulation(cls) -> None: import usdrt body_paths = getattr(cls._model, "body_label", None) or getattr(cls._model, "body_key", None) - if body_paths is None: - raise RuntimeError("NewtonManager: model has no body_label/body_key, skipping USD/Fabric sync for RTX.") - NewtonManager._usdrt_stage = get_current_stage(fabric=True) - for i, prim_path in enumerate(body_paths): - prim = cls._usdrt_stage.GetPrimAtPath(prim_path) - prim.CreateAttribute(cls._newton_index_attr, usdrt.Sdf.ValueTypeNames.UInt, True) - prim.GetAttribute(cls._newton_index_attr).Set(i) - # Tag with PhysicsRigidBodyAPI so cubric's eRigidBody mode - # applies Inverse propagation (preserves Newton's world - # transforms and derives local) instead of Forward. - prim.AddAppliedSchema("PhysicsRigidBodyAPI") - xformable_prim = usdrt.Rt.Xformable(prim) - if not xformable_prim.HasWorldXform(): - xformable_prim.SetWorldXformFromUsd() - - cls._mark_transforms_dirty() - cls.sync_transforms_to_usd() + if not body_paths: + logger.warning( + "NewtonManager: model has no rigid bodies (body_label/body_key is empty). " + "USD/Fabric body sync for RTX is skipped. " + "Particle-only scenes (e.g. cloth) must register their own USD mesh update." + ) + NewtonManager._usdrt_stage = None + else: + NewtonManager._usdrt_stage = get_current_stage(fabric=True) + for i, prim_path in enumerate(body_paths): + prim = cls._usdrt_stage.GetPrimAtPath(prim_path) + prim.CreateAttribute(cls._newton_index_attr, usdrt.Sdf.ValueTypeNames.UInt, True) + prim.GetAttribute(cls._newton_index_attr).Set(i) + # Tag with PhysicsRigidBodyAPI so cubric's eRigidBody mode + # applies Inverse propagation (preserves Newton's world + # transforms and derives local) instead of Forward. + prim.AddAppliedSchema("PhysicsRigidBodyAPI") + xformable_prim = usdrt.Rt.Xformable(prim) + if not xformable_prim.HasWorldXform(): + xformable_prim.SetWorldXformFromUsd() + + cls._mark_transforms_dirty() + cls.sync_transforms_to_usd() @classmethod def instantiate_builder_from_stage(cls): @@ -1033,7 +1137,7 @@ def _initialize_contacts(cls) -> None: @classmethod @abstractmethod - def _build_solver(cls, model: Model, solver_cfg: NewtonSolverCfg) -> None: + def _build_solver(cls, model: Model, solver_cfg) -> None: """Construct the solver this manager owns and assign it onto the base class. Subclasses must populate the canonical :class:`NewtonManager` slots: @@ -1062,16 +1166,14 @@ def _build_solver(cls, model: Model, solver_cfg: NewtonSolverCfg) -> None: raise NotImplementedError("NewtonManager subclasses must implement _build_solver()") @classmethod - def _step_solver(cls, state_0: State, state_1: State, control: Control, substep_dt: float) -> None: + def _step_solver( + cls, state_0: State, state_1: State, control: Control, contacts: Contacts | None, substep_dt: float + ) -> None: """Run one solver substep. Default invokes :attr:`_solver` once. Subclasses can override to batch multiple solvers within a single substep. """ - # Only solvers that consume Newton collision-pipeline contacts receive ``_contacts`` here. Solvers with - # internal contact handling receive ``None`` even when ``_contacts`` is allocated for later reporting via - # ``solver.update_contacts`` (e.g. MuJoCo with ``use_mujoco_contacts=True``). - contacts = cls._contacts if cls._needs_collision_pipeline else None cls._solver.step(state_0, state_1, control, contacts, substep_dt) @classmethod @@ -1118,17 +1220,13 @@ def initialize_solver(cls) -> None: NewtonManager._solver_dt = cls.get_physics_dt() / cls._num_substeps NewtonManager._collision_cfg = cfg.collision_cfg # type: ignore[union-attr] - cls._build_solver( - cls._model, - cfg.solver_cfg, # type: ignore[union-attr] - ) + cls._build_solver(cls._model, cfg.solver_cfg) # type: ignore[union-attr] if NewtonManager._solver is None: raise RuntimeError( f"{cls.__name__}._build_solver did not assign NewtonManager._solver. " "Subclasses of NewtonManager must populate NewtonManager._solver, " "NewtonManager._use_single_state, and NewtonManager._needs_collision_pipeline." ) - cls._initialize_contacts() if cls._usdrt_stage is not None: @@ -1342,17 +1440,17 @@ def _run_solver_substeps(cls, contacts) -> None: """Run ``num_substeps`` solver iterations, handling double-buffered state swap.""" if cls._use_single_state: for _ in range(cls._num_substeps): - cls._solver.step(cls._state_0, cls._state_0, cls._control, contacts, cls._solver_dt) + cls._step_solver(cls._state_0, cls._state_0, cls._control, contacts, cls._solver_dt) cls._state_0.clear_forces() else: cfg = PhysicsManager._cfg need_copy_on_last = (cfg is not None and cfg.use_cuda_graph) and cls._num_substeps % 2 == 1 # type: ignore[union-attr] for i in range(cls._num_substeps): - cls._solver.step(cls._state_0, cls._state_1, cls._control, contacts, cls._solver_dt) + cls._step_solver(cls._state_0, cls._state_1, cls._control, contacts, cls._solver_dt) if need_copy_on_last and i == cls._num_substeps - 1: cls._state_0.assign(cls._state_1) else: - cls._state_0, cls._state_1 = cls._state_1, cls._state_0 + NewtonManager._state_0, NewtonManager._state_1 = cls._state_1, cls._state_0 cls._state_0.clear_forces() @classmethod @@ -1413,14 +1511,6 @@ def _simulate_physics_only(cls) -> None: cls._run_solver_substeps(contacts) cls._update_sensors(contacts) - @classmethod - def get_solver_convergence_steps(cls) -> dict[str, float | int]: - """Get the solver convergence steps. Needs to be implemented in solver-specific managers.""" - if hasattr(cls, "_get_solver_convergence_steps"): - return cls._get_solver_convergence_steps() - else: - raise NotImplementedError("NewtonManager subclasses must implement _get_solver_convergence_steps()") - # State accessors (used extensively by articulation/rigid object data) @classmethod def get_model(cls) -> Model: @@ -1521,8 +1611,8 @@ def _ensure_visualization_model(cls) -> None: device = PhysicsManager._device or "cpu" try: - cls._model = builder.finalize(device=device) - cls._state_0 = cls._model.state() + NewtonManager._model = builder.finalize(device=device) + NewtonManager._state_0 = cls._model.state() cls._model.num_envs = cls._num_envs replace_newton_shape_colors(cls._model) @@ -1531,8 +1621,8 @@ def _ensure_visualization_model(cls) -> None: "[NewtonManager] Failed to finalize the shadow Newton ModelBuilder for " "visualization (sim backend is PhysX)." ) - cls._model = None - cls._state_0 = None + NewtonManager._model = None + NewtonManager._state_0 = None @classmethod def _build_visualization_model_from_stage(cls, stage) -> ModelBuilder | None: diff --git a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi index aac4c8327ccb..699fc15ad4df 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi @@ -4,7 +4,19 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "NewtonDeformableBodyPropertiesCfg", + "NewtonDeformableBodyMaterialCfg", + "NewtonDeformableMaterialCfg", + "NewtonSurfaceDeformableBodyMaterialCfg", + "schemas", + "spawners", "views", ] -from . import views +from . import schemas, spawners, views +from .schemas import NewtonDeformableBodyPropertiesCfg +from .spawners.materials import ( + NewtonDeformableBodyMaterialCfg, + NewtonDeformableMaterialCfg, + NewtonSurfaceDeformableBodyMaterialCfg, +) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index 80f943ad46ee..e79e72e10dff 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -3,15 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Newton and MuJoCo simulation schema configuration classes.""" +"""Sub-module containing Newton schema configuration exports.""" -from .schemas_cfg import ( - MujocoJointDrivePropertiesCfg, - MujocoRigidBodyPropertiesCfg, - NewtonArticulationRootPropertiesCfg, - NewtonCollisionPropertiesCfg, - NewtonJointDrivePropertiesCfg, - NewtonMaterialPropertiesCfg, - NewtonMeshCollisionPropertiesCfg, - NewtonRigidBodyPropertiesCfg, -) +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index 6dae7b8cf2b7..1ae2d74c217b 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -3,11 +3,24 @@ # # SPDX-License-Identifier: BSD-3-Clause +__all__ = [ + "MujocoJointDrivePropertiesCfg", + "MujocoRigidBodyPropertiesCfg", + "NewtonArticulationRootPropertiesCfg", + "NewtonCollisionPropertiesCfg", + "NewtonDeformableBodyPropertiesCfg", + "NewtonJointDrivePropertiesCfg", + "NewtonMaterialPropertiesCfg", + "NewtonMeshCollisionPropertiesCfg", + "NewtonRigidBodyPropertiesCfg", +] + from .schemas_cfg import ( MujocoJointDrivePropertiesCfg, MujocoRigidBodyPropertiesCfg, NewtonArticulationRootPropertiesCfg, NewtonCollisionPropertiesCfg, + NewtonDeformableBodyPropertiesCfg, NewtonJointDrivePropertiesCfg, NewtonMaterialPropertiesCfg, NewtonMeshCollisionPropertiesCfg, diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py index d98fbc3a2368..db039306375c 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -10,6 +10,7 @@ from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, CollisionBaseCfg, + DeformableBodyPropertiesBaseCfg, JointDriveBaseCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, @@ -39,6 +40,24 @@ class NewtonRigidBodyPropertiesCfg(RigidBodyBaseCfg): _usd_field_exceptions: ClassVar[dict] = {} +@configclass +class NewtonDeformableBodyPropertiesCfg(DeformableBodyPropertiesBaseCfg): + """Newton-specific properties to apply to a deformable body. + + Currently empty. Backend-specific fields can be added here when Newton exposes + a registered deformable body property schema. + + The ``newton:`` namespace is reserved here so future Newton-native + deformable-body fields can be added without an API change. + + See :meth:`~isaaclab.sim.schemas.modify_deformable_body_properties` for more information. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = {} + + @configclass class MujocoRigidBodyPropertiesCfg(NewtonRigidBodyPropertiesCfg): """MuJoCo-solver-specific rigid body properties. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/spawners/__init__.py new file mode 100644 index 000000000000..9e75b2442524 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/__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 spawner utilities.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/spawners/__init__.pyi new file mode 100644 index 000000000000..ad99e610b437 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/__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__ = [ + "materials", +] + +from . import materials diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.py new file mode 100644 index 000000000000..7ed9dfa18e29 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__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 + +"""Sub-module for Newton material configuration exports.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi new file mode 100644 index 000000000000..c3f13216f781 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi @@ -0,0 +1,18 @@ +# 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__ = [ + "spawn_deformable_body_material", + "NewtonDeformableBodyMaterialCfg", + "NewtonDeformableMaterialCfg", + "NewtonSurfaceDeformableBodyMaterialCfg", +] + +from .physics_materials import spawn_deformable_body_material +from .physics_materials_cfg import ( + NewtonDeformableBodyMaterialCfg, + NewtonDeformableMaterialCfg, + NewtonSurfaceDeformableBodyMaterialCfg, +) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials.py b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials.py new file mode 100644 index 000000000000..1ace5ae38675 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials.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-compatible deformable physics material spawning exports.""" + +from isaaclab.sim.spawners.materials.physics_materials import spawn_deformable_body_material + +__all__ = ["spawn_deformable_body_material"] diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py new file mode 100644 index 000000000000..f7b63a8701c6 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py @@ -0,0 +1,79 @@ +# 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 + +from collections.abc import Callable +from typing import ClassVar + +from isaaclab.sim.spawners.materials.physics_materials_cfg import ( + DeformableBodyMaterialBaseCfg, + SurfaceDeformableBodyMaterialBaseCfg, +) +from isaaclab.utils.configclass import configclass + + +@configclass +class NewtonDeformableMaterialCfg: + """Newton-specific material properties for a deformable body. + + These properties are set with the prefix ``newton:``. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = {} + + density: float = 1.0 + """The material density [kg/m^3]. Defaults to 1.0 kg/m^3.""" + + particle_radius: float = 0.008 + """Particle radius [m] used by the Newton backend.""" + + +@configclass +class NewtonDeformableBodyMaterialCfg(DeformableBodyMaterialBaseCfg, NewtonDeformableMaterialCfg): + """Newton-specific physics material parameters for volume deformable bodies.""" + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = {} + + func: Callable | str = "isaaclab.sim.spawners.materials.physics_materials:spawn_deformable_body_material" + + k_mu: float = 1e5 + """First Lame material parameter [Pa]. Defaults to 1e5 Pa.""" + + k_lambda: float = 1e5 + """Second Lame material parameter [Pa]. Defaults to 1e5 Pa.""" + + k_damp: float = 0.0 + """Damping stiffness for tetrahedral elements [Pa*s]. Defaults to 0.0.""" + + +@configclass +class NewtonSurfaceDeformableBodyMaterialCfg(SurfaceDeformableBodyMaterialBaseCfg, NewtonDeformableMaterialCfg): + """Newton-specific physics material parameters for surface deformable bodies.""" + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = {} + + func: Callable | str = "isaaclab.sim.spawners.materials.physics_materials:spawn_deformable_body_material" + + tri_ke: float = 1e4 + """Triangle area-preserving stiffness [Pa]. Used by Newton backend for cloth meshes.""" + + tri_ka: float = 1e4 + """Triangle area stiffness [Pa]. Used by Newton backend for cloth meshes.""" + + tri_kd: float = 1.5e-6 + """Triangle area damping [Pa*s]. Used by Newton backend for cloth meshes.""" + + edge_ke: float = 5.0 + """Bending stiffness [N*m]. Used by Newton backend for cloth meshes.""" + + edge_kd: float = 1e-2 + """Bending damping [N*m*s]. Used by Newton backend for cloth meshes.""" diff --git a/source/isaaclab_physx/changelog.d/mym-deformable-backend-split.minor.rst b/source/isaaclab_physx/changelog.d/mym-deformable-backend-split.minor.rst new file mode 100644 index 000000000000..0879ea0a90f9 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/mym-deformable-backend-split.minor.rst @@ -0,0 +1,11 @@ +Added +^^^^^ + +* Added PhysX-specific deformable property and material cfgs. + +Deprecated +^^^^^^^^^^ + +* Deprecated generic PhysX deformable cfg aliases in favor of + ``PhysxDeformableBodyPropertiesCfg``, ``PhysxDeformableBodyMaterialCfg``, + and ``PhysxSurfaceDeformableBodyMaterialCfg``. diff --git a/source/isaaclab_physx/changelog.d/mym-deformable_experimental.major.rst b/source/isaaclab_physx/changelog.d/mym-deformable_experimental.major.rst new file mode 100644 index 000000000000..aa4a2dad99fd --- /dev/null +++ b/source/isaaclab_physx/changelog.d/mym-deformable_experimental.major.rst @@ -0,0 +1,24 @@ +Changed +^^^^^^^ + +* **Breaking:** Moved deformable body schema and material APIs from + :mod:`isaaclab_physx.sim` to :mod:`isaaclab.sim`, and moved deformable object + configuration from :mod:`isaaclab_physx.assets` to :mod:`isaaclab.assets`. + Import :class:`~isaaclab.sim.DeformableBodyPropertiesCfg`, + :func:`~isaaclab.sim.define_deformable_body_properties`, + :func:`~isaaclab.sim.modify_deformable_body_properties`, + :class:`~isaaclab.sim.DeformableObjectSpawnerCfg`, + :class:`~isaaclab.sim.DeformableBodyMaterialCfg`, + :class:`~isaaclab.sim.SurfaceDeformableBodyMaterialCfg`, and + :func:`~isaaclab.sim.spawn_deformable_body_material` from :mod:`isaaclab.sim` + instead of :mod:`isaaclab_physx.sim`; import + :class:`~isaaclab.assets.DeformableObjectCfg` from :mod:`isaaclab.assets` + instead of :mod:`isaaclab_physx.assets`. +* Changed PhysX deformable API documentation to direct users to the + backend-neutral :mod:`isaaclab.assets` and :mod:`isaaclab.sim` imports. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_physx.assets.DeformableObject` state writer methods + to accept ``ProxyArray`` inputs without requiring manual conversion. diff --git a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/__init__.pyi index 5ed56fc4f384..8effe50b9d85 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/__init__.pyi @@ -9,6 +9,7 @@ __all__ = [ "DeformableObjectData", ] +from isaaclab.assets.deformable_object import DeformableObjectCfg + from .deformable_object import DeformableObject -from .deformable_object_cfg import DeformableObjectCfg from .deformable_object_data import DeformableObjectData diff --git a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py index a240cb8203b5..759e31d9fb87 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py @@ -180,7 +180,7 @@ def update(self, dt: float): def write_nodal_state_to_sim_index( self, - nodal_state: torch.Tensor | wp.array, + nodal_state: torch.Tensor | wp.array | ProxyArray, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, full_data: bool = False, ) -> None: @@ -195,8 +195,10 @@ def write_nodal_state_to_sim_index( env_ids: Environment indices. If None, then all indices are used. full_data: Whether to expect full data. Defaults to False. """ - # Convert warp to torch if needed - if isinstance(nodal_state, wp.array): + # Convert array wrappers to torch for slicing into position and velocity views. + if isinstance(nodal_state, ProxyArray): + nodal_state = nodal_state.torch + elif isinstance(nodal_state, wp.array): nodal_state = wp.to_torch(nodal_state) # set into simulation self.write_nodal_pos_to_sim_index(nodal_state[..., :3], env_ids=env_ids, full_data=full_data) @@ -204,7 +206,7 @@ def write_nodal_state_to_sim_index( def write_nodal_state_to_sim_mask( self, - nodal_state: torch.Tensor | wp.array, + nodal_state: torch.Tensor | wp.array | ProxyArray, env_mask: wp.array | None = None, ) -> None: """Set the nodal state over selected environment mask into the simulation. @@ -225,7 +227,7 @@ def write_nodal_state_to_sim_mask( def write_nodal_pos_to_sim_index( self, - nodal_pos: torch.Tensor | wp.array, + nodal_pos: torch.Tensor | wp.array | ProxyArray, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, full_data: bool = False, ) -> None: @@ -242,6 +244,8 @@ def write_nodal_pos_to_sim_index( """ # resolve env_ids env_ids = self._resolve_env_ids(env_ids) + if isinstance(nodal_pos, ProxyArray): + nodal_pos = nodal_pos.warp if full_data: self.assert_shape_and_dtype( nodal_pos, (self.num_instances, self.max_sim_vertices_per_body), wp.vec3f, "nodal_pos" @@ -271,7 +275,7 @@ def write_nodal_pos_to_sim_index( def write_nodal_pos_to_sim_mask( self, - nodal_pos: torch.Tensor | wp.array, + nodal_pos: torch.Tensor | wp.array | ProxyArray, env_mask: wp.array | None = None, ) -> None: """Set the nodal positions over selected environment mask into the simulation. @@ -292,7 +296,7 @@ def write_nodal_pos_to_sim_mask( def write_nodal_velocity_to_sim_index( self, - nodal_vel: torch.Tensor | wp.array, + nodal_vel: torch.Tensor | wp.array | ProxyArray, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, full_data: bool = False, ) -> None: @@ -310,6 +314,8 @@ def write_nodal_velocity_to_sim_index( """ # resolve env_ids env_ids = self._resolve_env_ids(env_ids) + if isinstance(nodal_vel, ProxyArray): + nodal_vel = nodal_vel.warp if full_data: self.assert_shape_and_dtype( nodal_vel, (self.num_instances, self.max_sim_vertices_per_body), wp.vec3f, "nodal_vel" @@ -339,7 +345,7 @@ def write_nodal_velocity_to_sim_index( def write_nodal_velocity_to_sim_mask( self, - nodal_vel: torch.Tensor | wp.array, + nodal_vel: torch.Tensor | wp.array | ProxyArray, env_mask: wp.array | None = None, ) -> None: """Set the nodal velocity over selected environment mask into the simulation. @@ -361,7 +367,7 @@ def write_nodal_velocity_to_sim_mask( def write_nodal_kinematic_target_to_sim_index( self, - targets: torch.Tensor | wp.array, + targets: torch.Tensor | wp.array | ProxyArray, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, full_data: bool = False, ) -> None: @@ -385,6 +391,8 @@ def write_nodal_kinematic_target_to_sim_index( # resolve env_ids env_ids = self._resolve_env_ids(env_ids) + if isinstance(targets, ProxyArray): + targets = targets.warp if full_data: self.assert_shape_and_dtype( targets, (self.num_instances, self.max_sim_vertices_per_body), wp.vec4f, "targets" @@ -403,7 +411,7 @@ def write_nodal_kinematic_target_to_sim_index( write_nodal_vec4f_to_buffer, dim=(env_ids.shape[0], self.max_sim_vertices_per_body), inputs=[targets, env_ids, full_data], - outputs=[self._data.nodal_kinematic_target], + outputs=[self._data.nodal_kinematic_target.warp], device=self.device, ) # set into simulation @@ -413,7 +421,7 @@ def write_nodal_kinematic_target_to_sim_index( def write_nodal_kinematic_target_to_sim_mask( self, - targets: torch.Tensor | wp.array, + targets: torch.Tensor | wp.array | ProxyArray, env_mask: wp.array | None = None, ) -> None: """Set the kinematic targets of the simulation mesh for the deformable bodies using mask. @@ -442,7 +450,7 @@ def write_nodal_kinematic_target_to_sim_mask( def write_nodal_state_to_sim( self, - nodal_state: torch.Tensor | wp.array, + nodal_state: torch.Tensor | wp.array | ProxyArray, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Deprecated. Please use :meth:`write_nodal_state_to_sim_index` instead.""" @@ -455,7 +463,7 @@ def write_nodal_state_to_sim( def write_nodal_kinematic_target_to_sim( self, - targets: torch.Tensor | wp.array, + targets: torch.Tensor | wp.array | ProxyArray, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Deprecated. Please use :meth:`write_nodal_kinematic_target_to_sim_index` instead.""" @@ -469,7 +477,7 @@ def write_nodal_kinematic_target_to_sim( def write_nodal_pos_to_sim( self, - nodal_pos: torch.Tensor | wp.array, + nodal_pos: torch.Tensor | wp.array | ProxyArray, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Deprecated. Please use :meth:`write_nodal_pos_to_sim_index` instead.""" @@ -482,7 +490,7 @@ def write_nodal_pos_to_sim( def write_nodal_velocity_to_sim( self, - nodal_vel: torch.Tensor | wp.array, + nodal_vel: torch.Tensor | wp.array | ProxyArray, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Deprecated. Please use :meth:`write_nodal_velocity_to_sim_index` instead.""" diff --git a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py index 30307c1b08ff..df22c8b061bd 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py @@ -156,7 +156,7 @@ def nodal_state_w(self) -> ProxyArray: wp.launch( compute_nodal_state_w, dim=(self._num_instances, self._max_sim_vertices), - inputs=[self.nodal_pos_w, self.nodal_vel_w], + inputs=[self.nodal_pos_w.warp, self.nodal_vel_w.warp], outputs=[self._nodal_state_w.data], device=self.device, ) @@ -180,7 +180,7 @@ def root_pos_w(self) -> ProxyArray: wp.launch( compute_mean_vec3f_over_vertices, dim=(self._num_instances,), - inputs=[self.nodal_pos_w, self._max_sim_vertices], + inputs=[self.nodal_pos_w.warp, self._max_sim_vertices], outputs=[self._root_pos_w.data], device=self.device, ) @@ -200,7 +200,7 @@ def root_vel_w(self) -> ProxyArray: wp.launch( compute_mean_vec3f_over_vertices, dim=(self._num_instances,), - inputs=[self.nodal_vel_w, self._max_sim_vertices], + inputs=[self.nodal_vel_w.warp, self._max_sim_vertices], outputs=[self._root_vel_w.data], device=self.device, ) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi index 9a522d96ff8f..b3a0bbfcd16e 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi @@ -8,12 +8,14 @@ __all__ = [ "modify_deformable_body_properties", "DeformableBodyPropertiesCfg", "JointDrivePropertiesCfg", + "OmniPhysicsDeformableBodyPropertiesCfg", + "PhysxDeformableBodyPropertiesCfg", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyPropertiesCfg", "RigidBodyPropertiesCfg", - "DeformableObjectSpawnerCfg", - "spawn_deformable_body_material", "DeformableBodyMaterialCfg", + "PhysxDeformableBodyMaterialCfg", + "PhysxSurfaceDeformableBodyMaterialCfg", "SurfaceDeformableBodyMaterialCfg", "views", ] @@ -23,14 +25,16 @@ from .schemas import ( modify_deformable_body_properties, DeformableBodyPropertiesCfg, JointDrivePropertiesCfg, + OmniPhysicsDeformableBodyPropertiesCfg, + PhysxDeformableBodyPropertiesCfg, PhysxJointDrivePropertiesCfg, PhysxRigidBodyPropertiesCfg, RigidBodyPropertiesCfg, ) from .spawners import ( - DeformableObjectSpawnerCfg, - spawn_deformable_body_material, DeformableBodyMaterialCfg, + PhysxDeformableBodyMaterialCfg, + PhysxSurfaceDeformableBodyMaterialCfg, SurfaceDeformableBodyMaterialCfg, ) from . import views diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index b0d1477483d6..47f0b1e8ae93 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Sub-module containing utilities for schemas used in Omniverse for PhysX backend.""" +"""Sub-module containing PhysX schema configuration exports.""" from isaaclab.utils.module import lazy_export diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 6d2a05bf803c..b542edf9f454 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -14,11 +14,12 @@ __all__ = [ "FixedTendonPropertiesCfg", "JointDrivePropertiesCfg", "MeshCollisionPropertiesCfg", + "OmniPhysicsDeformableBodyPropertiesCfg", "PhysxArticulationRootPropertiesCfg", "PhysxCollisionPropertiesCfg", - "PhysXCollisionPropertiesCfg", "PhysxConvexDecompositionPropertiesCfg", "PhysxConvexHullPropertiesCfg", + "PhysxDeformableBodyPropertiesCfg", "PhysxDeformableCollisionPropertiesCfg", "PhysxFixedTendonPropertiesCfg", "PhysxJointDrivePropertiesCfg", @@ -47,11 +48,12 @@ from .schemas_cfg import ( FixedTendonPropertiesCfg, JointDrivePropertiesCfg, MeshCollisionPropertiesCfg, + OmniPhysicsDeformableBodyPropertiesCfg, PhysxArticulationRootPropertiesCfg, PhysxCollisionPropertiesCfg, - PhysXCollisionPropertiesCfg, PhysxConvexDecompositionPropertiesCfg, PhysxConvexHullPropertiesCfg, + PhysxDeformableBodyPropertiesCfg, PhysxDeformableCollisionPropertiesCfg, PhysxFixedTendonPropertiesCfg, PhysxJointDrivePropertiesCfg, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index 5b57b93d7ff4..713c1cc3264c 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -3,206 +3,12 @@ # # SPDX-License-Identifier: BSD-3-Clause -# needed to import for allowing type-hinting: Usd.Stage | None -from __future__ import annotations - -import logging - -from omni.physx.scripts import deformableUtils -from pxr import Usd - -from isaaclab.sim.utils import ( - apply_nested, - get_all_matching_child_prims, - safe_set_attribute_on_usd_prim, -) -from isaaclab.sim.utils.stage import get_current_stage -from isaaclab.utils.string import to_camel_case - -from isaaclab_physx.sim.schemas.schemas_cfg import DeformableBodyPropertiesCfg - -# import logger -logger = logging.getLogger(__name__) - +"""Compatibility wrappers for deformable schema writers. +The deformable schema writers are backend-aware but remain unified in +:mod:`isaaclab.sim.schemas`. """ -Deformable body properties. -""" - - -def define_deformable_body_properties( - prim_path: str, - cfg: DeformableBodyPropertiesCfg, - stage: Usd.Stage | None = None, - deformable_type: str = "volume", - sim_mesh_prim_path: str | None = None, -): - """Apply the deformable body schema on the input prim and set its properties. - - See :func:`modify_deformable_body_properties` for more details on how the properties are set. - - .. note:: - If the input prim is not a mesh, this function will traverse the prim and find the first mesh - under it. If no mesh or multiple meshes are found, an error is raised. This is because the deformable - body schema can only be applied to a single mesh. - - Args: - prim_path: The prim path where to apply the deformable body schema. - cfg: The configuration for the deformable body. - stage: The stage where to find the prim. Defaults to None, in which case the - current stage is used. - deformable_type: The type of the deformable body (surface or volume). - This is used to determine which PhysX API to use for the deformable body. Defaults to "volume". - sim_mesh_prim_path: Optional override for the simulation mesh prim path. - If None, it is set to ``{prim_path}/sim_mesh`` for surface deformables - and ``{prim_path}/sim_tetmesh`` for volume deformables. - - Raises: - ValueError: When the prim path is not valid. - ValueError: When the prim has no mesh or multiple meshes. - RuntimeError: When setting the deformable body properties fails. - """ - # get stage handle - if stage is None: - stage = get_current_stage() - - # get USD prim - prim = stage.GetPrimAtPath(prim_path) - # check if prim path is valid - if not prim.IsValid(): - raise ValueError(f"Prim path '{prim_path}' is not valid.") - - # traverse the prim and get the mesh. If none or multiple meshes are found, raise error. - matching_prims = get_all_matching_child_prims(prim_path, lambda p: p.GetTypeName() == "Mesh") - # check if the volume deformable mesh is valid - if len(matching_prims) == 0: - raise ValueError(f"Could not find any mesh in '{prim_path}'. Please check asset.") - if len(matching_prims) > 1: - # get list of all meshes found - mesh_paths = [p.GetPrimPath() for p in matching_prims] - raise ValueError( - f"Found multiple meshes in '{prim_path}': {mesh_paths}." - " Deformable body schema can only be applied to one mesh." - ) - mesh_prim = matching_prims[0] - mesh_prim_path = mesh_prim.GetPrimPath() - - # check if the prim is valid - if not mesh_prim.IsValid(): - raise ValueError(f"Mesh prim path '{mesh_prim_path}' is not valid.") - - # set root prim properties based on the type of the deformable mesh (surface vs volume) - if deformable_type == "surface": - sim_mesh_prim_path = prim_path + "/sim_mesh" if sim_mesh_prim_path is None else sim_mesh_prim_path - success = deformableUtils.create_auto_surface_deformable_hierarchy( - stage=stage, - root_prim_path=prim_path, - simulation_mesh_path=sim_mesh_prim_path, - cooking_src_mesh_path=mesh_prim_path, - cooking_src_simplification_enabled=False, - set_visibility_with_guide_purpose=True, - ) - elif deformable_type == "volume": - sim_mesh_prim_path = prim_path + "/sim_tetmesh" if sim_mesh_prim_path is None else sim_mesh_prim_path - success = deformableUtils.create_auto_volume_deformable_hierarchy( - stage=stage, - root_prim_path=prim_path, - simulation_tetmesh_path=sim_mesh_prim_path, - collision_tetmesh_path=sim_mesh_prim_path, - cooking_src_mesh_path=mesh_prim_path, - simulation_hex_mesh_enabled=False, - cooking_src_simplification_enabled=False, - set_visibility_with_guide_purpose=True, - ) - else: - raise ValueError( - f"""Unsupported deformable type: '{deformable_type}'. - Only surface and volume deformables are supported.""" - ) - - # api failure - if not success: - raise RuntimeError(f"Failed to set deformable body properties on prim '{mesh_prim_path}'.") - - # set deformable body properties - modify_deformable_body_properties(prim_path, cfg, stage) - - -@apply_nested -def modify_deformable_body_properties(prim_path: str, cfg: DeformableBodyPropertiesCfg, stage: Usd.Stage | None = None): - """Modify PhysX parameters for a deformable body prim. - - A `deformable body`_ is a single body (either surface or volume deformable) that can be simulated by PhysX. - Unlike rigid bodies, deformable bodies support relative motion of the nodes in the mesh. - Consequently, they can be used to simulate deformations under applied forces. - - PhysX deformable body simulation employs Finite Element Analysis (FEA) to simulate the deformations of the mesh. - It uses two meshes to represent the deformable body: - - 1. **Simulation mesh**: This mesh is used for the simulation and is the one that is deformed by the solver. - 2. **Collision mesh**: This mesh only needs to match the surface of the simulation mesh and is used for - collision detection. - - For most applications, we assume that the above two meshes are computed from the "render mesh" of the deformable - body. The render mesh is the mesh that is visible in the scene and is used for rendering purposes. It is composed - of triangles, while the simulation mesh is composed of tetrahedrons for volume deformables, - and triangles for surface deformables. - - .. caution:: - The deformable body schema is still under development by the Omniverse team. The current implementation - works with the PhysX schemas shipped with Isaac Sim 6.0.0 onwards. It may change in future releases. - - .. note:: - This function is decorated with :func:`apply_nested` that sets the properties to all the prims - (that have the schema applied on them) under the input prim path. - - .. _deformable body: https://nvidia-omniverse.github.io/PhysX/physx/5.6.1/docs/DeformableVolume.html - .. _PhysxDeformableBodyAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/physxschema/annotated.html - - Args: - prim_path: The prim path to the deformable body. - cfg: The configuration for the deformable body. - stage: The stage where to find the prim. Defaults to None, in which case the - current stage is used. - - Returns: - True if the properties were successfully set, False otherwise. - """ - # get stage handle - if stage is None: - stage = get_current_stage() - - # get deformable-body USD prim - deformable_body_prim = stage.GetPrimAtPath(prim_path) - # check if the prim is valid - if not deformable_body_prim.IsValid(): - return False - # check if deformable body API is applied - if "OmniPhysicsDeformableBodyAPI" not in deformable_body_prim.GetAppliedSchemas(): - return False - - # apply customization to deformable API - if "PhysxBaseDeformableBodyAPI" not in deformable_body_prim.GetAppliedSchemas(): - deformable_body_prim.AddAppliedSchema("PhysxBaseDeformableBodyAPI") - # ensure PhysX collision API is applied on the collision mesh - if "PhysxCollisionAPI" not in deformable_body_prim.GetAppliedSchemas(): - deformable_body_prim.AddAppliedSchema("PhysxCollisionAPI") +from isaaclab.sim.schemas.schemas import define_deformable_body_properties, modify_deformable_body_properties - # convert to dict - cfg = cfg.to_dict() - # set into PhysX API - if cfg["kinematic_enabled"]: - logger.warning( - "Kinematic deformable bodies are not fully supported in the current version of Omni Physics. " - "Setting kinematic_enabled to True may lead to unexpected behavior." - ) - # prefixes for each attribute (collision attributes: physxCollision:*, and physxDeformable:* for rest) - property_prefixes = cfg["_property_prefix"] - for prefix, attr_list in property_prefixes.items(): - for attr_name in attr_list: - safe_set_attribute_on_usd_prim( - deformable_body_prim, f"{prefix}:{to_camel_case(attr_name, 'cC')}", cfg[attr_name], camel_case=False - ) - # success - return True +__all__ = ["define_deformable_body_properties", "modify_deformable_body_properties"] diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py index 88729c2b0f0f..2eacd969e949 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -5,13 +5,13 @@ from __future__ import annotations -import dataclasses import warnings from typing import ClassVar from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, CollisionBaseCfg, + DeformableBodyPropertiesBaseCfg, JointDriveBaseCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, @@ -20,15 +20,16 @@ @configclass -class OmniPhysicsPropertiesCfg: +class OmniPhysicsDeformableBodyPropertiesCfg(DeformableBodyPropertiesBaseCfg): """OmniPhysics properties for a deformable body. - These properties are set with the prefix ``omniphysics:``. For example, to set the mass of the - deformable body, you would set the property ``omniphysics:mass``. - - See the OmniPhysics documentation for more information on the available properties. + These properties are set with the prefix ``omniphysics:``. """ + _usd_namespace: ClassVar[str | None] = "omniphysics" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = {} + deformable_body_enabled: bool | None = None """Enables deformable body.""" @@ -36,7 +37,7 @@ class OmniPhysicsPropertiesCfg: """Enables kinematic body. Defaults to False, which means that the body is not kinematic.""" mass: float | None = None - """The material mass in [kg]. Defaults to None, in which case the material density is used to compute the mass.""" + """The material mass [kg]. Defaults to None, in which case the material density is used to compute the mass.""" @configclass @@ -48,6 +49,10 @@ class PhysXDeformableBodyPropertiesCfg: For more information on the available properties, please refer to the `documentation `_. """ + _usd_namespace: ClassVar[str | None] = "physxDeformableBody" + _usd_applied_schema: ClassVar[str | None] = "PhysxBaseDeformableBodyAPI" + _usd_field_exceptions: ClassVar[dict] = {} + solver_position_iteration_count: int = 16 """Number of the solver positional iterations per step. Range is [1,255], default to 16.""" @@ -134,6 +139,10 @@ class PhysxDeformableCollisionPropertiesCfg: as a base of :class:`DeformableBodyPropertiesCfg`. """ + _usd_namespace: ClassVar[str | None] = "physxCollision" + _usd_applied_schema: ClassVar[str | None] = "PhysxCollisionAPI" + _usd_field_exceptions: ClassVar[dict] = {} + contact_offset: float | None = None """Contact offset for the collision shape [m]. @@ -152,31 +161,12 @@ class PhysxDeformableCollisionPropertiesCfg: @configclass -class PhysXCollisionPropertiesCfg(PhysxDeformableCollisionPropertiesCfg): - """Deprecated: use :class:`PhysxDeformableCollisionPropertiesCfg`. - - .. deprecated:: 4.6.23 - ``PhysXCollisionPropertiesCfg`` (capital X) was renamed to - :class:`PhysxDeformableCollisionPropertiesCfg` to clear the namespace for the - new rigid-body :class:`PhysxCollisionPropertiesCfg` (lowercase x). The capital-X - name is preserved as a deprecation alias and is scheduled for removal in 5.0. - """ - - def __post_init__(self): - warnings.warn( - "'PhysXCollisionPropertiesCfg' (capital X) is deprecated and will be removed in 5.0." - " Use 'isaaclab_physx.sim.schemas.PhysxDeformableCollisionPropertiesCfg' instead.", - DeprecationWarning, - stacklevel=2, - ) - super().__post_init__() - - -@configclass -class DeformableBodyPropertiesCfg( - OmniPhysicsPropertiesCfg, PhysXDeformableBodyPropertiesCfg, PhysxDeformableCollisionPropertiesCfg +class PhysxDeformableBodyPropertiesCfg( + OmniPhysicsDeformableBodyPropertiesCfg, + PhysXDeformableBodyPropertiesCfg, + PhysxDeformableCollisionPropertiesCfg, ): - """Properties to apply to a deformable body. + """PhysX-specific properties to apply to a deformable body. A deformable body is a body that can deform under forces, both surface and volume deformables. The configuration allows users to specify the properties of the deformable body, @@ -192,12 +182,25 @@ class DeformableBodyPropertiesCfg( the properties and leave the rest as-is. """ - _property_prefix: dict[str, list[str]] = { - "omniphysics": [field.name for field in dataclasses.fields(OmniPhysicsPropertiesCfg)], - "physxDeformableBody": [field.name for field in dataclasses.fields(PhysXDeformableBodyPropertiesCfg)], - "physxCollision": [field.name for field in dataclasses.fields(PhysxDeformableCollisionPropertiesCfg)], - } - """Mapping between the property prefixes and the properties that fall under each prefix.""" + +@configclass +class DeformableBodyPropertiesCfg(PhysxDeformableBodyPropertiesCfg): + """Deprecated: use :class:`PhysxDeformableBodyPropertiesCfg`. + + .. deprecated:: 4.6.x + ``DeformableBodyPropertiesCfg`` has moved to + :class:`PhysxDeformableBodyPropertiesCfg` for PhysX-specific deformable properties + and is scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'DeformableBodyPropertiesCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.schemas.PhysxDeformableBodyPropertiesCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() @configclass diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/spawners/__init__.py index 82b931452f4f..767a7adeb7e6 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/__init__.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Sub-module containing utilities for creating prims in Omniverse for the PhysX backend.""" +"""Sub-module containing PhysX spawner compatibility exports.""" from isaaclab.utils.module import lazy_export diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/spawners/__init__.pyi index 805a9f79fd52..617e4566c1d1 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/__init__.pyi @@ -4,15 +4,15 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ - "DeformableObjectSpawnerCfg", - "spawn_deformable_body_material", "DeformableBodyMaterialCfg", + "PhysxDeformableBodyMaterialCfg", + "PhysxSurfaceDeformableBodyMaterialCfg", "SurfaceDeformableBodyMaterialCfg", ] -from .spawner_cfg import DeformableObjectSpawnerCfg from .materials import ( - spawn_deformable_body_material, DeformableBodyMaterialCfg, + PhysxDeformableBodyMaterialCfg, + PhysxSurfaceDeformableBodyMaterialCfg, SurfaceDeformableBodyMaterialCfg, ) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.py index 4877bb0c1470..8ef9db1d4d8e 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Sub-module for spawners that spawn PhysX-based materials.""" +"""Sub-module for PhysX material configuration compatibility exports.""" from isaaclab.utils.module import lazy_export diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi index 1a3e833d61d9..315458ea5d24 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi @@ -4,17 +4,21 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ - "spawn_deformable_body_material", "DeformableBodyMaterialCfg", + "PhysXDeformableMaterialCfg", + "PhysxDeformableBodyMaterialCfg", "PhysxRigidBodyMaterialCfg", + "PhysxSurfaceDeformableBodyMaterialCfg", "RigidBodyMaterialCfg", "SurfaceDeformableBodyMaterialCfg", ] -from .physics_materials import spawn_deformable_body_material from .physics_materials_cfg import ( DeformableBodyMaterialCfg, + PhysXDeformableMaterialCfg, + PhysxDeformableBodyMaterialCfg, PhysxRigidBodyMaterialCfg, + PhysxSurfaceDeformableBodyMaterialCfg, RigidBodyMaterialCfg, SurfaceDeformableBodyMaterialCfg, ) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials.py b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials.py deleted file mode 100644 index 78920cca9f8d..000000000000 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials.py +++ /dev/null @@ -1,80 +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 - -from __future__ import annotations - -from pxr import Usd, UsdShade - -from isaaclab.sim.utils import clone, safe_set_attribute_on_usd_prim -from isaaclab.sim.utils.stage import get_current_stage -from isaaclab.utils.string import to_camel_case - -from . import physics_materials_cfg - - -@clone -def spawn_deformable_body_material(prim_path: str, cfg: physics_materials_cfg.DeformableBodyMaterialCfg) -> Usd.Prim: - """Create material with deformable-body physics properties. - - Deformable body materials are used to define the physical properties to meshes of a deformable body. These - include the friction and deformable body properties. For more information on deformable body material, - please refer to the documentation on `PxFEMSoftBodyMaterial`_. - - .. 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. This is done to support spawning multiple assets - from a single and cloning the USD prim at the given path expression. - - Args: - prim_path: The prim path or pattern to spawn the asset at. If the prim path is a regex pattern, - then the asset is spawned at all the matching prim paths. - cfg: The configuration for the physics material. - - Returns: - The spawned deformable body material prim. - - Raises: - ValueError: When a prim already exists at the specified prim path and is not a material. - - .. _PxFEMSoftBodyMaterial: https://nvidia-omniverse.github.io/PhysX/physx/5.4.1/_api_build/structPxFEMSoftBodyMaterialModel.html - """ - # get stage handle - stage = get_current_stage() - - # create material prim if no prim exists - if not stage.GetPrimAtPath(prim_path).IsValid(): - _ = UsdShade.Material.Define(stage, prim_path) - - # obtain prim - prim = stage.GetPrimAtPath(prim_path) - # check if prim is a material - if not prim.IsA(UsdShade.Material): - raise ValueError(f"A prim already exists at path: '{prim_path}' but is not a material.") - # ensure PhysX deformable body material API is applied - applied = prim.GetAppliedSchemas() - if "OmniPhysicsDeformableMaterialAPI" not in applied: - prim.AddAppliedSchema("OmniPhysicsDeformableMaterialAPI") - if "PhysxDeformableMaterialAPI" not in applied: - prim.AddAppliedSchema("PhysxDeformableMaterialAPI") - # surface deformable material API - is_surface_deformable = isinstance(cfg, physics_materials_cfg.SurfaceDeformableBodyMaterialCfg) - if is_surface_deformable: - if "OmniPhysicsSurfaceDeformableMaterialAPI" not in applied: - prim.AddAppliedSchema("OmniPhysicsSurfaceDeformableMaterialAPI") - if "PhysxSurfaceDeformableMaterialAPI" not in applied: - prim.AddAppliedSchema("PhysxSurfaceDeformableMaterialAPI") - - # convert to dict - cfg = cfg.to_dict() - del cfg["func"] - # set into PhysX API, gather prefixes for each attribute - property_prefixes = cfg["_property_prefix"] - for prefix, attr_list in property_prefixes.items(): - for attr_name in attr_list: - safe_set_attribute_on_usd_prim( - prim, f"{prefix}:{to_camel_case(attr_name, 'cC')}", cfg[attr_name], camel_case=False - ) - # return the prim - return prim diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py index 7128a080b502..345fadc88704 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py @@ -5,13 +5,15 @@ from __future__ import annotations -import dataclasses import warnings from collections.abc import Callable from typing import ClassVar, Literal -from isaaclab.sim.spawners.materials import PhysicsMaterialCfg -from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg +from isaaclab.sim.spawners.materials.physics_materials_cfg import ( + DeformableBodyMaterialBaseCfg, + RigidBodyMaterialBaseCfg, + SurfaceDeformableBodyMaterialBaseCfg, +) from isaaclab.utils.configclass import configclass @@ -19,50 +21,39 @@ class OmniPhysicsDeformableMaterialCfg: """OmniPhysics material properties for a deformable body. - These properties are set with the prefix ``omniphysics:``. For example, to set the density of the - deformable body, you would set the property ``omniphysics:density``. - - See the OmniPhysics documentation for more information on the available properties. + These properties are set with the prefix ``omniphysics:``. """ - density: float | None = None - """The material density in [kg/m^3]. Defaults to None, in which case the simulation decides the default density.""" + _usd_namespace: ClassVar[str | None] = "omniphysics" + _usd_applied_schema: ClassVar[str | None] = "OmniPhysicsDeformableMaterialAPI" + _usd_field_exceptions: ClassVar[dict] = {} + + density: float = 1000.0 + """The material density [kg/m^3]. Defaults to 1000.0 kg/m^3.""" static_friction: float = 0.25 - """The static friction. Defaults to 0.25.""" + """The static friction coefficient. Defaults to 0.25.""" dynamic_friction: float = 0.25 - """The dynamic friction. Defaults to 0.25.""" + """The dynamic friction coefficient. Defaults to 0.25.""" youngs_modulus: float = 1000000.0 - """The Young's modulus, which defines the body's stiffness. Defaults to 1[MPa]. - - The Young's modulus is a measure of the material's ability to deform under stress. It is measured in Pascals ([Pa]). - """ + """The Young's modulus, which defines the body's stiffness [Pa]. Defaults to 1 MPa.""" poissons_ratio: float = 0.45 - """The Poisson's ratio which defines the body's volume preservation. Defaults to 0.45. - - The Poisson's ratio is a measure of the material's ability to expand in the lateral direction when compressed - in the axial direction. It is a dimensionless number between 0 and 0.5. Using a value of 0.5 will make the - material incompressible. - """ + """The Poisson's ratio which defines the body's volume preservation.""" @configclass class OmniPhysicsSurfaceDeformableMaterialCfg(OmniPhysicsDeformableMaterialCfg): - """OmniPhysics material properties for a surface deformable body, - extending on :class:`OmniPhysicsDeformableMaterialCfg` with additional parameters for surface deformable bodies. + """OmniPhysics material properties for a surface deformable body.""" - These properties are set with the prefix ``omniphysics:``. - For example, to set the surface thickness of the surface deformable body, - you would set the property ``omniphysics:surfaceThickness``. - - See the OmniPhysics documentation for more information on the available properties. - """ + _usd_namespace: ClassVar[str | None] = "omniphysics" + _usd_applied_schema: ClassVar[str | None] = "OmniPhysicsSurfaceDeformableMaterialAPI" + _usd_field_exceptions: ClassVar[dict] = {} surface_thickness: float = 0.01 - """The thickness of the deformable body's surface. Defaults to 0.01 meters ([m]).""" + """The thickness of the deformable body's surface [m]. Defaults to 0.01.""" surface_stretch_stiffness: float = 0.0 """The stretch stiffness of the deformable body's surface. Defaults to 0.0.""" @@ -73,56 +64,88 @@ class OmniPhysicsSurfaceDeformableMaterialCfg(OmniPhysicsDeformableMaterialCfg): surface_bend_stiffness: float = 0.0 """The bend stiffness of the deformable body's surface. Defaults to 0.0.""" - bend_damping: float = 0.0 - """The bend damping for the deformable body's surface. Defaults to 0.0.""" - @configclass class PhysXDeformableMaterialCfg: """PhysX-specific material properties for a deformable body. - These properties are set with the prefix ``physxDeformableBody:``. - For example, to set the elasticity damping of the deformable body, - you would set the property ``physxDeformableBody:elasticityDamping``. - - See the PhysX documentation for more information on the available properties. + These properties are set with the prefix ``physxDeformableMaterial:``. """ + _usd_namespace: ClassVar[str | None] = "physxDeformableMaterial" + _usd_applied_schema: ClassVar[str | None] = "PhysxDeformableMaterialAPI" + _usd_field_exceptions: ClassVar[dict] = {} + elasticity_damping: float = 0.005 """The elasticity damping for the deformable material. Defaults to 0.005.""" @configclass -class DeformableBodyMaterialCfg(PhysicsMaterialCfg, OmniPhysicsDeformableMaterialCfg, PhysXDeformableMaterialCfg): - """Physics material parameters for deformable bodies. +class PhysxDeformableBodyMaterialCfg( + DeformableBodyMaterialBaseCfg, + OmniPhysicsDeformableMaterialCfg, + PhysXDeformableMaterialCfg, +): + """PhysX-specific physics material parameters for deformable bodies.""" - See :meth:`spawn_deformable_body_material` for more information. - """ + func: Callable | str = "isaaclab.sim.spawners.materials.physics_materials:spawn_deformable_body_material" + + +@configclass +class PhysxSurfaceDeformableBodyMaterialCfg( + SurfaceDeformableBodyMaterialBaseCfg, + OmniPhysicsSurfaceDeformableMaterialCfg, + PhysXDeformableMaterialCfg, +): + """PhysX-specific physics material parameters for surface deformable bodies.""" + + _usd_namespace: ClassVar[str | None] = "physxDeformableMaterial" + _usd_applied_schema: ClassVar[str | None] = "PhysxSurfaceDeformableMaterialAPI" - func: Callable | str = "{DIR}.physics_materials:spawn_deformable_body_material" + func: Callable | str = "isaaclab.sim.spawners.materials.physics_materials:spawn_deformable_body_material" - _property_prefix: dict[str, list[str]] = { - "omniphysics": [field.name for field in dataclasses.fields(OmniPhysicsDeformableMaterialCfg)], - "physxDeformableBody": [field.name for field in dataclasses.fields(PhysXDeformableMaterialCfg)], - } - """Mapping between the property prefixes and the properties that fall under each prefix.""" + bend_damping: float = 0.0 + """Damping acting against bend-resistance forces [1/s]. Defaults to 0.0.""" @configclass -class SurfaceDeformableBodyMaterialCfg(DeformableBodyMaterialCfg, OmniPhysicsSurfaceDeformableMaterialCfg): - """Physics material parameters for surface deformable bodies, - extending on :class:`DeformableBodyMaterialCfg` with additional parameters for surface deformable bodies. +class DeformableBodyMaterialCfg(PhysxDeformableBodyMaterialCfg): + """Deprecated: use :class:`PhysxDeformableBodyMaterialCfg`. - See :meth:`spawn_deformable_body_material` for more information. + .. deprecated:: 4.6.x + ``DeformableBodyMaterialCfg`` has moved to + :class:`PhysxDeformableBodyMaterialCfg` for PhysX-specific deformable materials + and is scheduled for removal in 5.0. """ - func: Callable | str = "{DIR}.physics_materials:spawn_deformable_body_material" + def __post_init__(self): + warnings.warn( + "'DeformableBodyMaterialCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.spawners.materials.PhysxDeformableBodyMaterialCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() + - _property_prefix: dict[str, list[str]] = { - "omniphysics": [field.name for field in dataclasses.fields(OmniPhysicsSurfaceDeformableMaterialCfg)], - "physxDeformableBody": [field.name for field in dataclasses.fields(PhysXDeformableMaterialCfg)], - } - """Extend DeformableBodyMaterialCfg properties under each prefix.""" +@configclass +class SurfaceDeformableBodyMaterialCfg(PhysxSurfaceDeformableBodyMaterialCfg): + """Deprecated: use :class:`PhysxSurfaceDeformableBodyMaterialCfg`. + + .. deprecated:: 4.6.x + ``SurfaceDeformableBodyMaterialCfg`` has moved to + :class:`PhysxSurfaceDeformableBodyMaterialCfg` for PhysX-specific surface + deformable materials and is scheduled for removal in 5.0. + """ + + def __post_init__(self): + warnings.warn( + "'SurfaceDeformableBodyMaterialCfg' is deprecated and will be removed in 5.0. Use" + " 'isaaclab_physx.sim.spawners.materials.PhysxSurfaceDeformableBodyMaterialCfg' instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__post_init__() @configclass diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/spawner_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/spawners/spawner_cfg.py deleted file mode 100644 index d4843ec83d3e..000000000000 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/spawner_cfg.py +++ /dev/null @@ -1,39 +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 - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from isaaclab.sim.spawners.spawner_cfg import SpawnerCfg -from isaaclab.utils.configclass import configclass - -if TYPE_CHECKING: - from isaaclab.sim import schemas - - # deformables only supported on PhysX backend - from isaaclab_physx.sim.schemas.schemas_cfg import DeformableBodyPropertiesCfg - - -@configclass -class DeformableObjectSpawnerCfg(SpawnerCfg): - """Configuration parameters for spawning a deformable asset. - - Unlike rigid objects, deformable objects are affected by forces and can deform when subjected to - external forces. This class is used to configure the properties of the deformable object. - - Deformable bodies don't have a separate collision mesh. The collision mesh is the same as the visual mesh. - The collision properties such as rest and collision offsets are specified in the :attr:`deformable_props`. - - Note: - By default, all properties are set to None. This means that no properties will be added or modified - to the prim outside of the properties available by default when spawning the prim. - """ - - mass_props: schemas.MassPropertiesCfg | None = None - """Mass properties.""" - - deformable_props: DeformableBodyPropertiesCfg | None = None - """Deformable body properties. Only supported on PhysX backend for now.""" diff --git a/source/isaaclab_physx/test/assets/test_deformable_object.py b/source/isaaclab_physx/test/assets/test_deformable_object.py index 31a199938c94..7a884fe4e0e7 100644 --- a/source/isaaclab_physx/test/assets/test_deformable_object.py +++ b/source/isaaclab_physx/test/assets/test_deformable_object.py @@ -22,13 +22,18 @@ import torch import warp as wp from flaky import flaky -from isaaclab_physx.assets import DeformableObject, DeformableObjectCfg -from isaaclab_physx.sim import DeformableBodyMaterialCfg, DeformableBodyPropertiesCfg, SurfaceDeformableBodyMaterialCfg +from isaaclab_physx.assets import DeformableObject +from isaaclab_physx.sim import ( + PhysxDeformableBodyMaterialCfg, + PhysxDeformableBodyPropertiesCfg, + PhysxSurfaceDeformableBodyMaterialCfg, +) import carb import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils +from isaaclab.assets import DeformableObjectCfg from isaaclab.sim import build_simulation_context # Temporarily disabled: this suite intermittently aborts with SIGABRT on CI. @@ -72,14 +77,14 @@ def generate_cubes_scene( if has_api: spawn_cfg = sim_utils.MeshCuboidCfg( size=(0.2, 0.2, 0.2), - deformable_props=DeformableBodyPropertiesCfg(kinematic_enabled=kinematic_enabled), + deformable_props=PhysxDeformableBodyPropertiesCfg(kinematic_enabled=kinematic_enabled), ) # Add physics material if provided if material_path is not None: if deformable_type == "surface": - spawn_cfg.physics_material = SurfaceDeformableBodyMaterialCfg() + spawn_cfg.physics_material = PhysxSurfaceDeformableBodyMaterialCfg() else: - spawn_cfg.physics_material = DeformableBodyMaterialCfg() + spawn_cfg.physics_material = PhysxDeformableBodyMaterialCfg() spawn_cfg.physics_material_path = material_path else: spawn_cfg.physics_material = None @@ -108,8 +113,15 @@ def sim(): yield sim -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("material_path", [None, "/World/SoftMaterial", "material"]) +@pytest.mark.parametrize( + "num_cubes, material_path", + [ + (1, "material"), + (2, None), + (2, "/World/SoftMaterial"), + (2, "material"), + ], +) def test_initialization(sim, num_cubes, material_path): """Test initialization for prim with deformable body API at the provided prim path.""" cube_object = generate_cubes_scene(num_cubes=num_cubes, material_path=material_path) @@ -153,10 +165,10 @@ def test_initialization(sim, num_cubes, material_path): assert cube_object.data.root_vel_w.torch.shape == (num_cubes, 3) -@pytest.mark.parametrize("num_cubes", [1, 2]) @pytest.mark.isaacsim_ci -def test_initialization_surface_deformable(sim, num_cubes): +def test_initialization_surface_deformable(sim): """Test initialization of a surface deformable body.""" + num_cubes = 2 cube_object = generate_cubes_scene(num_cubes=num_cubes, deformable_type="surface") # Play sim @@ -203,10 +215,10 @@ def test_initialization_on_device_cpu(): sim.reset() -@pytest.mark.parametrize("num_cubes", [1, 2]) @pytest.mark.isaacsim_ci -def test_set_nodal_state(sim, num_cubes): +def test_set_nodal_state(sim): """Test setting the state of the deformable object.""" + num_cubes = 2 cube_object = generate_cubes_scene(num_cubes=num_cubes) # Play the simulator @@ -241,9 +253,15 @@ def test_set_nodal_state(sim, num_cubes): cube_object.update(sim.cfg.dt) -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("randomize_pos", [True, False]) -@pytest.mark.parametrize("randomize_rot", [True, False]) +@pytest.mark.parametrize( + "num_cubes, randomize_pos, randomize_rot", + [ + (1, False, False), + (1, True, False), + (1, False, True), + (2, True, True), + ], +) @flaky(max_runs=3, min_passes=1) @pytest.mark.isaacsim_ci def test_set_nodal_state_with_applied_transform(num_cubes, randomize_pos, randomize_rot): @@ -289,10 +307,10 @@ def test_set_nodal_state_with_applied_transform(num_cubes, randomize_pos, random torch.testing.assert_close(cube_object.data.root_pos_w.torch, mean_nodal_pos_init, rtol=1e-4, atol=1e-4) -@pytest.mark.parametrize("num_cubes", [2, 4]) @pytest.mark.isaacsim_ci -def test_set_kinematic_targets(sim, num_cubes): +def test_set_kinematic_targets(sim): """Test setting kinematic targets for the deformable object.""" + num_cubes = 2 cube_object = generate_cubes_scene(num_cubes=num_cubes, height=1.0) sim.reset() diff --git a/source/isaaclab_physx/test/sim/test_spawn_materials.py b/source/isaaclab_physx/test/sim/test_spawn_materials.py index 477132c5c5aa..bdd39a2d242e 100644 --- a/source/isaaclab_physx/test/sim/test_spawn_materials.py +++ b/source/isaaclab_physx/test/sim/test_spawn_materials.py @@ -14,7 +14,10 @@ import pytest -from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import DeformableBodyMaterialCfg +from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import ( + PhysxDeformableBodyMaterialCfg, + PhysxSurfaceDeformableBodyMaterialCfg, +) import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext @@ -36,7 +39,7 @@ def sim(): def test_spawn_deformable_body_material(sim): """Test spawning a deformable body material.""" - cfg = DeformableBodyMaterialCfg( + cfg = PhysxDeformableBodyMaterialCfg( density=1.0, dynamic_friction=0.25, youngs_modulus=50000000.0, @@ -52,4 +55,23 @@ def test_spawn_deformable_body_material(sim): assert prim.GetAttribute("omniphysics:dynamicFriction").Get() == cfg.dynamic_friction assert prim.GetAttribute("omniphysics:youngsModulus").Get() == cfg.youngs_modulus assert prim.GetAttribute("omniphysics:poissonsRatio").Get() == cfg.poissons_ratio - assert prim.GetAttribute("physxDeformableBody:elasticityDamping").Get() == pytest.approx(cfg.elasticity_damping) + assert prim.GetAttribute("physxDeformableMaterial:elasticityDamping").Get() == pytest.approx(cfg.elasticity_damping) + + +def test_spawn_surface_deformable_body_material(sim): + """Test spawning a surface deformable body material.""" + cfg = PhysxSurfaceDeformableBodyMaterialCfg( + density=1.0, + youngs_modulus=50000000.0, + poissons_ratio=0.5, + elasticity_damping=0.005, + bend_damping=0.01, + ) + prim = cfg.func("/Looks/SurfaceDeformableBodyMaterial", cfg) + # Check validity + assert prim.IsValid() + assert sim.stage.GetPrimAtPath("/Looks/SurfaceDeformableBodyMaterial").IsValid() + # Check PhysX properties + assert "PhysxSurfaceDeformableMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("physxDeformableMaterial:elasticityDamping").Get() == pytest.approx(cfg.elasticity_damping) + assert prim.GetAttribute("physxDeformableMaterial:bendDamping").Get() == pytest.approx(cfg.bend_damping) diff --git a/source/isaaclab_physx/test/sim/test_spawn_meshes.py b/source/isaaclab_physx/test/sim/test_spawn_meshes.py index 7c24e289ef9f..02a21641071b 100644 --- a/source/isaaclab_physx/test/sim/test_spawn_meshes.py +++ b/source/isaaclab_physx/test/sim/test_spawn_meshes.py @@ -14,8 +14,8 @@ import pytest -from isaaclab_physx.sim.schemas.schemas_cfg import DeformableBodyPropertiesCfg -from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import DeformableBodyMaterialCfg +from isaaclab_physx.sim.schemas.schemas_cfg import PhysxDeformableBodyPropertiesCfg +from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxDeformableBodyMaterialCfg import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext @@ -46,33 +46,13 @@ def sim(): """ -def test_spawn_cone_with_deformable_props(sim): - """Test spawning of UsdGeomMesh prim for a cone with deformable body API.""" - # Spawn cone - cfg = sim_utils.MeshConeCfg( - radius=1.0, - height=2.0, - deformable_props=DeformableBodyPropertiesCfg(deformable_body_enabled=True), - ) - prim = cfg.func("/World/Cone", cfg) - - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Cone").IsValid() - - # Check properties - # Unlike rigid body, deformable body properties are on the mesh prim - prim = sim.stage.GetPrimAtPath("/World/Cone") - assert prim.GetAttribute("omniphysics:deformableBodyEnabled").Get() == cfg.deformable_props.deformable_body_enabled - - def test_spawn_cone_with_deformable_and_mass_props(sim): """Test spawning of UsdGeomMesh prim for a cone with deformable body and mass API.""" # Spawn cone cfg = sim_utils.MeshConeCfg( radius=1.0, height=2.0, - deformable_props=DeformableBodyPropertiesCfg(deformable_body_enabled=True, mass=1.0), + deformable_props=PhysxDeformableBodyPropertiesCfg(deformable_body_enabled=True, mass=1.0), ) prim = cfg.func("/World/Cone", cfg) @@ -81,6 +61,7 @@ def test_spawn_cone_with_deformable_and_mass_props(sim): assert sim.stage.GetPrimAtPath("/World/Cone").IsValid() # Check properties prim = sim.stage.GetPrimAtPath("/World/Cone") + assert prim.GetAttribute("omniphysics:deformableBodyEnabled").Get() == cfg.deformable_props.deformable_body_enabled assert prim.GetAttribute("omniphysics:mass").Get() == cfg.deformable_props.mass # check sim playing @@ -97,8 +78,8 @@ def test_spawn_cone_with_deformable_and_density_props(sim): cfg = sim_utils.MeshConeCfg( radius=1.0, height=2.0, - deformable_props=DeformableBodyPropertiesCfg(deformable_body_enabled=True), - physics_material=DeformableBodyMaterialCfg(density=10.0), + deformable_props=PhysxDeformableBodyPropertiesCfg(deformable_body_enabled=True), + physics_material=PhysxDeformableBodyMaterialCfg(density=10.0), ) prim = cfg.func("/World/Cone", cfg) @@ -113,30 +94,3 @@ def test_spawn_cone_with_deformable_and_density_props(sim): sim.play() for _ in range(10): sim.step() - - -def test_spawn_cone_with_all_deformable_props(sim): - """Test spawning of UsdGeomMesh prim for a cone with all deformable properties.""" - # Spawn cone - cfg = sim_utils.MeshConeCfg( - radius=1.0, - height=2.0, - deformable_props=DeformableBodyPropertiesCfg(mass=1.0), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.75, 0.5)), - physics_material=DeformableBodyMaterialCfg(), - ) - prim = cfg.func("/World/Cone", cfg) - - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Cone").IsValid() - assert sim.stage.GetPrimAtPath("/World/Cone/geometry/material").IsValid() - # Check properties - # -- deformable body - prim = sim.stage.GetPrimAtPath("/World/Cone") - assert prim.GetAttribute("omniphysics:deformableBodyEnabled").Get() is True - - # check sim playing - sim.play() - for _ in range(10): - sim.step() diff --git a/source/isaaclab_tasks/changelog.d/mym-deformable-backend-split.rst b/source/isaaclab_tasks/changelog.d/mym-deformable-backend-split.rst new file mode 100644 index 000000000000..e0cd3070591c --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/mym-deformable-backend-split.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Changed Franka soft-object task configs to use backend-specific deformable cfgs. + Use Newton deformable cfgs from :mod:`isaaclab_newton.sim` or PhysX deformable + cfgs from :mod:`isaaclab_physx.sim` when customizing these tasks. diff --git a/source/isaaclab_tasks/changelog.d/mym-deformable_experimental.minor.rst b/source/isaaclab_tasks/changelog.d/mym-deformable_experimental.minor.rst new file mode 100644 index 000000000000..561a08c3e261 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/mym-deformable_experimental.minor.rst @@ -0,0 +1,6 @@ +Added +^^^^^ + +* Added manager-based Franka soft-body lifting environment + ``Isaac-Lift-Soft-Franka-v0`` as the documented rigid-deformable coupling + task. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_abs_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_abs_env_cfg.py index 388837be4127..e9a151a89a2a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_abs_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_abs_env_cfg.py @@ -3,8 +3,10 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_physx.assets import DeformableObjectCfg +from isaaclab_physx.sim.schemas import PhysxDeformableBodyPropertiesCfg +from isaaclab_physx.sim.spawners.materials import PhysxDeformableBodyMaterialCfg +from isaaclab.assets import DeformableObjectCfg from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg from isaaclab.managers import EventTermCfg as EventTerm @@ -77,6 +79,8 @@ def __post_init__(self): spawn=UsdFileCfg( usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Objects/Teddy_Bear/teddy_bear.usd", scale=(0.01, 0.01, 0.01), + deformable_props=PhysxDeformableBodyPropertiesCfg(), + physics_material=PhysxDeformableBodyMaterialCfg(), ), ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/__init__.py new file mode 100644 index 000000000000..0a4c52549e07 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/__init__.py @@ -0,0 +1,33 @@ +# 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 + +import gymnasium as gym + +from . import agents + +## +# Register Gym environments. +## + + +gym.register( + id="Isaac-Lift-Soft-Franka-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.franka_soft_env_cfg:FrankaSoftEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:FrankaDeformablePPORunnerCfg", + }, +) + +gym.register( + id="Isaac-Lift-Cloth-Franka-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.franka_cloth_env_cfg:FrankaClothEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:FrankaDeformablePPORunnerCfg", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/agents/__init__.py new file mode 100644 index 000000000000..460a30569089 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/agents/__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_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/agents/rsl_rl_ppo_cfg.py new file mode 100644 index 000000000000..dfbad6c6fb8a --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/agents/rsl_rl_ppo_cfg.py @@ -0,0 +1,38 @@ +# 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 isaaclab.utils.configclass import configclass + +from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg + + +@configclass +class FrankaDeformablePPORunnerCfg(RslRlOnPolicyRunnerCfg): + num_steps_per_env = 24 + max_iterations = 50000 + save_interval = 50 + experiment_name = "franka_deformable" + policy = RslRlPpoActorCriticCfg( + init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, + actor_hidden_dims=[256, 128, 64], + critic_hidden_dims=[256, 128, 64], + activation="elu", + ) + algorithm = RslRlPpoAlgorithmCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.006, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-4, + schedule="adaptive", + gamma=0.98, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/franka_cloth_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/franka_cloth_env_cfg.py new file mode 100644 index 000000000000..1f89cf012c2a --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/franka_cloth_env_cfg.py @@ -0,0 +1,242 @@ +# 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 + +"""Configuration for the Franka surface deformable lifting environment.""" + +from __future__ import annotations + +from isaaclab_newton.physics import MJWarpSolverCfg +from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg +from isaaclab_newton.sim.spawners.materials import NewtonSurfaceDeformableBodyMaterialCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg +from isaaclab.assets.deformable_object import DeformableObjectCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils.configclass import configclass + +from isaaclab_contrib.deformable.newton_manager_cfg import CoupledMJWarpVBDSolverCfg, NewtonModelCfg, VBDSolverCfg + +from isaaclab_tasks.utils import PresetCfg + +from . import mdp +from .franka_soft_env_cfg import DeformableNewtonCfg, FrankaSoftEnvCfg, _FrankaSoftSceneCfg +from .franka_soft_env_cfg import EventCfg as FrankaSoftEventCfg + +## +# Scene definition +## + +ROBOT_SHAPE_MATERIAL_MU = 100.0 +"""Franka collision-shape friction coefficient [dimensionless] used for Newton cloth contact.""" + +ROBOT_SHAPE_MATERIAL_BODY_NAMES = ".*" +"""Franka body-name regex receiving :data:`ROBOT_SHAPE_MATERIAL_MU`.""" + + +@configclass +class PhysicsCfg(PresetCfg): + # Newton physics: MJWarp rigid + VBD soft, one-way coupled + # (matches newton/examples/softbody/example_softbody_franka.py) + newton_mjwarp_vdb: DeformableNewtonCfg = DeformableNewtonCfg( + solver_cfg=CoupledMJWarpVBDSolverCfg( + rigid_solver_cfg=MJWarpSolverCfg( + njmax=40, + nconmax=20, + ls_iterations=20, + cone="pyramidal", + impratio=1, + ls_parallel=False, + integrator="implicitfast", + ccd_iterations=100, + ), + soft_solver_cfg=VBDSolverCfg( + iterations=10, + integrate_with_external_rigid_solver=True, + particle_enable_self_contact=False, + particle_collision_detection_interval=-1, + ), + coupling_mode="two_way", + ), + model_cfg=NewtonModelCfg( + soft_contact_ke=1e3, + soft_contact_kd=1e-5, + soft_contact_mu=0.5, + shape_material_ke=1e3, + shape_material_kd=1e-5, + shape_material_mu=1e-4, + ), + num_substeps=10, + use_cuda_graph=True, + ) + + default = newton_mjwarp_vdb + + +@configclass +class DeformableCfg(PresetCfg): + """Preset config for the deformable object, matching the Newton example.""" + + newton_mjwarp_vdb: DeformableObjectCfg = DeformableObjectCfg( + prim_path="/World/envs/env_.*/Deformable", + init_state=DeformableObjectCfg.InitialStateCfg(pos=(0.4, 0.0, 0.2)), + spawn=sim_utils.MeshRectangleCfg( + size=(0.2, 0.2), + resolution=(30, 30), + deformable_props=NewtonDeformableBodyPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.95, 0.85, 0.1)), + physics_material=NewtonSurfaceDeformableBodyMaterialCfg( + density=50.0, + particle_radius=0.005, + tri_ke=5e2, + tri_ka=5e2, + tri_kd=1e-3, + edge_ke=2.0, + edge_kd=1e-3, + ), + ), + ) + + default = newton_mjwarp_vdb + + +@configclass +class FrankaClothSceneCfg(_FrankaSoftSceneCfg): + """Scene for the Franka surface deformable environment.""" + + deformable: DeformableCfg = DeformableCfg() + + # static collidable cubes the cloth drops onto (sits on the table top at z = 0). + # Modeled as a static asset (no rigid body / no DOFs) so adding it does not + # extend the Newton model's joint state. + cube: AssetBaseCfg = AssetBaseCfg( + prim_path="{ENV_REGEX_NS}/Cube", + init_state=AssetBaseCfg.InitialStateCfg(pos=(0.45, 0.0, 0.04)), + spawn=sim_utils.CuboidCfg( + size=(0.03, 0.01, 0.08), + collision_props=sim_utils.CollisionPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.2, 0.25)), + ), + ) + + +@configclass +class ActionsCfg: + """7-dim arm joint position + 1-dim binary gripper.""" + + arm_action = mdp.JointPositionActionCfg( + asset_name="robot", joint_names=["panda_joint.*"], scale=0.1, use_default_offset=True + ) + gripper_action = mdp.BinaryJointPositionActionCfg( + asset_name="robot", + joint_names=["panda_finger.*"], + open_command_expr={"panda_finger_.*": 0.05}, + close_command_expr={"panda_finger_.*": 0.0}, + ) + + +@configclass +class RewardsCfg: + """Lift-to-target reward for a deformable object.""" + + reaching_deformable = RewTerm( + func=mdp.deformable_ee_distance, + params={"std": 0.1, "asset_cfg": SceneEntityCfg("deformable")}, + weight=5.0, + ) + lifting_deformable = RewTerm( + func=mdp.deformable_lifted, + params={"minimal_height": 0.04, "asset_cfg": SceneEntityCfg("deformable")}, + weight=5.0, + ) + deformable_goal_tracking = RewTerm( + func=mdp.deformable_com_goal_distance, + params={ + "std": 0.3, + "minimal_height": 0.075, + "command_name": "deformable_pose", + "asset_cfg": SceneEntityCfg("deformable"), + }, + weight=16.0, + ) + deformable_goal_tracking_fine_grained = RewTerm( + func=mdp.deformable_com_goal_distance, + params={ + "std": 0.05, + "minimal_height": 0.075, + "command_name": "deformable_pose", + "asset_cfg": SceneEntityCfg("deformable"), + }, + weight=5.0, + ) + + action_rate = RewTerm(func=mdp.action_rate_l2, weight=-1e-2) + gripper_close = RewTerm( + func=mdp.gripper_close_action, + params={"action_name": "gripper_action"}, + weight=-1.0, + ) + joint_vel = RewTerm(func=mdp.joint_vel_l2, weight=-1e-2) + joint_torque = RewTerm(func=mdp.joint_torques_l2, weight=-1e-4) + joint_acc = RewTerm(func=mdp.joint_acc_l2, weight=-1e-4) + + +@configclass +class EventCfg(FrankaSoftEventCfg): + """Reset and startup events for the Franka cloth environment.""" + + robot_physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=ROBOT_SHAPE_MATERIAL_BODY_NAMES), + "static_friction_range": (ROBOT_SHAPE_MATERIAL_MU, ROBOT_SHAPE_MATERIAL_MU), + "dynamic_friction_range": (ROBOT_SHAPE_MATERIAL_MU, ROBOT_SHAPE_MATERIAL_MU), + "restitution_range": (0.0, 0.0), + "num_buckets": 1, + }, + ) + + +## +# Environment configuration +## + + +@configclass +class FrankaClothEnvCfg(FrankaSoftEnvCfg): + """Manager-based RL environment: Franka Panda lifting a surface deformable.""" + + # Scene settings + scene: FrankaClothSceneCfg = FrankaClothSceneCfg(num_envs=128, env_spacing=2.5, replicate_physics=True) + # Basic settings + actions: ActionsCfg = ActionsCfg() + # MDP settings + rewards: RewardsCfg = RewardsCfg() + events: EventCfg = EventCfg() + + def __post_init__(self) -> None: + # general settings + self.decimation = 1 + self.episode_length_s = 5.0 + + # simulation settings + self.sim.dt = 1 / 60.0 + self.sim.render_interval = self.decimation + + # viewer settings + self.viewer.origin_type = "asset_root" + self.viewer.asset_name = "robot" + self.viewer.env_index = 0 + self.viewer.eye = (1.25, -1.5, 0.6) + self.viewer.resolution = (1920, 1080) + self.sim.physics = PhysicsCfg() + + # increase franka gripper stiffness + self.scene.robot.actuators["panda_hand"].effort_limit_sim = 500.0 + self.scene.robot.actuators["panda_hand"].stiffness = 2000.0 + self.scene.robot.actuators["panda_hand"].damping = 100.0 diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/franka_soft_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/franka_soft_env_cfg.py new file mode 100644 index 000000000000..9aba49d303c9 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/franka_soft_env_cfg.py @@ -0,0 +1,448 @@ +# 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 + +"""Configuration for the Franka deformable lifting environment. + +The scene mirrors ``newton/examples/softbody/example_softbody_franka.py``: +a Franka Panda manipulator on a tabletop with a tetrahedral deformable object simulated +by VBD. The RL task is to lift the deformable object's centre of mass to a randomised target +position sampled in the robot's root frame. +""" + +from __future__ import annotations + +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg +from isaaclab_newton.sim.spawners.materials import NewtonDeformableBodyMaterialCfg +from isaaclab_physx.physics import PhysxCfg +from isaaclab_physx.sim.schemas import PhysxDeformableBodyPropertiesCfg +from isaaclab_physx.sim.spawners.materials import PhysxDeformableBodyMaterialCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg, AssetBaseCfg +from isaaclab.assets.deformable_object import DeformableObjectCfg +from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.markers import VisualizationMarkersCfg +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors import FrameTransformerCfg +from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg +from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass + +from isaaclab_contrib.deformable.newton_manager_cfg import CoupledMJWarpVBDSolverCfg, NewtonModelCfg, VBDSolverCfg + +from isaaclab_tasks.utils import PresetCfg + +from . import mdp + +## +# Pre-defined configs +## + +from isaaclab_assets.robots.franka import FRANKA_PANDA_CFG # isort:skip + + +## +# Helpers +## + + +# Shared volume material parameters. The Newton config below uses the equivalent Lame parameters. +YOUNGS_MODULUS = 8e4 +POISSONS_RATIO = 0.25 + + +@configclass +class DeformableNewtonCfg(NewtonCfg): + """NewtonCfg extended with model-level contact parameters for deformable objects. + + Uses a distinct class name so that ``_is_kitless_physics`` does not + match it, ensuring Kit is launched for USD deformable spawning. + """ + + model_cfg: NewtonModelCfg | None = None + """Global Newton model parameters applied after builder finalization.""" + + +@configclass +class DeformableCfg(PresetCfg): + """Preset config for the deformable object, matching the Newton example.""" + + newton_mjwarp_vbd: DeformableObjectCfg = DeformableObjectCfg( + prim_path="/World/envs/env_.*/Deformable", + init_state=DeformableObjectCfg.InitialStateCfg(pos=(0.5, 0.0, 0.05)), + spawn=sim_utils.MeshCuboidCfg( + size=(0.3, 0.05, 0.05), + deformable_props=NewtonDeformableBodyPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.95, 0.85, 0.1)), + physics_material=NewtonDeformableBodyMaterialCfg( + density=300.0, + k_mu=YOUNGS_MODULUS / (2.0 * (1.0 + POISSONS_RATIO)), + k_lambda=(YOUNGS_MODULUS * POISSONS_RATIO / ((1.0 + POISSONS_RATIO) * (1.0 - 2.0 * POISSONS_RATIO))), + particle_radius=0.01, + ), + ), + ) + + physx: DeformableObjectCfg = DeformableObjectCfg( + prim_path="/World/envs/env_.*/Deformable", + init_state=DeformableObjectCfg.InitialStateCfg(pos=(0.5, 0.0, 0.05)), + spawn=sim_utils.MeshCuboidCfg( + size=(0.3, 0.05, 0.05), + deformable_props=PhysxDeformableBodyPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.95, 0.85, 0.1)), + physics_material=PhysxDeformableBodyMaterialCfg( + density=300.0, + youngs_modulus=YOUNGS_MODULUS, + poissons_ratio=POISSONS_RATIO, + static_friction=10.0, + dynamic_friction=5.0, + ), + ), + ) + + default = newton_mjwarp_vbd + + +@configclass +class PhysicsCfg(PresetCfg): + # Newton physics: MJWarp rigid + VBD soft, one-way coupled + # (matches newton/examples/softbody/example_softbody_franka.py) + newton_mjwarp_vbd: DeformableNewtonCfg = DeformableNewtonCfg( + solver_cfg=CoupledMJWarpVBDSolverCfg( + rigid_solver_cfg=MJWarpSolverCfg( + njmax=40, + nconmax=20, + ls_iterations=20, + cone="pyramidal", + impratio=1, + ls_parallel=False, + integrator="implicitfast", + ccd_iterations=100, + ), + soft_solver_cfg=VBDSolverCfg( + iterations=10, + integrate_with_external_rigid_solver=True, + particle_enable_self_contact=False, + particle_collision_detection_interval=-1, + ), + coupling_mode="two_way", + ), + model_cfg=NewtonModelCfg( + soft_contact_ke=1e4, + soft_contact_kd=1e-5, + soft_contact_mu=5.0, + shape_material_ke=4e4, + shape_material_kd=1e-5, + shape_material_mu=5.0, + ), + num_substeps=10, + use_cuda_graph=True, + ) + + physx: PhysxCfg = PhysxCfg() + + default = newton_mjwarp_vbd + + +## +# Scene definition +## + + +@configclass +class _FrankaSoftSceneCfg(InteractiveSceneCfg): + """Scene for the Franka deformable environment.""" + + robot: ArticulationCfg = FRANKA_PANDA_CFG.replace(prim_path="/World/envs/env_.*/Robot") + + # end-effector frame for reward shaping + ee_frame: FrameTransformerCfg = FrameTransformerCfg( + prim_path="/World/envs/env_.*/Robot/panda_link0", + debug_vis=False, + target_frames=[ + FrameTransformerCfg.FrameCfg( + prim_path="/World/envs/env_.*/Robot/panda_hand", + name="end_effector", + offset=OffsetCfg(pos=[0.0, 0.0, 0.1034]), + ), + ], + ) + + deformable: DeformableCfg = DeformableCfg() + + # static table matching the Newton example: half-extents (0.4, 0.4, 0.1) → top at z = 0.2 + # NOTE: SeattleLabTable USD has its origin on the top surface, so the deformable object + # sits directly on it when placed at z = 0.05. + table: AssetBaseCfg = AssetBaseCfg( + prim_path="{ENV_REGEX_NS}/Table", + init_state=AssetBaseCfg.InitialStateCfg(pos=[0.5, 0.0, 0.0], rot=[0.0, 0.0, 0.707, 0.707]), + spawn=UsdFileCfg(usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Mounts/SeattleLabTable/table_instanceable.usd"), + ) + + # ground plane + ground: AssetBaseCfg = AssetBaseCfg( + prim_path="/World/GroundPlane", + init_state=AssetBaseCfg.InitialStateCfg(pos=[0.0, 0.0, -1.05]), + spawn=GroundPlaneCfg(), + ) + + # lights + # dome_light: AssetBaseCfg = AssetBaseCfg( + # prim_path="/World/light", + # spawn=sim_utils.DomeLightCfg(color=(0.75, 0.75, 0.75), intensity=2000.0), + # ) + sky_light = AssetBaseCfg( + prim_path="/World/skyLight", + spawn=sim_utils.DomeLightCfg( + intensity=750.0, + texture_file=f"{ISAAC_NUCLEUS_DIR}/Materials/Textures/Skies/PolyHaven/kloofendal_43d_clear_puresky_4k.hdr", + ), + ) + + def __post_init__(self) -> None: + # disable gravity on the arm so the low-PD actuators do not need to fight gravity sag, + # which is the dominant source of steady-state IK tracking error. + self.robot.spawn.rigid_props.disable_gravity = True + + # increase franka gripper stiffness + self.robot.actuators["panda_hand"].effort_limit_sim = 500.0 + self.robot.actuators["panda_hand"].stiffness = 1000.0 + self.robot.actuators["panda_hand"].damping = 100.0 + + +## +# MDP settings +## + + +@configclass +class CommandsCfg: + """Commands for the deformable goal pose (xyz + identity quat in robot root frame).""" + + deformable_pose = mdp.UniformPoseCommandCfg( + asset_name="robot", + body_name="panda_hand", + resampling_time_range=(5.0, 5.0), + debug_vis=True, + ranges=mdp.UniformPoseCommandCfg.Ranges( + pos_x=(0.4, 0.6), + pos_y=(-0.25, 0.25), + pos_z=(0.25, 0.5), + roll=(0.0, 0.0), + pitch=(0.0, 0.0), + yaw=(0.0, 0.0), + ), + # Render the goal as a transparent colored sphere (a point) instead of a coordinate frame. + goal_pose_visualizer_cfg=VisualizationMarkersCfg( + prim_path="/Visuals/Command/goal_pose", + markers={ + "sphere": sim_utils.SphereCfg( + radius=0.03, + visual_material=sim_utils.PreviewSurfaceCfg( + diffuse_color=(0.1, 0.9, 0.2), + opacity=0.4, + ), + ), + }, + ), + ) + + +@configclass +class ActionsCfg: + """7-dim absolute end-effector pose (xyz + quaternion) via differential IK + 1-dim binary gripper.""" + + arm_action = DifferentialInverseKinematicsActionCfg( + asset_name="robot", + joint_names=["panda_joint.*"], + body_name="panda_hand", + controller=DifferentialIKControllerCfg( + command_type="pose", + use_relative_mode=False, + ik_method="dls", + ik_params={"lambda_val": 0.6}, + ), + body_offset=DifferentialInverseKinematicsActionCfg.OffsetCfg(pos=[0.0, 0.0, 0.107]), + ) + gripper_action = mdp.BinaryJointPositionActionCfg( + asset_name="robot", + joint_names=["panda_finger.*"], + open_command_expr={"panda_finger_.*": 0.05}, + close_command_expr={"panda_finger_.*": 0.0}, + ) + + +@configclass +class ObservationsCfg: + """Policy observations: joint state, deformable COM in robot frame, target, last action.""" + + @configclass + class PolicyCfg(ObsGroup): + joint_pos = ObsTerm(func=mdp.joint_pos_rel) + joint_vel = ObsTerm(func=mdp.joint_vel_rel) + deformable_sampled_points = ObsTerm( + func=mdp.DeformableSampledPointsInRobotRootFrame, + params={"asset_cfg": SceneEntityCfg("deformable"), "num_points": 20}, + ) + target_position = ObsTerm(func=mdp.generated_commands, params={"command_name": "deformable_pose"}) + actions = ObsTerm(func=mdp.last_action) + + def __post_init__(self) -> None: + self.enable_corruption = True + self.concatenate_terms = True + + policy: PolicyCfg = PolicyCfg() + + +@configclass +class EventCfg: + """Reset events: robot to default joint config, deformable with small position randomization.""" + + reset_robot_joints = EventTerm( + func=mdp.reset_joints_by_scale, + mode="reset", + params={"position_range": (0.9, 1.1), "velocity_range": (0.0, 0.0)}, + ) + + reset_deformable = EventTerm( + func=mdp.reset_nodal_state_uniform, + mode="reset", + params={ + "position_range": {"x": (-0.05, 0.05), "y": (-0.05, 0.05), "z": (0.0, 0.0)}, + "velocity_range": {}, + "asset_cfg": SceneEntityCfg("deformable"), + }, + ) + + +@configclass +class RewardsCfg: + """Lift-to-target reward for a deformable object.""" + + reaching_deformable = RewTerm( + func=mdp.deformable_ee_distance, + params={"std": 0.1, "asset_cfg": SceneEntityCfg("deformable")}, + weight=5.0, + ) + lifting_deformable = RewTerm( + func=mdp.deformable_lifted, + params={"minimal_height": 0.04, "asset_cfg": SceneEntityCfg("deformable")}, + weight=5.0, + ) + deformable_goal_tracking = RewTerm( + func=mdp.deformable_com_goal_distance, + params={ + "std": 0.3, + "minimal_height": 0.075, + "command_name": "deformable_pose", + "asset_cfg": SceneEntityCfg("deformable"), + }, + weight=16.0, + ) + deformable_goal_tracking_fine_grained = RewTerm( + func=mdp.deformable_com_goal_distance, + params={ + "std": 0.05, + "minimal_height": 0.075, + "command_name": "deformable_pose", + "asset_cfg": SceneEntityCfg("deformable"), + }, + weight=5.0, + ) + + action_rate = RewTerm(func=mdp.action_rate_l2, weight=-1e-2) + gripper_close = RewTerm( + func=mdp.gripper_close_action, + params={"action_name": "gripper_action"}, + weight=-1.0, + ) + joint_vel = RewTerm(func=mdp.joint_vel_l2, weight=-1e-2) + joint_torque = RewTerm(func=mdp.joint_torques_l2, weight=-1e-4) + joint_acc = RewTerm(func=mdp.joint_acc_l2, weight=-1e-4) + + +@configclass +class TerminationsCfg: + """Time out + table bounds/drop termination.""" + + time_out = DoneTerm(func=mdp.time_out, time_out=True) + + deformable_outside_table = DoneTerm( + func=mdp.deformable_outside_table_bounds, + params={ + "x_bounds": (0.0, 1.0), + "y_bounds": (-0.5, 0.5), + "asset_cfg": SceneEntityCfg("deformable"), + }, + ) + + deformable_dropped = DoneTerm( + func=mdp.deformable_com_below_minimum, + params={"minimum_height": -0.1, "asset_cfg": SceneEntityCfg("deformable")}, + ) + + ee_below_table = DoneTerm( + func=mdp.ee_below_minimum, + params={"minimum_height": 0.0, "ee_frame_cfg": SceneEntityCfg("ee_frame")}, + ) + + +## +# Environment configuration +## + + +@configclass +class FrankaSoftSceneCfg(PresetCfg): + newton_mjwarp_vbd: _FrankaSoftSceneCfg = _FrankaSoftSceneCfg(num_envs=128, env_spacing=2.5, replicate_physics=True) + + # PhysX does not support replicating physics for deformable objects + physx: _FrankaSoftSceneCfg = _FrankaSoftSceneCfg(num_envs=128, env_spacing=2.5, replicate_physics=False) + + default = newton_mjwarp_vbd + + +@configclass +class FrankaSoftEnvCfg(ManagerBasedRLEnvCfg): + """Manager-based RL environment: Franka Panda lifting a volume deformable.""" + + # Scene settings + scene: FrankaSoftSceneCfg = FrankaSoftSceneCfg() + # Basic settings + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + commands: CommandsCfg = CommandsCfg() + # MDP settings + rewards: RewardsCfg = RewardsCfg() + terminations: TerminationsCfg = TerminationsCfg() + events: EventCfg = EventCfg() + + def __post_init__(self) -> None: + # general settings + self.decimation = 1 + self.episode_length_s = 5.0 + + # simulation settings + self.sim.dt = 1 / 60.0 + self.sim.render_interval = self.decimation + self.sim.gravity = (0.0, 0.0, 0.0) + self.sim.physics = PhysicsCfg() + + # viewer settings + self.viewer.origin_type = "asset_root" + self.viewer.asset_name = "robot" + self.viewer.env_index = 0 + self.viewer.eye = (1.25, -1.5, 0.75) + self.viewer.resolution = (1920, 1080) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/__init__.py new file mode 100644 index 000000000000..8596e8efb4e7 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/__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 + +"""This sub-module contains the functions that are specific to the environment.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/__init__.pyi new file mode 100644 index 000000000000..bb8b3af19852 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/__init__.pyi @@ -0,0 +1,28 @@ +# 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__ = [ + "deformable_com_below_minimum", + "deformable_ee_distance", + "deformable_com_goal_distance", + "deformable_com_in_robot_root_frame", + "DeformableSampledPointsInRobotRootFrame", + "deformable_lifted", + "deformable_outside_table_bounds", + "ee_below_minimum", + "gripper_close_action", +] + +from .observations import DeformableSampledPointsInRobotRootFrame, deformable_com_in_robot_root_frame +from .rewards import ( + deformable_com_below_minimum, + deformable_ee_distance, + deformable_com_goal_distance, + deformable_lifted, + deformable_outside_table_bounds, + ee_below_minimum, + gripper_close_action, +) +from isaaclab.envs.mdp import * diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/observations.py new file mode 100644 index 000000000000..a1aa2fa0aa64 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/observations.py @@ -0,0 +1,115 @@ +# 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 + +"""Observation functions for the Franka deformable lifting environment.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch +import warp as wp + +from isaaclab.managers import ManagerTermBase, SceneEntityCfg +from isaaclab.utils.math import subtract_frame_transforms + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, DeformableObject + from isaaclab.envs import ManagerBasedRLEnv + + +def deformable_com_in_robot_root_frame( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("deformable"), + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), +) -> torch.Tensor: + """Position of the deformable object's COM in the robot's root frame [m]. + + The COM is the mean of the deformable's nodal positions (see + :attr:`~isaaclab.assets.DeformableObject.data.root_pos_w`). + + Returns: + Tensor of shape ``(num_envs, 3)``. + """ + asset: DeformableObject = env.scene[asset_cfg.name] + robot: Articulation = env.scene[robot_cfg.name] + com_w = wp.to_torch(asset.data.root_pos_w) + com_b, _ = subtract_frame_transforms(wp.to_torch(robot.data.root_pos_w), wp.to_torch(robot.data.root_quat_w), com_w) + return com_b + + +class DeformableSampledPointsInRobotRootFrame(ManagerTermBase): + """Sampled deformable nodal points expressed in the robot's root frame. + + The point indices are sampled on reset, then reused within the episode so + each observed point follows the same material node over time. + """ + + def __init__(self, cfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + + self.asset_cfg: SceneEntityCfg = cfg.params.get("asset_cfg", SceneEntityCfg("deformable")) + self.robot_cfg: SceneEntityCfg = cfg.params.get("robot_cfg", SceneEntityCfg("robot")) + self.num_points: int = cfg.params.get("num_points", 20) + + asset: DeformableObject = env.scene[self.asset_cfg.name] + self.num_nodes = asset.data.nodal_pos_w.shape[1] + self.node_ids = torch.empty(env.num_envs, self.num_points, dtype=torch.long, device=env.device) + self.reset() + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Resample observed deformable nodes for the selected environments.""" + if env_ids is None: + env_ids = slice(None) + num_envs = self.num_envs + else: + num_envs = len(env_ids) + + if self.num_points <= self.num_nodes: + self.node_ids[env_ids] = ( + torch.rand((num_envs, self.num_nodes), device=self.device).topk(self.num_points, dim=1).indices + ) + else: + self.node_ids[env_ids] = torch.randint(self.num_nodes, (num_envs, self.num_points), device=self.device) + + def __call__( + self, + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("deformable"), + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + num_points: int = 20, + ) -> torch.Tensor: + """Sample deformable nodal positions in the robot's root frame. + + Args: + env: The environment instance. + asset_cfg: The deformable object entity. + robot_cfg: The robot entity providing the reference frame. + num_points: Number of sampled points. + + Returns: + Flattened tensor of shape ``(num_envs, 3 * num_points)`` with sampled + point positions [m] in the robot root frame. + """ + asset: DeformableObject = env.scene[asset_cfg.name] + robot: Articulation = env.scene[robot_cfg.name] + if num_points != self.num_points: + raise ValueError( + f"Requested {num_points} deformable points, but this term was initialized with {self.num_points}." + ) + + nodal_pos_w = asset.data.nodal_pos_w.torch + sampled_points_w = nodal_pos_w.gather(1, self.node_ids.unsqueeze(-1).expand(-1, -1, 3)) + + flat_sampled_points_w = sampled_points_w.reshape(-1, 3) + root_pos_w = robot.data.root_pos_w.torch.unsqueeze(1).expand(-1, num_points, -1) + root_quat_w = robot.data.root_quat_w.torch.unsqueeze(1).expand(-1, num_points, -1) + sampled_points_b, _ = subtract_frame_transforms( + root_pos_w.reshape(-1, 3), + root_quat_w.reshape(-1, 4), + flat_sampled_points_w, + ) + return sampled_points_b.view(env.num_envs, -1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/rewards.py new file mode 100644 index 000000000000..9811051cd370 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift_franka_soft/mdp/rewards.py @@ -0,0 +1,167 @@ +# 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 + +"""Reward and termination functions for the Franka deformable lifting environment.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch +import warp as wp + +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils.math import combine_frame_transforms + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, DeformableObject + from isaaclab.envs import ManagerBasedRLEnv + from isaaclab.sensors import FrameTransformer + + +def deformable_lifted( + env: ManagerBasedRLEnv, + minimal_height: float, + asset_cfg: SceneEntityCfg = SceneEntityCfg("deformable"), +) -> torch.Tensor: + """Reward if the deformable COM is above a minimum height. + + Args: + env: The environment instance. + minimal_height: Minimum COM height [m]. + asset_cfg: The deformable object entity. + + Returns: + Reward tensor with shape ``(num_envs,)``. + """ + asset: DeformableObject = env.scene[asset_cfg.name] + com_z = wp.to_torch(asset.data.root_pos_w)[:, 2] + return torch.where(com_z > minimal_height, 1.0, 0.0) + + +def deformable_ee_distance( + env: ManagerBasedRLEnv, + std: float, + asset_cfg: SceneEntityCfg = SceneEntityCfg("deformable"), + ee_frame_cfg: SceneEntityCfg = SceneEntityCfg("ee_frame"), +) -> torch.Tensor: + """Reward reaching the deformable's nearest nodal point with the end-effector. + + Args: + env: The environment instance. + std: The tanh kernel standard deviation [m]. + asset_cfg: The deformable object entity. + ee_frame_cfg: The end-effector frame entity. + + Returns: + Reward tensor with shape ``(num_envs,)``. + """ + asset: DeformableObject = env.scene[asset_cfg.name] + ee_frame: FrameTransformer = env.scene[ee_frame_cfg.name] + nodal_pos_w = wp.to_torch(asset.data.nodal_pos_w) + ee_w = wp.to_torch(ee_frame.data.target_pos_w)[..., 0, :] + distance = torch.linalg.norm(nodal_pos_w - ee_w.unsqueeze(1), dim=2).min(dim=1).values + return 1.0 - torch.tanh(distance / std) + + +def deformable_com_goal_distance( + env: ManagerBasedRLEnv, + std: float, + minimal_height: float, + command_name: str, + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + asset_cfg: SceneEntityCfg = SceneEntityCfg("deformable"), +) -> torch.Tensor: + """Reward tracking of the goal position by the deformable's COM (tanh kernel). + + Only credits when the COM is above ``minimal_height`` (i.e. the object is lifted). + The command is interpreted as ``[x, y, z, qw, qx, qy, qz]`` in the robot's root frame. + """ + robot: Articulation = env.scene[robot_cfg.name] + asset: DeformableObject = env.scene[asset_cfg.name] + command = env.command_manager.get_command(command_name) + des_pos_b = command[:, :3] + des_pos_w, _ = combine_frame_transforms( + wp.to_torch(robot.data.root_pos_w), wp.to_torch(robot.data.root_quat_w), des_pos_b + ) + com_w = wp.to_torch(asset.data.root_pos_w) + distance = torch.linalg.norm(des_pos_w - com_w, dim=1) + return (com_w[:, 2] > minimal_height) * (1.0 - torch.tanh(distance / std)) + + +def gripper_close_action(env: ManagerBasedRLEnv, action_name: str = "gripper_action") -> torch.Tensor: + """Penalty signal for commanding the gripper to close. + + The binary gripper action uses negative float actions for close commands and + non-negative actions for open commands. + + Args: + env: The environment instance. + action_name: Name of the gripper action term. + + Returns: + Tensor with shape ``(num_envs,)`` containing ``1`` when the gripper is + commanded closed and ``0`` otherwise. + """ + gripper_action = env.action_manager.get_term(action_name).raw_actions + return torch.any(gripper_action < 0.0, dim=1).float() + + +def deformable_com_below_minimum( + env: ManagerBasedRLEnv, + minimum_height: float, + asset_cfg: SceneEntityCfg = SceneEntityCfg("deformable"), +) -> torch.Tensor: + """Termination signal when the deformable's COM falls below ``minimum_height`` [m].""" + asset: DeformableObject = env.scene[asset_cfg.name] + com_z = wp.to_torch(asset.data.root_pos_w)[:, 2] + return com_z < minimum_height + + +def deformable_outside_table_bounds( + env: ManagerBasedRLEnv, + x_bounds: tuple[float, float], + y_bounds: tuple[float, float], + asset_cfg: SceneEntityCfg = SceneEntityCfg("deformable"), +) -> torch.Tensor: + """Terminate if any deformable nodal point leaves the table footprint. + + Args: + env: The environment instance. + x_bounds: Allowed x-position range in the environment frame [m]. + y_bounds: Allowed y-position range in the environment frame [m]. + asset_cfg: The deformable object entity. + + Returns: + Boolean tensor with shape ``(num_envs,)``. + """ + asset: DeformableObject = env.scene[asset_cfg.name] + nodal_pos = wp.to_torch(asset.data.nodal_pos_w) - env.scene.env_origins.unsqueeze(1) + outside_x = (nodal_pos[..., 0] < x_bounds[0]) | (nodal_pos[..., 0] > x_bounds[1]) + outside_y = (nodal_pos[..., 1] < y_bounds[0]) | (nodal_pos[..., 1] > y_bounds[1]) + return torch.any(outside_x | outside_y, dim=1) + + +def ee_below_minimum( + env: ManagerBasedRLEnv, + minimum_height: float, + ee_frame_cfg: SceneEntityCfg = SceneEntityCfg("ee_frame"), +) -> torch.Tensor: + """Termination signal when the end-effector falls below ``minimum_height`` [m]. + + Height is measured in the environment frame (``z`` of the EE position with the env + origin subtracted), so the threshold is independent of the environment's xy offset. + + Args: + env: The environment instance. + minimum_height: Minimum allowed EE height in the environment frame [m]. + ee_frame_cfg: The end-effector frame entity. + + Returns: + Boolean tensor with shape ``(num_envs,)``. + """ + ee_frame: FrameTransformer = env.scene[ee_frame_cfg.name] + ee_z = wp.to_torch(ee_frame.data.target_pos_w)[..., 0, 2] - env.scene.env_origins[:, 2] + return ee_z < minimum_height From a36ab7ab56d196b2214c37cb986e10282a089425 Mon Sep 17 00:00:00 2001 From: Daniela Hase <116915287+daniela-hase@users.noreply.github.com> Date: Tue, 19 May 2026 20:54:58 -0700 Subject: [PATCH 121/133] Scene Data Provider Update (#5622) --- docs/source/api/index.rst | 1 + docs/source/api/lab/isaaclab.scene_data.rst | 33 +++++++ docs/source/features/visualization.rst | 2 +- .../multi_backend_architecture.rst | 9 +- .../core-concepts/scene_data_providers.rst | 10 +- .../daniela-move-scene-data.major.rst | 23 +++++ source/isaaclab/docs/CHANGELOG.rst | 12 +-- source/isaaclab/isaaclab/physics/__init__.pyi | 3 - .../isaaclab/physics/physics_manager.py | 2 +- .../isaaclab/physics/scene_data_backend.py | 61 ------------- .../isaaclab/isaaclab/scene_data/__init__.py | 22 +++++ .../isaaclab/isaaclab/scene_data/__init__.pyi | 13 +++ .../isaaclab/scene_data/scene_data_backend.py | 91 +++++++++++++++++++ .../scene_data_provider.py | 2 +- .../isaaclab/sim/simulation_context.py | 7 +- .../isaaclab/visualizers/base_visualizer.py | 2 +- ...test_newton_manager_visualization_state.py | 2 +- .../changelog.d/daniela-move-scene-data.rst | 14 +++ source/isaaclab_newton/docs/CHANGELOG.rst | 4 +- .../isaaclab_newton/physics/newton_manager.py | 36 ++++---- .../changelog.d/daniela-move-scene-data.rst | 7 ++ .../physics/ovphysx_manager.py | 3 +- .../changelog.d/daniela-move-scene-data.rst | 6 ++ source/isaaclab_physx/docs/CHANGELOG.rst | 2 +- .../isaaclab_physx/physics/physx_manager.py | 3 +- .../changelog.d/daniela-move-scene-data.rst | 11 +++ .../kit/kit_visualizer.py | 2 +- .../newton/newton_visualizer.py | 2 +- .../rerun/rerun_visualizer.py | 2 +- .../viser/viser_visualizer.py | 2 +- 30 files changed, 271 insertions(+), 118 deletions(-) create mode 100644 docs/source/api/lab/isaaclab.scene_data.rst create mode 100644 source/isaaclab/changelog.d/daniela-move-scene-data.major.rst delete mode 100644 source/isaaclab/isaaclab/physics/scene_data_backend.py create mode 100644 source/isaaclab/isaaclab/scene_data/__init__.py create mode 100644 source/isaaclab/isaaclab/scene_data/__init__.pyi create mode 100644 source/isaaclab/isaaclab/scene_data/scene_data_backend.py rename source/isaaclab/isaaclab/{scene => scene_data}/scene_data_provider.py (99%) create mode 100644 source/isaaclab_newton/changelog.d/daniela-move-scene-data.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/daniela-move-scene-data.rst create mode 100644 source/isaaclab_physx/changelog.d/daniela-move-scene-data.rst create mode 100644 source/isaaclab_visualizers/changelog.d/daniela-move-scene-data.rst diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 452cadfebcc4..2ff0cf6174be 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -25,6 +25,7 @@ The following modules are available in the ``isaaclab`` extension: physics renderers scene + scene_data sensors sim terrains diff --git a/docs/source/api/lab/isaaclab.scene_data.rst b/docs/source/api/lab/isaaclab.scene_data.rst new file mode 100644 index 000000000000..c36b7666861a --- /dev/null +++ b/docs/source/api/lab/isaaclab.scene_data.rst @@ -0,0 +1,33 @@ +isaaclab.scene_data +=================== + +.. automodule:: isaaclab.scene_data + + .. rubric:: Classes + + .. autosummary:: + + SceneDataProvider + SceneDataBackend + SceneDataFormat + +Scene Data Provider +------------------- + +.. autoclass:: SceneDataProvider + :members: + :undoc-members: + :show-inheritance: + +Scene Data Backend +------------------ + +.. autoclass:: SceneDataBackend + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: SceneDataFormat + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index c23d9d4b5fd2..91a6027e72c5 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -547,7 +547,7 @@ Currently, live plots are only available in the Kit Visualizer. **Viser Visualizer Renderer Requirement** The Viser visualizer requires a Newton model, which is provided automatically by -:class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` regardless of the active physics +:class:`~isaaclab.scene_data.SceneDataProvider` regardless of the active physics backend or renderer. It is compatible with all rendering backends (RTX, Newton Warp, OVRTX). diff --git a/docs/source/overview/core-concepts/multi_backend_architecture.rst b/docs/source/overview/core-concepts/multi_backend_architecture.rst index 8a946b50023c..55c524b22c33 100644 --- a/docs/source/overview/core-concepts/multi_backend_architecture.rst +++ b/docs/source/overview/core-concepts/multi_backend_architecture.rst @@ -56,7 +56,7 @@ This pattern applies to all simulation components: - :class:`~isaaclab_physx.renderers.IsaacRtxRenderer` - :class:`~isaaclab_newton.renderers.NewtonWarpRenderer` * - Scene Data Backend - - :class:`~isaaclab.physics.SceneDataBackend` + - :class:`~isaaclab.scene_data.SceneDataBackend` - ``PhysxSceneDataBackend`` (in :mod:`isaaclab_physx.physics`) - ``NewtonSceneDataBackend`` (in :mod:`isaaclab_newton.physics`) * - Cloner @@ -272,14 +272,15 @@ the established conventions: **2. Implement the physics manager:** -The manager must expose a :class:`~isaaclab.physics.SceneDataBackend` so that -:class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` can read your backend's body +The manager must expose a :class:`~isaaclab.scene_data.SceneDataBackend` so that +:class:`~isaaclab.scene_data.SceneDataProvider` can read your backend's body transforms in a Warp-native format that renderers and visualizers consume directly. .. code-block:: python # isaaclab_mybackend/physics/mybackend_manager.py - from isaaclab.physics import PhysicsManager, SceneDataBackend, SceneDataFormat + from isaaclab.physics import PhysicsManager + from isaaclab.scene_data import SceneDataBackend, SceneDataFormat class MyBackendSceneDataBackend(SceneDataBackend): diff --git a/docs/source/overview/core-concepts/scene_data_providers.rst b/docs/source/overview/core-concepts/scene_data_providers.rst index a279735efb31..9562610e8b02 100644 --- a/docs/source/overview/core-concepts/scene_data_providers.rst +++ b/docs/source/overview/core-concepts/scene_data_providers.rst @@ -1,7 +1,7 @@ Scene Data Provider =================== -The :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` bridges physics simulation +The :class:`~isaaclab.scene_data.SceneDataProvider` bridges physics simulation backends and the visualizers/renderers that consume scene data. It exposes a single Warp-native read path for body transforms regardless of which physics backend (PhysX or Newton) is active, so renderers and visualizers can stay backend-agnostic. @@ -12,7 +12,7 @@ Overview Isaac Lab supports multiple physics backends (PhysX and Newton) and multiple visualizers (Omniverse Kit, Newton, Rerun, Viser). Each combination needs scene data to flow from the physics engine into the renderer or visualizer. The :class:`SceneDataProvider` owns this flow: -the physics manager provides a :class:`~isaaclab.physics.SceneDataBackend` that wraps its +the physics manager provides a :class:`~isaaclab.scene_data.SceneDataBackend` that wraps its native tensor views, and the provider handles format conversion and re-mapping on top of it. .. code-block:: python @@ -28,9 +28,9 @@ Architecture The system has three layers: -1. :class:`~isaaclab.physics.SceneDataBackend` — small interface implemented by each physics +1. :class:`~isaaclab.scene_data.SceneDataBackend` — small interface implemented by each physics manager. It exposes the backend's transform array directly as one of the - :class:`~isaaclab.physics.SceneDataFormat` Warp structs, plus the per-transform prim paths + :class:`~isaaclab.scene_data.SceneDataFormat` Warp structs, plus the per-transform prim paths and total count. There is no per-frame "update" call — the property accessors return live views into the underlying tensor each time they're read. @@ -40,7 +40,7 @@ The system has three layers: - :attr:`SceneDataBackend.transform_count` — number of transforms. - :attr:`SceneDataBackend.transform_paths` — list of USD prim paths, one per transform. -2. :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` — wraps a backend and offers +2. :class:`~isaaclab.scene_data.SceneDataProvider` — wraps a backend and offers format conversion plus index re-mapping: - :meth:`SceneDataProvider.get_transforms` — write the backend's transforms into a diff --git a/source/isaaclab/changelog.d/daniela-move-scene-data.major.rst b/source/isaaclab/changelog.d/daniela-move-scene-data.major.rst new file mode 100644 index 000000000000..39c979e086c4 --- /dev/null +++ b/source/isaaclab/changelog.d/daniela-move-scene-data.major.rst @@ -0,0 +1,23 @@ +Added +^^^^^ + +* Added :mod:`isaaclab.scene_data` sub-package consolidating + :class:`~isaaclab.scene_data.SceneDataProvider`, + :class:`~isaaclab.scene_data.SceneDataBackend`, and + :class:`~isaaclab.scene_data.SceneDataFormat` in a single import location. + +Changed +^^^^^^^ + +* **Breaking:** Moved :class:`~isaaclab.scene_data.SceneDataProvider` from + :mod:`isaaclab.scene.scene_data_provider` and + :class:`~isaaclab.scene_data.SceneDataBackend` / + :class:`~isaaclab.scene_data.SceneDataFormat` from :mod:`isaaclab.physics` + to the new :mod:`isaaclab.scene_data` sub-package. Update imports:: + + # before + from isaaclab.scene.scene_data_provider import SceneDataProvider + from isaaclab.physics import SceneDataBackend, SceneDataFormat + + # after + from isaaclab.scene_data import SceneDataProvider, SceneDataBackend, SceneDataFormat diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 93d4c67b9497..68028a87110e 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -272,9 +272,9 @@ Added ``JointTypeFloatingBase``, OCS2's ``generalizedCoordinatesNum = 6 + actuatedJointsNum``, iDynTree's ``getFreeFloatingMassMatrix`` returning ``(6 + dofs, 6 + dofs)``). -* Added :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.usd_stage`, - :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.num_envs`, and - :meth:`~isaaclab.scene.scene_data_provider.SceneDataProvider.get_camera_transforms` +* Added :attr:`~isaaclab.scene_data.SceneDataProvider.usd_stage`, + :attr:`~isaaclab.scene_data.SceneDataProvider.num_envs`, and + :meth:`~isaaclab.scene_data.SceneDataProvider.get_camera_transforms` so visualizers and renderers can pull stage-derived data through the same Warp-native provider that already exposes transforms. @@ -309,12 +309,12 @@ Changed COM-referenced form can read :attr:`body_com_jacobian_w`. * **Breaking:** :class:`~isaaclab.visualizers.base_visualizer.BaseVisualizer` subclasses now receive a - :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` in + :class:`~isaaclab.scene_data.SceneDataProvider` in :meth:`~isaaclab.visualizers.base_visualizer.BaseVisualizer.initialize` instead of the removed ``BaseSceneDataProvider``. Read environment count - from :attr:`~isaaclab.scene.scene_data_provider.SceneDataProvider.num_envs` + from :attr:`~isaaclab.scene_data.SceneDataProvider.num_envs` and call - :meth:`~isaaclab.scene.scene_data_provider.SceneDataProvider.get_camera_transforms` + :meth:`~isaaclab.scene_data.SceneDataProvider.get_camera_transforms` on the new provider; both replace the previous ``get_metadata()`` / ``get_camera_transforms()`` calls on the legacy interface. diff --git a/source/isaaclab/isaaclab/physics/__init__.pyi b/source/isaaclab/isaaclab/physics/__init__.pyi index d2ceced82eb2..b4f910866210 100644 --- a/source/isaaclab/isaaclab/physics/__init__.pyi +++ b/source/isaaclab/isaaclab/physics/__init__.pyi @@ -8,10 +8,7 @@ __all__ = [ "PhysicsEvent", "PhysicsManager", "PhysicsCfg", - "SceneDataBackend", - "SceneDataFormat", ] from .physics_manager import CallbackHandle, PhysicsEvent, PhysicsManager from .physics_manager_cfg import PhysicsCfg -from .scene_data_backend import SceneDataBackend, SceneDataFormat diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index e6abd8287139..658679d5db01 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any, ClassVar if TYPE_CHECKING: - from isaaclab.physics.scene_data_backend import SceneDataBackend + from isaaclab.scene_data import SceneDataBackend from isaaclab.sim.simulation_context import SimulationContext logger = logging.getLogger(__name__) diff --git a/source/isaaclab/isaaclab/physics/scene_data_backend.py b/source/isaaclab/isaaclab/physics/scene_data_backend.py deleted file mode 100644 index d94bea9e76c9..000000000000 --- a/source/isaaclab/isaaclab/physics/scene_data_backend.py +++ /dev/null @@ -1,61 +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 - -"""Backend interface and data formats for the scene data provider. - -These types live in :mod:`isaaclab.physics` rather than -:mod:`isaaclab.scene.scene_data_provider` so that physics backends -(``isaaclab_physx``, ``isaaclab_newton``) can subclass -:class:`SceneDataBackend` without pulling :mod:`isaaclab.scene` into the -``AppLauncher`` pre-launch import chain. ``AppLauncher._create_app`` pops -``*lab*`` modules from ``sys.modules`` during Kit init and any submodule -imported during that window ends up orphaned from its parent's -``__dict__`` after restoration. -""" - -from __future__ import annotations - -import warp as wp - - -class SceneDataFormat: - @wp.struct - class Vec3_Quat: - positions: wp.array(dtype=wp.vec3f) = None - orientations: wp.array(dtype=wp.quatf) = None - - @wp.struct - class Vec3_Matrix33: - positions: wp.array(dtype=wp.vec3f) = None - orientations: wp.array(dtype=wp.mat33f) = None - - @wp.struct - class Transform: - transforms: wp.array(dtype=wp.transformf) = None - - @wp.struct - class Matrix44: - matrices: wp.array(dtype=wp.mat44f) = None - - -class SceneDataBackend: - @property - def transforms( - self, - ) -> ( - SceneDataFormat.Vec3_Quat | SceneDataFormat.Transform | SceneDataFormat.Matrix44 | SceneDataFormat.Vec3_Matrix33 - ): - """Return the sim backends transforms as one of the SceneDataFormat structs.""" - raise NotImplementedError - - @property - def transform_count(self) -> int: - """Return the number of transforms in the sim backend.""" - raise NotImplementedError - - @property - def transform_paths(self) -> list[str]: - """Return the paths for each transform.""" - raise NotImplementedError diff --git a/source/isaaclab/isaaclab/scene_data/__init__.py b/source/isaaclab/isaaclab/scene_data/__init__.py new file mode 100644 index 000000000000..9e3eaa88579f --- /dev/null +++ b/source/isaaclab/isaaclab/scene_data/__init__.py @@ -0,0 +1,22 @@ +# 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 + +"""Sub-package containing the scene data provider and backend interface. + +The :class:`SceneDataProvider` bridges physics simulation backends and the +consumers that read scene transforms (renderers and visualizers). Physics +backends implement :class:`SceneDataBackend` to expose their current +transforms in one of the :class:`SceneDataFormat` Warp struct variants; +the provider converts and remaps them on demand for each consumer. + +This package is deliberately separate from :mod:`isaaclab.scene` so that +physics backends (``isaaclab_physx``, ``isaaclab_newton``) can subclass +:class:`SceneDataBackend` without pulling :mod:`isaaclab.scene` into the +``AppLauncher`` pre-launch import chain. +""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab/isaaclab/scene_data/__init__.pyi b/source/isaaclab/isaaclab/scene_data/__init__.pyi new file mode 100644 index 000000000000..2e17d33cf195 --- /dev/null +++ b/source/isaaclab/isaaclab/scene_data/__init__.pyi @@ -0,0 +1,13 @@ +# 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__ = [ + "SceneDataBackend", + "SceneDataFormat", + "SceneDataProvider", +] + +from .scene_data_backend import SceneDataBackend, SceneDataFormat +from .scene_data_provider import SceneDataProvider diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py new file mode 100644 index 000000000000..183b08246848 --- /dev/null +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -0,0 +1,91 @@ +# 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 interface and data formats for the scene data provider. + +These types live in :mod:`isaaclab.scene_data` rather than +:mod:`isaaclab.scene` so that physics backends (``isaaclab_physx``, +``isaaclab_newton``) can subclass :class:`SceneDataBackend` without pulling +:mod:`isaaclab.scene` into the ``AppLauncher`` pre-launch import chain. +``AppLauncher._create_app`` pops ``*lab*`` modules from ``sys.modules`` +during Kit init and any submodule imported during that window ends up +orphaned from its parent's ``__dict__`` after restoration. +""" + +from __future__ import annotations + +import warp as wp + +# Under Sphinx ``autodoc_mock_imports``, ``wp.struct`` is a ``_MockObject`` +# that replaces the decorated class with another mock, hiding its docstring +# and fields from autodoc. Fall back to an identity decorator when warp is +# mocked so the documentation builds from the source classes directly. +if getattr(wp, "__sphinx_mock__", False): + + def wp_struct(cls): + return cls +else: + wp_struct = wp.struct + + +class SceneDataFormat: + """Warp struct variants describing the transform layouts that a + :class:`SceneDataBackend` may publish to consumers. + """ + + @wp_struct + class Vec3_Quat: + """Separate position and quaternion arrays.""" + + positions: wp.array(dtype=wp.vec3f) = None + """Per-transform positions [m].""" + + orientations: wp.array(dtype=wp.quatf) = None + """Per-transform orientations as quaternions.""" + + @wp_struct + class Vec3_Matrix33: + """Separate position and rotation-matrix arrays.""" + + positions: wp.array(dtype=wp.vec3f) = None + """Per-transform positions [m].""" + + orientations: wp.array(dtype=wp.mat33f) = None + """Per-transform orientations as 3x3 rotation matrices.""" + + @wp_struct + class Transform: + """Packed warp transforms (position + quaternion).""" + + transforms: wp.array(dtype=wp.transformf) = None + """Per-transform packed position + orientation transforms [m, -].""" + + @wp_struct + class Matrix44: + """Packed 4x4 homogeneous transform matrices.""" + + matrices: wp.array(dtype=wp.mat44f) = None + """Per-transform 4x4 homogeneous transform matrices [m].""" + + +class SceneDataBackend: + @property + def transforms( + self, + ) -> ( + SceneDataFormat.Vec3_Quat | SceneDataFormat.Transform | SceneDataFormat.Matrix44 | SceneDataFormat.Vec3_Matrix33 + ): + """Return the sim backends transforms as one of the SceneDataFormat structs.""" + raise NotImplementedError + + @property + def transform_count(self) -> int: + """Return the number of transforms in the sim backend.""" + raise NotImplementedError + + @property + def transform_paths(self) -> list[str]: + """Return the paths for each transform.""" + raise NotImplementedError diff --git a/source/isaaclab/isaaclab/scene/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py similarity index 99% rename from source/isaaclab/isaaclab/scene/scene_data_provider.py rename to source/isaaclab/isaaclab/scene_data/scene_data_provider.py index a44700dd575c..723652339b46 100644 --- a/source/isaaclab/isaaclab/scene/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -13,7 +13,7 @@ import numpy as np import warp as wp -from isaaclab.physics.scene_data_backend import SceneDataBackend, SceneDataFormat +from .scene_data_backend import SceneDataBackend, SceneDataFormat if TYPE_CHECKING: from pxr import Usd diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 9975cf13100d..19712bde96b8 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -29,7 +29,7 @@ resolve_scene_data_requirements, ) from isaaclab.renderers.render_context import RenderContext -from isaaclab.scene.scene_data_provider import SceneDataProvider +from isaaclab.scene_data import SceneDataProvider 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 @@ -772,11 +772,6 @@ def update_visualizers(self, dt: float, skip_app_pumping: bool = False) -> None: if any(viz.supports_markers() for viz in self._visualizers): self.vis_marker_registry.dispatch_callbacks() - # Marker callbacks update VisualizationMarkers state; visualizer step() - # consumes that state later in this method. - if any(viz.supports_markers() for viz in self._visualizers): - self.vis_marker_registry.dispatch_callbacks() - visualizers_to_remove = [] for viz in self._visualizers: try: diff --git a/source/isaaclab/isaaclab/visualizers/base_visualizer.py b/source/isaaclab/isaaclab/visualizers/base_visualizer.py index 9e41ddce3402..4084aaef559b 100644 --- a/source/isaaclab/isaaclab/visualizers/base_visualizer.py +++ b/source/isaaclab/isaaclab/visualizers/base_visualizer.py @@ -16,7 +16,7 @@ from urllib.parse import urlparse if TYPE_CHECKING: - from isaaclab.scene.scene_data_provider import SceneDataProvider + from isaaclab.scene_data import SceneDataProvider from .visualizer_cfg import VisualizerCfg diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index 7c3abf9f300a..d444799620f5 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -11,7 +11,7 @@ :meth:`NewtonManager._build_visualization_model_from_stage`), and :meth:`NewtonManager.update_visualization_state` must copy fresh transforms into ``_state_0.body_q`` via the new -:class:`~isaaclab.scene.scene_data_provider.SceneDataProvider`. +:class:`~isaaclab.scene_data.SceneDataProvider`. """ from __future__ import annotations diff --git a/source/isaaclab_newton/changelog.d/daniela-move-scene-data.rst b/source/isaaclab_newton/changelog.d/daniela-move-scene-data.rst new file mode 100644 index 000000000000..1c077dc3f68a --- /dev/null +++ b/source/isaaclab_newton/changelog.d/daniela-move-scene-data.rst @@ -0,0 +1,14 @@ +Changed +^^^^^^^ + +* Updated imports of :class:`~isaaclab.scene_data.SceneDataBackend` and + :class:`~isaaclab.scene_data.SceneDataFormat` to their new location in + :mod:`isaaclab.scene_data` (previously :mod:`isaaclab.physics`). + +Fixed +^^^^^ + +* Fixed :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state` + retrieving the wrong simulation context. It now uses + :meth:`~isaaclab.sim.SimulationContext.instance` instead of the stale + ``PhysicsManager._sim`` reference. diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index 836ba4a2c133..03ceddc82005 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -116,7 +116,7 @@ Added USD stage (via :meth:`~isaaclab_newton.physics.NewtonManager.instantiate_builder_from_stage`) and refreshes ``state_0.body_q`` from rigid-body transforms supplied by the - :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` each render + :class:`~isaaclab.scene_data.SceneDataProvider` each render frame. Changed @@ -149,7 +149,7 @@ Removed (``NewtonSceneDataProvider``). Replace direct uses with :meth:`~isaaclab_newton.physics.NewtonManager.get_model` / :meth:`~isaaclab_newton.physics.NewtonManager.get_state` and the - Warp-native :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider`. + Warp-native :class:`~isaaclab.scene_data.SceneDataProvider`. Fixed ^^^^^ diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 73f1ac535049..f41afa85678b 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -33,7 +33,9 @@ from newton.sensors import SensorIMU as NewtonSensorIMU from newton.solvers import SolverBase, SolverKamino, SolverNotifyFlags -from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager, SceneDataBackend, SceneDataFormat +from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager +from isaaclab.scene_data import SceneDataBackend, SceneDataFormat +from isaaclab.sim import SimulationContext from isaaclab.sim.utils.newton_model_utils import replace_newton_shape_colors from isaaclab.sim.utils.stage import get_current_stage from isaaclab.utils import checked_apply @@ -264,8 +266,8 @@ class NewtonManager(PhysicsManager): # Visualization-only state used when the sim backend is PhysX. Populated # lazily in :meth:`_ensure_visualization_model` and updated each render # frame in :meth:`update_visualization_state`. - _visualization_scene_data: SceneDataFormat.Transform | None = None - _visualization_mapping: wp.array | None = None + _scene_data: SceneDataFormat.Transform | None = None + _scene_data_mapping: wp.array | None = None # Views list for assets to register their views _views: list = [] @@ -609,7 +611,7 @@ def close(cls) -> None: super().close() @classmethod - def get_scene_data_backend(cls) -> SceneDataBackend: + def get_scene_data_backend(cls) -> SceneDataBackend | None: """Return the SceneDataBackend for the SceneDataProvider.""" return cls._scene_data_backend @@ -683,8 +685,8 @@ def clear(cls): NewtonManager._transforms_dirty = False NewtonManager._particles_dirty = False NewtonManager._up_axis = "Z" - NewtonManager._visualization_scene_data = None - NewtonManager._visualization_mapping = None + NewtonManager._scene_data = None + NewtonManager._scene_data_mapping = None NewtonManager._model_changes = set() NewtonManager._scene_data_backend = None NewtonManager._cl_pending_sites = {} @@ -1573,7 +1575,7 @@ def _ensure_visualization_model(cls) -> None: :meth:`_build_visualization_model_from_stage` and finalizing the resulting :class:`~newton.ModelBuilder`. Per-frame body transforms are pushed into ``_state_0.body_q`` by :meth:`update_visualization_state` using the new - :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider`. + :class:`~isaaclab.scene_data.SceneDataProvider`. """ if cls._model is not None and cls._state_0 is not None: @@ -1767,7 +1769,7 @@ def update_visualization_state(cls, scene_data_provider=None) -> None: already advanced by :meth:`step` / forward kinematics. PhysX sim backend: pull rigid-body transforms from the - :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` and write + :class:`~isaaclab.scene_data.SceneDataProvider` and write them into the shadow ``_state_0.body_q`` so Newton-native consumers (Newton renderer, Newton/Rerun/Viser visualizers, OVRTX renderer, Newton GL video) see fresh poses. @@ -1782,24 +1784,20 @@ def update_visualization_state(cls, scene_data_provider=None) -> None: return sdp = scene_data_provider if sdp is None: - sim = PhysicsManager._sim + sim = SimulationContext.instance() if sim is not None: sdp = sim.get_scene_data_provider() if sdp is None: return - if cls._visualization_scene_data is None: - cls._visualization_scene_data = SceneDataFormat.Transform() - if cls._visualization_mapping is None: + if cls._scene_data is None: + cls._scene_data = SceneDataFormat.Transform() + if cls._scene_data_mapping is None: body_paths = list(getattr(cls._model, "body_label", None) or []) - cls._visualization_mapping = sdp.create_mapping(body_paths) + cls._scene_data_mapping = sdp.create_mapping(body_paths) - cls._visualization_scene_data.transforms = cls._state_0.body_q - sdp.get_transforms( - cls._visualization_scene_data, - mapping=cls._visualization_mapping, - allow_passthrough=False, - ) + cls._scene_data.transforms = cls._state_0.body_q + sdp.get_transforms(cls._scene_data, mapping=cls._scene_data_mapping) @classmethod def get_state_1(cls) -> State: diff --git a/source/isaaclab_ovphysx/changelog.d/daniela-move-scene-data.rst b/source/isaaclab_ovphysx/changelog.d/daniela-move-scene-data.rst new file mode 100644 index 000000000000..3a830a73152a --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/daniela-move-scene-data.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* Updated imports of :class:`~isaaclab.scene_data.SceneDataBackend` and + :class:`~isaaclab.scene_data.SceneDataFormat` in + :mod:`~isaaclab_ovphysx.physics.ovphysx_manager` to their new location in + :mod:`isaaclab.scene_data` (previously :mod:`isaaclab.physics`). diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 729b06befa95..14eb03ddc5fc 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -24,7 +24,8 @@ from pxr import UsdPhysics -from isaaclab.physics import PhysicsEvent, PhysicsManager, SceneDataBackend, SceneDataFormat +from isaaclab.physics import PhysicsEvent, PhysicsManager +from isaaclab.scene_data import SceneDataBackend, SceneDataFormat if TYPE_CHECKING: from isaaclab.sim.simulation_context import SimulationContext diff --git a/source/isaaclab_physx/changelog.d/daniela-move-scene-data.rst b/source/isaaclab_physx/changelog.d/daniela-move-scene-data.rst new file mode 100644 index 000000000000..d2e0b7df99ad --- /dev/null +++ b/source/isaaclab_physx/changelog.d/daniela-move-scene-data.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Updated imports of :class:`~isaaclab.scene_data.SceneDataBackend` and + :class:`~isaaclab.scene_data.SceneDataFormat` to their new location in + :mod:`isaaclab.scene_data` (previously :mod:`isaaclab.physics`). diff --git a/source/isaaclab_physx/docs/CHANGELOG.rst b/source/isaaclab_physx/docs/CHANGELOG.rst index 8cee80884415..416ad468b059 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -100,7 +100,7 @@ Removed * **Breaking:** Removed the ``isaaclab_physx.scene_data_providers`` package (``PhysxSceneDataProvider``). The Warp-native - :class:`~isaaclab.scene.scene_data_provider.SceneDataProvider` now exposes + :class:`~isaaclab.scene_data.SceneDataProvider` now exposes PhysX rigid-body transforms via :class:`~isaaclab_physx.physics.PhysxSceneDataBackend`, and the PhysX→Newton state sync used by Newton visualizers/renderers moved to diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 00e9ed3007d6..df30c9e268a7 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -32,7 +32,8 @@ from pxr import Sdf, Usd, UsdPhysics, UsdUtils import isaaclab.sim as sim_utils -from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager, SceneDataBackend, SceneDataFormat +from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager +from isaaclab.scene_data import SceneDataBackend, SceneDataFormat from isaaclab.utils.string import to_camel_case if TYPE_CHECKING: diff --git a/source/isaaclab_visualizers/changelog.d/daniela-move-scene-data.rst b/source/isaaclab_visualizers/changelog.d/daniela-move-scene-data.rst new file mode 100644 index 000000000000..ea709a9f96b3 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/daniela-move-scene-data.rst @@ -0,0 +1,11 @@ +Fixed +^^^^^ + +* Updated ``configclass`` imports in :mod:`isaaclab_visualizers.kit`, + :mod:`isaaclab_visualizers.newton`, :mod:`isaaclab_visualizers.rerun`, and + :mod:`isaaclab_visualizers.viser` visualizer configs to import from + :mod:`isaaclab.utils.configclass` directly, matching the lazy-import layout + introduced in :mod:`isaaclab.utils`. +* Updated ``test_visualizer_cartpole_integration`` to read the tiled camera + RGB output via the ``.torch`` accessor, matching the Warp-backed camera + data API. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 133c565ccbad..ec51d8b183d4 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: - from isaaclab.scene.scene_data_provider import SceneDataProvider + from isaaclab.scene_data import SceneDataProvider _DEFAULT_VIEWPORT_NAME = "Visualizer Viewport" diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 565c16b0251b..43aa7d628e80 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -24,7 +24,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: - from isaaclab.scene.scene_data_provider import SceneDataProvider + from isaaclab.scene_data import SceneDataProvider class NewtonViewerGL(ViewerGL): diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py index cc55dcef7fa5..c83439a5b9f3 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py @@ -28,7 +28,7 @@ from .rerun_visualizer_cfg import RerunVisualizerCfg if TYPE_CHECKING: - from isaaclab.scene.scene_data_provider import SceneDataProvider + from isaaclab.scene_data import SceneDataProvider logger = logging.getLogger(__name__) diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py index 5848690263f1..10d117873c7d 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: - from isaaclab.scene.scene_data_provider import SceneDataProvider + from isaaclab.scene_data import SceneDataProvider def _disable_viser_runtime_client_rebuild_if_bundled() -> None: From c707450649a8af8841d7247890b234712cfca755 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Wed, 20 May 2026 07:05:42 +0200 Subject: [PATCH 122/133] [OvPhysX] Adds FrameView for the OVPhysX backend (#5678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds `OvPhysxFrameView`, the OVPhysX-backend implementation of :class:`isaaclab.sim.views.FrameView`. The factory in `isaaclab.sim.views.frame_view` now dispatches to it when the active backend is `"ovphysx"`, mirroring the `FabricFrameView` (PhysX) and `NewtonSiteFrameView` (Newton) entries. `OvPhysxFrameView` reads live body poses via the OVPhysX wheel's `create_tensor_binding(pattern, tensor_type=RIGID_BODY_POSE)` API and exposes them as `wp.transformf` arrays without going through the SceneDataProvider. Sensors built on top of `FrameView` (e.g. RayCaster, the ContactSensor pose-tracking path) can now read body transforms under OVPhysX with the same API surface they use under PhysX and Newton. Two scaling-related changes in `InteractiveScene` and `OvPhysxManager` make OVPhysX fan-out behave like the other backends: * `InteractiveScene` now USD-replicates the per-env asset subtree for every backend (`clone_usd=True`), including OVPhysX. The `find_matching_prims`-based `_num_envs` discovery used by many sensors now sees N prims under OVPhysX, matching PhysX/Newton. * `OvPhysxManager` strips `env_1..N` from the in-memory USD before handing it to `physx.add_usd`. Without this, the wheel's per-USD-path enumeration scales O(num_envs * bodies) and hangs at 4k+ envs — even though physics still uses a single replicated template. The env_0 USD is what the wheel ingests; physics replication happens via `physx.clone`, identical to the prior `clone_usd=False` path. A short comment on the helper documents the assumption that the per-env subtrees are structurally homogeneous. Tested manually with `Isaac-Velocity-Rough-Anymal-D-v0` at 4096 envs under the OVPhysX preset; iter time matches the prior `clone_usd=False` baseline. Fixes #N/A ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots N/A — backend infrastructure change with no UI surface. ## 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 added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../ovphysx-frameview-dispatch.rst | 8 + .../isaaclab/scene/interactive_scene.py | 12 +- .../isaaclab/isaaclab/sim/views/frame_view.py | 13 +- .../changelog.d/ovphysx-frameview.minor.rst | 11 + .../physics/ovphysx_manager.py | 98 +- .../isaaclab_ovphysx/sim/__init__.py | 10 + .../isaaclab_ovphysx/sim/__init__.pyi | 6 + .../isaaclab_ovphysx/sim/views/__init__.py | 10 + .../isaaclab_ovphysx/sim/views/__init__.pyi | 10 + .../sim/views/ovphysx_frame_view.py | 890 ++++++++++++++++++ source/isaaclab_ovphysx/test/sim/__init__.py | 4 + .../test/sim/test_views_xform_prim_ovphysx.py | 163 ++++ 12 files changed, 1227 insertions(+), 8 deletions(-) create mode 100644 source/isaaclab/changelog.d/ovphysx-frameview-dispatch.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/ovphysx-frameview.minor.rst create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sim/__init__.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sim/__init__.pyi create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/__init__.py create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/__init__.pyi create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_frame_view.py create mode 100644 source/isaaclab_ovphysx/test/sim/__init__.py create mode 100644 source/isaaclab_ovphysx/test/sim/test_views_xform_prim_ovphysx.py diff --git a/source/isaaclab/changelog.d/ovphysx-frameview-dispatch.rst b/source/isaaclab/changelog.d/ovphysx-frameview-dispatch.rst new file mode 100644 index 000000000000..030589c05f9c --- /dev/null +++ b/source/isaaclab/changelog.d/ovphysx-frameview-dispatch.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab.sim.views.FrameView` dispatch under the OVPhysX + backend. ``FrameView(...)`` now routes to + :class:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView` instead of silently + falling through to ``FabricFrameView``, which returned stale USD spawn + poses for sensor frames riding on physics bodies. diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index b8df495d1e79..1d1afa2cfad9 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -168,10 +168,14 @@ def __init__(self, cfg: InteractiveSceneCfg): clone_in_fabric=self.cfg.clone_in_fabric, device=self.device, physics_clone_fn=physics_clone_fn, - # For ovphysx: env_1..N are created by physx.clone() in the physics - # runtime after add_usd(). USD replication of the asset hierarchy - # to env_1..N is skipped — only env_0 needs physics prims in the USD. - clone_usd=not self.physics_backend.startswith("ovphysx"), + # USD replication runs for every backend. PhysX/Newton need per-env + # USD prims for sensor discovery. For OVPhysX, the per-env USD + # subtrees are layered on TOP of the physics-side ``physx.clone()`` + # replicas -- PhysX is indifferent to additional USD content and + # the two layers don't conflict. Probing whether this assumption + # holds in practice; revert to ``not startswith("ovphysx")`` if + # ``physx.clone()`` errors on already-populated targets. + clone_usd=True, ) # create source prim diff --git a/source/isaaclab/isaaclab/sim/views/frame_view.py b/source/isaaclab/isaaclab/sim/views/frame_view.py index ea9d5bfbeea9..95c5f0bb6850 100644 --- a/source/isaaclab/isaaclab/sim/views/frame_view.py +++ b/source/isaaclab/isaaclab/sim/views/frame_view.py @@ -25,11 +25,18 @@ class FrameView(FactoryBase, BaseFrameView): - **PhysX / no backend**: :class:`~isaaclab_physx.sim.views.FabricFrameView` (Fabric GPU acceleration with USD fallback). + - **OVPhysX**: :class:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView` + (Warp-native, reads body poses via an OVPhysX ``RIGID_BODY_POSE`` + tensor binding). - **Newton**: :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` - (GPU-resident site-based transforms). + (Warp-native, reads ``body_q`` from the Newton state). """ - _backend_class_names = {"physx": "FabricFrameView", "newton": "NewtonSiteFrameView"} + _backend_class_names = { + "physx": "FabricFrameView", + "ovphysx": "OvPhysxFrameView", + "newton": "NewtonSiteFrameView", + } @classmethod def _get_backend(cls, *args, **kwargs) -> str: @@ -41,6 +48,8 @@ def _get_backend(cls, *args, **kwargs) -> str: manager_name = ctx.physics_manager.__name__.lower() if "newton" in manager_name: return "newton" + if "ovphysx" in manager_name: + return "ovphysx" return "physx" def __new__(cls, *args, **kwargs) -> BaseFrameView: diff --git a/source/isaaclab_ovphysx/changelog.d/ovphysx-frameview.minor.rst b/source/isaaclab_ovphysx/changelog.d/ovphysx-frameview.minor.rst new file mode 100644 index 000000000000..e3e0f653d4bf --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/ovphysx-frameview.minor.rst @@ -0,0 +1,11 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView`, a + Warp-native batched-prim view that reads world poses from the OVPhysX + scene data provider's ``body_q`` array. Mirrors + :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` in semantics + and API: ``set_world_poses`` / ``set_local_poses`` update the view's + internal ``site_local`` buffer and never mutate the physics state. + Scales and visibility delegate to a lazy internal + :class:`~isaaclab.sim.views.UsdFrameView`. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 14eb03ddc5fc..8f7e1e576f17 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -406,6 +406,84 @@ def get_scene_data_backend(cls) -> SceneDataBackend: # Internal helpers # ------------------------------------------------------------------ + @staticmethod + def _export_env0_only_stage(sim_stage: Any, target_file: str) -> None: + """Export the simulation stage to ``target_file`` with env_1..N stripped. + + Writes a USD file containing every prim under the live stage **except** + ``/World/envs/env_`` for ``i != 0``. Globals (``/physicsScene``, + ``/World/ground``, lights, materials, etc.) and ``/World/envs/env_0`` are + retained. ``physx.clone()`` is then expected to repopulate env_1..N at + the physics layer with proper clone lineage so that subsequent + ``create_tensor_binding`` calls hit the wheel's fast path. + + Implementation: export the full stage to disk, then re-open the result + as an :class:`Sdf.Layer` and delete env_1..N prim specs in place. This + avoids mutating the live stage (which other consumers -- sensors, + visualizers -- still see in its full N-env form). + + Limitations: + * **Homogeneous-env assumption.** Every env is treated as an + identical copy of env_0 from the physics runtime's point of view. + Anything authored *only* under ``/World/envs/env_`` for + ``i != 0`` (per-env mass overrides, per-env friction, per-env + collision filters, etc.) is dropped from the file handed to + ``physx.add_usd`` and therefore not seen by PhysX. Sensors and + visualizers still see those overrides in USD (the live stage is + unmodified), so a divergence is possible. Per-env physics state + must instead be written via the runtime APIs + (``RigidObject.write_root_state_to_sim_index``, etc.). + * **Global path convention.** Any physics-relevant prim that lives + under ``/World/envs/env_/`` (e.g. an asset-specific + ``PhysicsScene``, a per-env material) gets stripped. Globals must + live outside ``/World/envs`` (or under ``/World/envs/env_0``) to + survive the export. + * **Static topology.** Envs added or removed at runtime after + warmup are not supported by ``physx.clone()`` lineage and would + require a re-warmup with a re-exported stage. + + Args: + sim_stage: Live USD stage held by ``SimulationContext``. + target_file: Output ``.usda`` file path. Overwritten if it exists. + """ + from pxr import Sdf # noqa: PLC0415 + + # Step 1: full flatten-export of the live stage. We pass the full file + # to ``Sdf.Layer.OpenAsAnonymous`` so the edits below don't write back + # to the source layer on disk. + sim_stage.Export(target_file) + + # Step 2: open the exported file as an editable Sdf layer and delete + # ``/World/envs/env_`` children for digits != 0. Walking the + # ``/World/envs`` ``PrimSpec``'s ``nameChildren`` keeps us scoped to + # the env-namespace and leaves the rest of the stage untouched. + layer = Sdf.Layer.FindOrOpen(target_file) + if layer is None: + raise RuntimeError( + f"OvPhysxManager: failed to re-open exported USD layer at {target_file!r} for env-scoping." + ) + envs_spec = layer.GetPrimAtPath("/World/envs") + if envs_spec is None or not envs_spec: + # No /World/envs in the stage (single-env or non-IsaacLab scene); nothing to scope. + logger.debug("OvPhysxManager: no /World/envs prim — exported stage as-is.") + return + + env_name_re = re.compile(r"^env_(\d+)$") + names_to_remove = [ + child_name + for child_name in list(envs_spec.nameChildren.keys()) + if (match := env_name_re.match(child_name)) and match.group(1) != "0" + ] + for child_name in names_to_remove: + del envs_spec.nameChildren[child_name] + + if names_to_remove: + layer.Export(target_file) + logger.info( + "OvPhysxManager: stripped %d env_ subtrees from exported USD (kept env_0 + globals)", + len(names_to_remove), + ) + @classmethod def _warmup_and_load(cls) -> None: """Export the USD stage and load it into the ovphysx runtime. @@ -449,11 +527,27 @@ def _warmup_and_load(cls) -> None: cls._configure_physx_scene_prim(scene_prim, PhysicsManager._cfg, ovphysx_device) # Export the current USD stage to a temporary file so ovphysx can load it. + # + # When ``InteractiveScene`` runs with ``clone_usd=True``, the live USD + # stage carries env_0..N's full asset subtrees as authored copies. + # Handing that stage to ``physx.add_usd`` would make the wheel ingest + # all 4096 envs as independent USD-defined bodies, defeating the + # ``physx.clone()`` fast path and turning every subsequent + # ``create_tensor_binding`` call into an O(N) USD enumeration -- the + # hang you'd see at large env counts. + # + # The workaround: strip ``/World/envs/env_`` for i != 0 from the + # exported file before handing it to the wheel. Sensors that read + # USD directly (RayCaster, Camera, ContactSensor discovery) still see + # the full N-env stage; only the wheel-side physics ingestion is + # scoped to env_0, and ``physx.clone()`` re-populates env_1..N in + # the physics runtime with proper clone lineage (which is what the + # binding fast path expects). cls._tmp_dir = tempfile.TemporaryDirectory(prefix="isaaclab_ovphysx_") stage_file = os.path.join(cls._tmp_dir.name, "scene.usda") - sim.stage.Export(stage_file) + cls._export_env0_only_stage(sim.stage, stage_file) cls._stage_path = stage_file - logger.info("OvPhysxManager: exported USD stage to %s", stage_file) + logger.info("OvPhysxManager: exported env_0-scoped USD stage to %s", stage_file) if cls._physx is None: cls._construct_physx(ovphysx_device, gpu_index) diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/__init__.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/__init__.py new file mode 100644 index 000000000000..09dfdceb6e3b --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/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 + +"""OVPhysX simulation views.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/__init__.pyi b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/__init__.pyi new file mode 100644 index 000000000000..12155e922ba7 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/__init__.pyi @@ -0,0 +1,6 @@ +# 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__: list[str] = [] diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/__init__.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/__init__.py new file mode 100644 index 000000000000..09dfdceb6e3b --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/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 + +"""OVPhysX simulation views.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/__init__.pyi b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/__init__.pyi new file mode 100644 index 000000000000..cbb07417da49 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/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__ = [ + "OvPhysxFrameView", +] + +from .ovphysx_frame_view import OvPhysxFrameView diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_frame_view.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_frame_view.py new file mode 100644 index 000000000000..1eda486a1205 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_frame_view.py @@ -0,0 +1,890 @@ +# 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 + +"""OVPhysX-backed FrameView -- Warp-native, GPU-resident pose queries.""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +import warp as wp + +from pxr import Gf, Usd, UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.physics import PhysicsEvent +from isaaclab.sim.views.base_frame_view import BaseFrameView +from isaaclab.sim.views.usd_frame_view import UsdFrameView +from isaaclab.utils.warp import ProxyArray + +from isaaclab_ovphysx.physics import OvPhysxManager + +logger = logging.getLogger(__name__) + +WORLD_BODY_INDEX = -1 + + +@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 OVPhysX-backed Newton state, + shape ``[num_bodies]``. + site_body: Per-site body index (flat model-level), shape ``[num_sites]``. + ``-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`.""" + 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 _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 sites reach desired world poses. + + For each site *i*, sets ``site_local[i] = inv(body_q[bid]) * desired_world`` + so that subsequent reads produce the requested world pose. Does **not** + modify ``body_q``. World-attached sites (``site_body[i] == -1``) receive + the desired world transform directly. + + Args: + body_q: Rigid-body world transforms, shape ``[num_bodies]``. + site_body: Per-site body index, 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`.""" + 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) + + +@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``. World-attached + sites/parents use ``site_local`` / ``parent_site_local`` directly. + """ + 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`.""" + 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.""" + 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`.""" + 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) + + +class OvPhysxFrameView(BaseFrameView): + """Batched prim view for non-physics prims tracked as sites on OVPhysX bodies. + + Each matched USD prim is resolved at init to a ``(body_index, site_local)`` + pair via ancestor walk: the nearest ancestor carrying ``UsdPhysics.RigidBodyAPI`` + becomes the attachment body, and the relative USD transform becomes the site + offset. If no rigid-body ancestor exists, the prim is attached to the world + frame (``body_index = WORLD_BODY_INDEX``) and ``site_local`` stores the prim's + USD world transform. + + Body world poses are read each step via an OVPhysX ``RIGID_BODY_POSE`` tensor + binding -- the same data path the contact sensor uses -- and **not** via the + scene data provider's Newton model. This keeps the view usable in scenes + that do not declare ``requires_newton_model=True``. + + World poses are computed on GPU as ``body_q[body_index] * site_local`` via + a Warp kernel, with the world-attached branch returning ``site_local`` + directly. Both :meth:`set_world_poses` and :meth:`set_local_poses` update + the view-owned ``site_local`` buffer -- neither writes to the physics state. + + Scales and visibility delegate to an internal :class:`UsdFrameView` + (lazy-constructed on first call). + + Pose getters return :class:`~isaaclab.utils.warp.ProxyArray`. Setters + accept ``wp.array``. + + Limitations (v1): + All resolved rigid-body ancestors (plus their USD parents for local-pose + queries) must share a single env-wildcarded path pattern. Mixed + body-types per view raise :class:`NotImplementedError`. The common + case (one body type, wildcarded across envs) is fully supported. + """ + + def __init__(self, prim_path: str, device: str = "cpu", stage: Usd.Stage | None = None, **kwargs): + """Initialize the OVPhysX site-based frame view. + + 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: Forwarded to the lazy internal :class:`UsdFrameView` + (e.g. ``validate_xform_ops``); accepted for backend-agnostic + kwarg passing through the :class:`FrameView` factory. + """ + self._prim_path = prim_path + self._device = device + self._kwargs = kwargs + + stage = sim_utils.get_current_stage() if stage is None else stage + self._stage = stage + self._prims: list[Usd.Prim] = sim_utils.find_matching_prims(prim_path, stage=stage) + if not self._prims: + raise ValueError(f"OvPhysxFrameView: pattern {prim_path!r} matched zero prims.") + + # Lazy USD view for scales / visibility. + self._usd_view: UsdFrameView | None = None + + # Try synchronous init; defer to PHYSICS_READY if the PhysX instance is not yet alive. + physx = self._try_get_physx() + if physx is not None: + self._initialize_impl(physx) + else: + OvPhysxManager.register_callback( + self._on_physics_ready, + PhysicsEvent.PHYSICS_READY, + name=f"ovphysx_frame_view_{prim_path}", + ) + + @staticmethod + def _try_get_physx() -> Any | None: + """Return the active OVPhysX ``PhysX`` instance, or ``None`` if not yet created.""" + return OvPhysxManager.get_physx_instance() + + def _on_physics_ready(self, _event) -> None: + """Callback invoked when the OVPhysX ``PhysX`` instance becomes available.""" + physx = self._try_get_physx() + if physx is None: + raise RuntimeError("OvPhysxFrameView: PHYSICS_READY fired but OvPhysxManager has no PhysX instance.") + self._initialize_impl(physx) + + def _initialize_impl(self, physx: Any) -> None: + """Resolve prims to rigid-body ancestors and create a RIGID_BODY_POSE tensor binding. + + Site discovery handles two scene-construction modes: + + * **``clone_usd=True``** (Newton-style cloning): every env has its own + USD prims; ``find_matching_prims`` returns one prim per env, and the + binding row count matches. + * **``clone_usd=False``** (OVPhysX default): only ``env_0`` has authored + USD prims; ``env_1..N`` are physics-layer clones (no USD twin). The + RIGID_BODY_POSE binding still exposes one row per env. In that case + the binding is the source of truth for the site count, and per-env + site paths are synthesized from the env_0 template prim's path with + ``env_0`` replaced by the row's env_id. + """ + from isaaclab_ovphysx import tensor_types as TT # noqa: PLC0415 + + xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) + identity_xform7 = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0] + + # 0. Reject prim_paths that resolve to a rigid body itself: a FrameView + # should track a non-physics child of a body (a sensor frame), not + # the body. Mirrors the Newton guard at + # ``newton_site_frame_view.py:572-584``. + for prim in self._prims: + if self._prim_or_template_has_rigid_body_api(prim): + raise ValueError( + f"OvPhysxFrameView prim '{prim.GetPath().pathString}' resolves to a rigid body. " + "FrameView should only be used for non-physics prims (cameras, sensor mounts, " + "Xform markers). Use OvPhysX's RigidObject or Articulation APIs to control " + "physics bodies directly, or point prim_path at a non-physics child of the body." + ) + + # 1. Resolve each (template) prim's ancestor body + the prim->ancestor offset. + per_prim_ancestor: list[str | None] = [] + per_prim_site_local: list[list[float]] = [] + for prim in self._prims: + ap, sl = self._resolve_rigid_body_ancestor(prim, xform_cache) + per_prim_ancestor.append(ap) + per_prim_site_local.append(sl) + + # 2. Same resolution for each prim's USD parent (used by local-pose queries). + parent_ancestor: list[str | None] = [] + parent_site_local: list[list[float]] = [] + for prim in self._prims: + parent = prim.GetParent() + if parent and parent.IsValid() and parent.GetPath().pathString != "/": + pap, psl = self._resolve_rigid_body_ancestor(parent, xform_cache) + else: + pap, psl = None, identity_xform7 + parent_ancestor.append(pap) + parent_site_local.append(psl) + + # 3. Dedup discovered ancestor paths into env-wildcarded patterns (one binding per pattern). + all_ancestors = [p for p in (per_prim_ancestor + parent_ancestor) if p is not None] + patterns = sorted({self._env_wildcardify(p) for p in all_ancestors}) + if len(patterns) > 1: + raise NotImplementedError( + f"OvPhysxFrameView v1 supports a single body-type pattern; resolved {len(patterns)}" + f" patterns under prim_path={self._prim_path!r}: {patterns}." + ) + + # 4. Create the RIGID_BODY_POSE binding (or operate in world-only mode). + if patterns: + pattern = patterns[0] + self._pose_binding = physx.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_POSE) + if self._pose_binding.shape[0] == 0: + raise RuntimeError( + f"OvPhysxFrameView: RIGID_BODY_POSE binding for pattern {pattern!r} matched zero bodies." + ) + self._pose_buf = wp.zeros(self._pose_binding.shape, dtype=wp.float32, device=self._device) + binding_paths: list[str] = list(self._pose_binding.prim_paths) + else: + # All prims resolved as world-attached: no binding needed; kernels only hit the -1 branch. + self._pose_binding = None + self._pose_buf = wp.zeros((1, 7), dtype=wp.float32, device=self._device) + binding_paths = [] + + # 5. Detect clone_usd=False expansion: binding row count > number of matched USD prims. + # Replace per-prim arrays with one entry per binding row, all derived from the env_0 template. + if binding_paths and len(binding_paths) > len(self._prims): + template_ancestor = per_prim_ancestor[0] + template_site_local = per_prim_site_local[0] + template_parent_ancestor = parent_ancestor[0] + template_parent_site_local = parent_site_local[0] + template_path = self._prims[0].GetPath().pathString + + per_prim_ancestor = [] + per_prim_site_local = [] + parent_ancestor = [] + parent_site_local = [] + synthetic_prim_paths: list[str] = [] + for body_path in binding_paths: + env_match = re.search(r"/World/envs/env_(\d+)", body_path) + env_token = env_match.group(0) if env_match else None + # Re-target the template path's env segment to this row's env_id. + if env_token is not None: + synthetic_path = re.sub(r"/World/envs/env_\d+", env_token, template_path) + ap = re.sub(r"/World/envs/env_\d+", env_token, template_ancestor) if template_ancestor else None + pap = ( + re.sub(r"/World/envs/env_\d+", env_token, template_parent_ancestor) + if template_parent_ancestor + else None + ) + else: + synthetic_path = template_path + ap = template_ancestor + pap = template_parent_ancestor + per_prim_ancestor.append(ap) + per_prim_site_local.append(template_site_local) + parent_ancestor.append(pap) + parent_site_local.append(template_parent_site_local) + synthetic_prim_paths.append(synthetic_path) + self._synthetic_prim_paths: list[str] | None = synthetic_prim_paths + else: + self._synthetic_prim_paths = None + + # 6. Build site_body and parent_site_body indices into the binding's row order. + path_to_row = {p: i for i, p in enumerate(binding_paths)} + site_bodies = [ + path_to_row.get(ap, WORLD_BODY_INDEX) if ap is not None else WORLD_BODY_INDEX for ap in per_prim_ancestor + ] + parent_bodies = [ + path_to_row.get(pap, WORLD_BODY_INDEX) if pap is not None else WORLD_BODY_INDEX for pap in parent_ancestor + ] + + # 7. Allocate Warp arrays. + device = self._device + self._site_body = wp.array(site_bodies, dtype=wp.int32, device=device) + self._site_local = wp.array([wp.transform(*x) for x in per_prim_site_local], dtype=wp.transformf, device=device) + 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_site_local], dtype=wp.transformf, device=device + ) + + count = len(per_prim_ancestor) + self._pos_buf = wp.zeros(count, dtype=wp.vec3f, device=device) + self._quat_buf = wp.zeros(count, dtype=wp.vec4f, device=device) + self._local_pos_buf = wp.zeros(count, dtype=wp.vec3f, device=device) + self._local_quat_buf = wp.zeros(count, dtype=wp.vec4f, device=device) + self._pos_ta = ProxyArray(self._pos_buf) + self._quat_ta = ProxyArray(self._quat_buf) + self._local_pos_ta = ProxyArray(self._local_pos_buf) + self._local_quat_ta = ProxyArray(self._local_quat_buf) + + def _resolve_rigid_body_ancestor( + self, + prim: Usd.Prim, + xform_cache: UsdGeom.XformCache, + ) -> tuple[str | None, list[float]]: + """Walk USD ancestors to find the nearest prim with ``UsdPhysics.RigidBodyAPI``. + + Under OVPhysX scenes built with ``clone_usd=False`` (the default for + :class:`~isaaclab.scene.InteractiveScene`), only ``env_0`` carries the + authored RigidBodyAPI -- ``env_1..N`` exist only as physics-layer clones + and the corresponding USD prims (when present) are untyped Xforms. + :meth:`_prim_or_template_has_rigid_body_api` handles this by checking + the prim's env_0 equivalent when the API is not directly applied. + + Returns: + ``(ancestor_path, [tx, ty, tz, qx, qy, qz, qw])``. ``ancestor_path`` is + ``None`` when no rigid-body ancestor exists; the local transform in + that case is the prim's world USD transform. + """ + prim_world_tf = xform_cache.GetLocalToWorldTransform(prim) + prim_world_tf.Orthonormalize() + # If the prim itself is a rigid body (directly or via env_0 template), the site offset is identity. + if self._prim_or_template_has_rigid_body_api(prim): + return prim.GetPath().pathString, [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0] + ancestor = prim.GetParent() + while ancestor and ancestor.IsValid() and ancestor.GetPath().pathString != "/": + if self._prim_or_template_has_rigid_body_api(ancestor): + ancestor_world_tf = xform_cache.GetLocalToWorldTransform(ancestor) + ancestor_world_tf.Orthonormalize() + local_tf = prim_world_tf * ancestor_world_tf.GetInverse() + return ancestor.GetPath().pathString, _gf_matrix_to_xform7(local_tf) + ancestor = ancestor.GetParent() + return None, _gf_matrix_to_xform7(prim_world_tf) + + def _prim_or_template_has_rigid_body_api(self, prim: Usd.Prim) -> bool: + """Return whether the prim (or its ``env_0`` equivalent) has ``RigidBodyAPI`` applied. + + Falls back to the env_0 template lookup so that ``clone_usd=False`` envs + (whose USD prims lack physics schemas) still resolve to the right body. + """ + if prim.HasAPI(UsdPhysics.RigidBodyAPI): + return True + path = prim.GetPath().pathString + env_zero_path = self._env_zero_equivalent(path) + if env_zero_path == path: + return False + template_prim = self._stage.GetPrimAtPath(env_zero_path) if self._stage is not None else None + if template_prim is None or not template_prim.IsValid(): + return False + return template_prim.HasAPI(UsdPhysics.RigidBodyAPI) + + @staticmethod + def _env_zero_equivalent(path: str) -> str: + """Replace ``/World/envs/env_`` with ``/World/envs/env_0`` for template lookup.""" + return re.sub(r"/World/envs/env_\d+", "/World/envs/env_0", path) + + @staticmethod + def _env_wildcardify(path: str) -> str: + """Replace ``/World/envs/env_`` with ``/World/envs/env_*`` for binding patterns.""" + return re.sub(r"/World/envs/env_\d+", "/World/envs/env_*", path) + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def prims(self) -> list[Usd.Prim]: + """List of USD prims discovered for this view. + + Under ``clone_usd=False`` scenes only ``env_0`` carries USD prims, so + this list may be shorter than :attr:`count`. Use :attr:`prim_paths` to + get one path per site (env-substituted for non-env_0 sites). + """ + return self._prims + + @property + def prim_paths(self) -> list[str]: + """List of one prim path per site. + + For ``clone_usd=False`` scenes (where ``env_1..N`` have no USD prim) + the paths are synthesized by replacing ``env_0`` in the template prim's + path with each binding row's env_id. + """ + if hasattr(self, "_synthetic_prim_paths") and self._synthetic_prim_paths is not None: + return self._synthetic_prim_paths + if not hasattr(self, "_prim_paths_cache"): + self._prim_paths_cache = [p.GetPath().pathString for p in self._prims] + return self._prim_paths_cache + + @property + def count(self) -> int: + """Number of sites in this view (one per binding row, or per matched prim in world-only mode).""" + if hasattr(self, "_site_body"): + return int(self._site_body.shape[0]) + return len(self._prims) + + @property + def device(self) -> str: + """Device where arrays are allocated (``"cpu"`` or ``"cuda:0"``).""" + return self._device + + # ------------------------------------------------------------------ + # Initialization guard for deferred-init users + # ------------------------------------------------------------------ + + def _require_initialized(self) -> None: + if not hasattr(self, "_site_body"): + raise RuntimeError( + "OvPhysxFrameView used before initialization. The view defers initialization " + "until OvPhysxManager dispatches PhysicsEvent.PHYSICS_READY. Step the " + "simulation once (or wait for physics to be ready) before calling pose methods." + ) + + def _current_body_q(self) -> wp.array: + """Refresh and return the body-pose array sourced from the OVPhysX tensor binding. + + Reads ``RIGID_BODY_POSE`` data into ``self._pose_buf`` and returns a + ``wp.transformf`` view. When no rigid-body ancestors were resolved at + init time (every prim was world-attached), the binding is ``None`` and + the returned view is a single-element placeholder buffer -- kernels + access it only via the world-attached (``site_body[i] == -1``) branch. + + Returns: + ``wp.array(dtype=wp.transformf)`` -- a view over the binding-pose + buffer ``[num_bodies]``. + """ + if self._pose_binding is not None: + self._pose_binding.read(self._pose_buf) + return self._pose_buf.view(wp.transformf) + + # ------------------------------------------------------------------ + # World / local pose APIs (Tasks 5 & 6) + # ------------------------------------------------------------------ + + # ------------------------------------------------------------------ + # World poses + # ------------------------------------------------------------------ + + def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + """Get world-space positions and orientations. + + Args: + indices: Subset of sites to query. ``None`` means all sites. + + Returns: + A tuple ``(positions, orientations)`` of :class:`~isaaclab.utils.warp.ProxyArray` + wrappers. Use ``.warp`` for the underlying ``wp.array`` or ``.torch`` for a + cached zero-copy ``torch.Tensor`` view. + """ + self._require_initialized() + body_q = self._current_body_q() + + 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=[body_q, self._site_body, self._site_local, indices], + outputs=[pos_buf, quat_buf], + device=self._device, + ) + return ProxyArray(pos_buf), ProxyArray(quat_buf) + + wp.launch( + _compute_site_world_transforms, + dim=self.count, + inputs=[body_q, self._site_body, self._site_local], + outputs=[self._pos_buf, self._quat_buf], + device=self._device, + ) + return self._pos_ta, self._quat_ta + + 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 ``site_local`` so that ``body_q[body] * site_local`` yields the + desired world pose. Does **not** modify ``body_q``. + + Args: + positions: Desired world positions ``(M, 3)`` [m]. ``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 + self._require_initialized() + body_q = self._current_body_q() + + if positions is None or orientations is None: + cur_pos_ta, cur_quat_ta = self.get_world_poses(indices) + if positions is None: + positions = cur_pos_ta.warp + if orientations is None: + orientations = cur_quat_ta.warp + + if indices is not None: + wp.launch( + _write_site_local_from_world_poses_indexed, + dim=len(indices), + inputs=[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=[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[ProxyArray, ProxyArray]: + """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)`` of :class:`~isaaclab.utils.warp.ProxyArray` + wrappers. + """ + self._require_initialized() + body_q = self._current_body_q() + + 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=[ + 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 ProxyArray(pos_buf), ProxyArray(quat_buf) + + wp.launch( + _compute_site_local_transforms, + dim=self.count, + inputs=[ + 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_ta, self._local_quat_ta + + 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 ``site_local`` only; does **not** modify ``body_q``. + + Args: + translations: Desired parent-relative translations ``(M, 3)`` [m]. + ``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 + self._require_initialized() + body_q = self._current_body_q() + + if translations is None or orientations is None: + cur_pos_ta, cur_quat_ta = self.get_local_poses(indices) + if translations is None: + translations = cur_pos_ta.warp + if orientations is None: + orientations = cur_quat_ta.warp + + if indices is not None: + wp.launch( + _write_site_local_from_local_poses_indexed, + dim=len(indices), + inputs=[ + 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=[ + body_q, + self._site_body, + self._parent_site_body, + self._parent_site_local, + translations, + orientations, + self._site_local, + ], + device=self._device, + ) + + # ------------------------------------------------------------------ + # Scales & visibility -- delegate to UsdFrameView + # ------------------------------------------------------------------ + + def _ensure_usd_view(self) -> UsdFrameView: + if self._usd_view is None: + self._usd_view = UsdFrameView( + self._prim_path, + device=self._device, + validate_xform_ops=self._kwargs.get("validate_xform_ops", True), + stage=self._stage, + ) + return self._usd_view + + def get_scales(self, indices: wp.array | None = None) -> wp.array: + """Get prim scales from the USD stage's ``xformOp:scale`` attribute. + + .. note:: + This reads the *static* USD authored value, not a live physics-state + value. OVPhysX does not maintain a per-shape ``shape_scale`` array + equivalent to Newton's ``model.shape_scale``, so sim-driven scale + updates are not reflected here. For sites under ``clone_usd=False`` + envs without authored USD prims, the read returns the env_0 + template's scale via the lazy internal :class:`UsdFrameView`. + + Args: + indices: Subset of sites to query. ``None`` means all sites. + + Returns: + ``wp.array`` of shape ``(M, 3)``. + """ + return self._ensure_usd_view().get_scales(indices) + + def set_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set prim scales by writing the USD ``xformOp:scale`` attribute. + + .. note:: + The write lands in the USD stage but does *not* propagate to any + OVPhysX-side collision-shape scale. PhysX is unaffected; this is a + stage-only annotation. Use :class:`~isaaclab_ovphysx.assets.RigidObject` + APIs if you need to change physics-effective shape sizes. + + Args: + scales: Scales ``(M, 3)`` as ``wp.array``. + indices: Subset of sites to update. ``None`` means all sites. + """ + self._ensure_usd_view().set_scales(scales, indices) + + def get_visibility(self, indices: wp.array | None = None): + """Get visibility for prims in the view (USD-backed). + + Note: OVPhysX runs without a Kit renderer, so visibility reads return + the static USD stage state. Writes succeed at the USD layer but + produce no visible change. + """ + return self._ensure_usd_view().get_visibility(indices) + + def set_visibility(self, visibility, indices: wp.array | None = None) -> None: + """Set visibility for prims in the view (USD-backed; no renderer effect under OVPhysX).""" + self._ensure_usd_view().set_visibility(visibility, indices) + + +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_ovphysx/test/sim/__init__.py b/source/isaaclab_ovphysx/test/sim/__init__.py new file mode 100644 index 000000000000..460a30569089 --- /dev/null +++ b/source/isaaclab_ovphysx/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_ovphysx/test/sim/test_views_xform_prim_ovphysx.py b/source/isaaclab_ovphysx/test/sim/test_views_xform_prim_ovphysx.py new file mode 100644 index 000000000000..bebd8aaa460d --- /dev/null +++ b/source/isaaclab_ovphysx/test/sim/test_views_xform_prim_ovphysx.py @@ -0,0 +1,163 @@ +# 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 + +"""Real-backend tests for the OVPhysX FrameView. + +Run via ``./scripts/run_ovphysx.sh -m pytest`` (kitless, no ``AppLauncher``). +""" + +from __future__ import annotations + +import pytest + +# The CI isaaclab_ov* pattern unintentionally collects isaaclab_ovphysx tests, +# but the ovphysx wheel is not installed in that environment. Skip gracefully +# so the isaaclab_ov CI pipeline is not blocked by an unrelated dependency. +pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") + +from isaaclab_ovphysx.physics import OvPhysxCfg # noqa: E402 + +import isaaclab.sim as sim_utils # noqa: E402 +from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 +from isaaclab.sim.views import FrameView # noqa: E402 + +OVPHYSX_SIM_CFG = SimulationCfg(physics=OvPhysxCfg()) + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_factory_dispatches_to_ovphysx_frame_view(device): + """``FrameView(...)`` under an OVPhysX ``SimulationContext`` returns an ``OvPhysxFrameView``.""" + OVPHYSX_SIM_CFG.device = device + with build_simulation_context(device=device, sim_cfg=OVPHYSX_SIM_CFG, add_ground_plane=True): + # Define a plain Xform prim so the pattern matches at least one prim. + stage = sim_utils.get_current_stage() + prim = stage.DefinePrim("/World/marker", "Xform") + sim_utils.standardize_xform_ops(prim) + + from isaaclab_ovphysx.sim.views import OvPhysxFrameView + + view = FrameView("/World/marker", device=device) + assert isinstance(view, OvPhysxFrameView), f"Expected OvPhysxFrameView, got {type(view).__name__}" + + +def test_view_raises_before_physics_ready(): + """A view constructed before PHYSICS_READY raises a clear error on pose-method calls.""" + device = "cpu" + OVPHYSX_SIM_CFG.device = device + with build_simulation_context(device=device, sim_cfg=OVPHYSX_SIM_CFG, add_ground_plane=False): + stage = sim_utils.get_current_stage() + prim = stage.DefinePrim("/World/marker_pre", "Xform") + sim_utils.standardize_xform_ops(prim) + view = FrameView("/World/marker_pre", device=device) + if hasattr(view, "_site_body"): + pytest.skip("PHYSICS_READY already fired; cannot exercise the deferred-init path here.") + with pytest.raises(RuntimeError, match="used before initialization"): + view.get_world_poses() + + +# Note: an earlier test ``test_view_errors_when_newton_model_not_required`` was +# removed when ``OvPhysxFrameView`` was reworked to read poses from a direct +# OVPhysX ``RIGID_BODY_POSE`` tensor binding instead of the SDP's Newton state. +# The view no longer depends on ``requires_newton_model``. + + +# ================================================================== +# Shared FrameView contract suite +# ================================================================== + +import sys # noqa: E402 +from pathlib import Path # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "isaaclab" / "test" / "sim")) + +import torch # noqa: E402 +import warp as wp # noqa: E402 +from frame_view_contract_utils import * # noqa: F401, F403, E402 -- import all contract tests +from frame_view_contract_utils import CHILD_OFFSET, ViewBundle # noqa: E402 + +from pxr import Gf # noqa: E402 + +from isaaclab.assets import RigidObjectCfg # noqa: E402 +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg # noqa: E402 +from isaaclab.utils.configclass import configclass # noqa: E402 + + +@configclass +class _OvPhysxFrameViewSceneCfg(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)), + ) + + +@pytest.fixture +def view_factory(): + """OVPhysX factory: CameraMount child Xform at CHILD_OFFSET under each Cube body. + + Test scaffolding note: ``OvPhysxFrameView`` reads body poses from a live + OVPhysX ``RIGID_BODY_POSE`` tensor binding each frame. The shared contract + tests inject synthetic parent poses via ``set_parent_pos`` and expect the + very next ``get_world_poses`` call to reflect them -- without stepping the + sim. To make that work, the fixture detaches the binding after one + initial read so subsequent reads return the contents of ``_pose_buf`` + directly, and the get/set callbacks drive ``_pose_buf`` in place. + """ + from isaaclab_ovphysx.sim.views import OvPhysxFrameView # noqa: PLC0415 + + contexts: list = [] + + def _build(num_envs: int, device: str) -> ViewBundle: + OVPHYSX_SIM_CFG.device = device + ctx = build_simulation_context(device=device, sim_cfg=OVPHYSX_SIM_CFG, add_ground_plane=True) + sim = ctx.__enter__() + sim._app_control_on_stop_handle = None + contexts.append(ctx) + + InteractiveScene(_OvPhysxFrameViewSceneCfg(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 = OvPhysxFrameView("/World/envs/env_.*/Cube/CameraMount", device=device) + + # Capture binding row order, populate _pose_buf once with the live spawn poses, + # then detach the binding so subsequent reads do not overwrite the buffer. + assert view._pose_binding is not None, "Fixture expects a non-empty pose binding." + view._pose_binding.read(view._pose_buf) + path_to_row = {p: i for i, p in enumerate(view._pose_binding.prim_paths)} + view._pose_binding = None + + cube_rows = [path_to_row[f"/World/envs/env_{i}/Cube"] for i in range(num_envs)] + pose_buf_torch = wp.to_torch(view._pose_buf) # shape [num_bodies, 7] float32 + + def _get_parent_pos(n: int, dev: str) -> torch.Tensor: + return pose_buf_torch[cube_rows, :3].to(dev).clone() + + def _set_parent_pos(positions: torch.Tensor, n: int) -> None: + pose_buf_torch[cube_rows, :3] = positions.to(pose_buf_torch.device, pose_buf_torch.dtype) + + return ViewBundle( + view=view, + get_parent_pos=_get_parent_pos, + set_parent_pos=_set_parent_pos, + teardown=lambda: None, + ) + + yield _build + + for cm in contexts: + cm.__exit__(None, None, None) From 45bd707ab374265b3a3d96654b711df9fb272fce Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 06:26:06 +0000 Subject: [PATCH 123/133] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 5.5.1 → 6.0.0 - isaaclab_contrib: 0.3.2 → 0.4.0 - isaaclab_newton: 0.11.0 → 0.12.0 - isaaclab_ovphysx: 2.1.0 → 3.0.0 - isaaclab_physx: 0.9.0 → 1.0.0 - isaaclab_tasks: 1.9.0 → 1.10.0 - isaaclab_teleop: 0.4.0 → 0.5.0 --- .../antoiner-feat-ovphysx_contactsensor.rst | 32 ----- ...er-feat-ovphysx_rigidobjectcollection.skip | 0 ...toiner-refactor-pr5455-followups.minor.rst | 22 --- .../daniela-move-scene-data.major.rst | 23 --- .../changelog.d/fix-zero-wrench-fast-path.rst | 12 -- .../changelog.d/mtrepte-update-debug-viz.rst | 9 -- .../mym-deformable-backend-split.major.rst | 19 --- .../mym-deformable_experimental.minor.rst | 34 ----- .../ovphysx-frameview-dispatch.rst | 8 -- .../pbarejko-fix-torch-imports.skip | 0 source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 131 ++++++++++++++++++ .../mingxue-fixed_rlinf_install.rst | 6 - .../mym-deformable-backend-split.skip | 1 - .../mym-deformable_experimental.minor.rst | 16 --- source/isaaclab_contrib/config/extension.toml | 2 +- source/isaaclab_contrib/docs/CHANGELOG.rst | 28 ++++ .../antoiner-refactor-pr5455-followups.rst | 11 -- .../changelog.d/daniela-move-scene-data.rst | 14 -- .../changelog.d/mtrepte-update-debug-viz.rst | 4 - .../mym-deformable-backend-split.minor.rst | 4 - .../mym-deformable_experimental.minor.rst | 33 ----- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 55 ++++++++ ...oiner-feat-ovphysx_contactsensor.minor.rst | 46 ------ ...at-ovphysx_rigidobjectcollection.major.rst | 13 -- .../changelog.d/daniela-move-scene-data.rst | 7 - .../changelog.d/ovphysx-frameview.minor.rst | 11 -- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 73 ++++++++++ .../antoiner-refactor-pr5455-followups.rst | 8 -- .../changelog.d/daniela-move-scene-data.rst | 6 - .../changelog.d/mtrepte-update-debug-viz.skip | 0 .../mym-deformable-backend-split.minor.rst | 11 -- .../mym-deformable_experimental.major.rst | 24 ---- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 49 +++++++ .../changelog.d/mtrepte-update-debug-viz.skip | 0 .../antoiner-feat-ovphysx_contactsensor.rst | 10 -- .../changelog.d/esekkin-pr-a-newton-xfail.rst | 6 - .../mingxue-fixed_rlinf_install.rst | 5 - .../changelog.d/mtrepte-update-debug-viz.skip | 0 .../mym-deformable-backend-split.rst | 6 - .../mym-deformable_experimental.minor.rst | 6 - .../rwiltz-mcap-replay-agent.minor.rst | 12 -- .../changelog.d/update-presets-doc.rst | 9 -- ...zhengyuz-active-tree-preset-resolution.rst | 7 - source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 53 +++++++ .../rwiltz-mcap-replay-agent.minor.rst | 36 ----- source/isaaclab_teleop/config/extension.toml | 2 +- source/isaaclab_teleop/docs/CHANGELOG.rst | 41 ++++++ 52 files changed, 437 insertions(+), 478 deletions(-) delete mode 100644 source/isaaclab/changelog.d/antoiner-feat-ovphysx_contactsensor.rst delete mode 100644 source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.skip delete mode 100644 source/isaaclab/changelog.d/antoiner-refactor-pr5455-followups.minor.rst delete mode 100644 source/isaaclab/changelog.d/daniela-move-scene-data.major.rst delete mode 100644 source/isaaclab/changelog.d/fix-zero-wrench-fast-path.rst delete mode 100644 source/isaaclab/changelog.d/mtrepte-update-debug-viz.rst delete mode 100644 source/isaaclab/changelog.d/mym-deformable-backend-split.major.rst delete mode 100644 source/isaaclab/changelog.d/mym-deformable_experimental.minor.rst delete mode 100644 source/isaaclab/changelog.d/ovphysx-frameview-dispatch.rst delete mode 100644 source/isaaclab/changelog.d/pbarejko-fix-torch-imports.skip delete mode 100644 source/isaaclab_contrib/changelog.d/mingxue-fixed_rlinf_install.rst delete mode 100644 source/isaaclab_contrib/changelog.d/mym-deformable-backend-split.skip delete mode 100644 source/isaaclab_contrib/changelog.d/mym-deformable_experimental.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/antoiner-refactor-pr5455-followups.rst delete mode 100644 source/isaaclab_newton/changelog.d/daniela-move-scene-data.rst delete mode 100644 source/isaaclab_newton/changelog.d/mtrepte-update-debug-viz.rst delete mode 100644 source/isaaclab_newton/changelog.d/mym-deformable-backend-split.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/mym-deformable_experimental.minor.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contactsensor.minor.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.major.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/daniela-move-scene-data.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/ovphysx-frameview.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/antoiner-refactor-pr5455-followups.rst delete mode 100644 source/isaaclab_physx/changelog.d/daniela-move-scene-data.rst delete mode 100644 source/isaaclab_physx/changelog.d/mtrepte-update-debug-viz.skip delete mode 100644 source/isaaclab_physx/changelog.d/mym-deformable-backend-split.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/mym-deformable_experimental.major.rst delete mode 100644 source/isaaclab_rl/changelog.d/mtrepte-update-debug-viz.skip delete mode 100644 source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_contactsensor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/esekkin-pr-a-newton-xfail.rst delete mode 100644 source/isaaclab_tasks/changelog.d/mingxue-fixed_rlinf_install.rst delete mode 100644 source/isaaclab_tasks/changelog.d/mtrepte-update-debug-viz.skip delete mode 100644 source/isaaclab_tasks/changelog.d/mym-deformable-backend-split.rst delete mode 100644 source/isaaclab_tasks/changelog.d/mym-deformable_experimental.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/rwiltz-mcap-replay-agent.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/update-presets-doc.rst delete mode 100644 source/isaaclab_tasks/changelog.d/zhengyuz-active-tree-preset-resolution.rst delete mode 100644 source/isaaclab_teleop/changelog.d/rwiltz-mcap-replay-agent.minor.rst diff --git a/source/isaaclab/changelog.d/antoiner-feat-ovphysx_contactsensor.rst b/source/isaaclab/changelog.d/antoiner-feat-ovphysx_contactsensor.rst deleted file mode 100644 index 7d92d9215b7c..000000000000 --- a/source/isaaclab/changelog.d/antoiner-feat-ovphysx_contactsensor.rst +++ /dev/null @@ -1,32 +0,0 @@ -Fixed -^^^^^ - -* Fixed three places where ``OvPhysxManager`` was misclassified as the - PhysX backend by a substring/schema match: - - - :meth:`~isaaclab.sensors.SensorBase._register_callbacks` matched - ``"physx" in physics_mgr_cls.__name__.lower()`` to gate the PhysX - ``IsaacEvents.PRIM_DELETION`` import — the substring also matches - ``"OvPhysxManager"``, so the ``isaaclab_physx`` import fired in - kitless OVPhysX mode and raised - :exc:`ModuleNotFoundError` because ``omni.physics.tensors`` is not - loaded. Switched to an exact ``physics_mgr_cls.__name__ == - "PhysxManager"`` match. - - :meth:`~isaaclab.assets.AssetBase.set_debug_vis` had the same - substring check guarding an ``import omni.kit.app`` call, which - would fire for OVPhysX-backed assets and break under - ``./scripts/run_ovphysx.sh``. Switched to an exact - ``"PhysxManager"`` match. - - :meth:`~isaaclab.physics.SceneDataProvider._get_backend` used - ``"physx" in manager_name`` to dispatch the backend factory; this - silently routed ``OvPhysxManager`` to the PhysX scene-data - provider. Switched to exact ``"PhysxManager"`` / - ``"NewtonManager"`` matches and an explicit ``ValueError`` for - unknown managers. -* Made - :attr:`~isaaclab.scene.InteractiveScene.physics_scene_path` accept a - bare :class:`pxr.UsdPhysics.Scene` prim as a fallback when no prim - with ``PhysxSceneAPI`` applied is on the stage. Kitless OVPhysX - does not load the ``omni.physx`` schema, so the auto-created scene - prim only carries the stock USD type. PhysX-backed flows continue - to prefer the ``PhysxSceneAPI`` prim. diff --git a/source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.skip b/source/isaaclab/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab/changelog.d/antoiner-refactor-pr5455-followups.minor.rst b/source/isaaclab/changelog.d/antoiner-refactor-pr5455-followups.minor.rst deleted file mode 100644 index b1dff35671d8..000000000000 --- a/source/isaaclab/changelog.d/antoiner-refactor-pr5455-followups.minor.rst +++ /dev/null @@ -1,22 +0,0 @@ -Added -^^^^^ - -* Added :func:`~isaaclab.sim.schemas.define_actuator_properties` to author - ``NewtonActuator`` USD prims from IsaacLab actuator configs. Lives alongside - the other ``define_*_properties`` schema writers and is invoked from the - schema-side post-spawn hook below. -* Added :meth:`~isaaclab.assets.AssetBaseCfg._post_spawn` hook (no-op by - default) invoked by :class:`~isaaclab.assets.AssetBase` after spawning the - asset. :class:`~isaaclab.assets.ArticulationCfg` overrides it to author - Newton-native actuator prims from :attr:`~isaaclab.assets.ArticulationCfg.actuators`. - -Changed -^^^^^^^ - -* :class:`~isaaclab.assets.BaseArticulation` no longer imports - ``isaaclab_newton`` from its ``__init__``. Newton-native actuator authoring - now flows through the generic ``_post_spawn`` hook on - :class:`~isaaclab.assets.AssetBaseCfg`. -* :meth:`~isaaclab.physics.PhysicsManager.set_decimation` now has an explicit - ``pass`` body so the base class is consistent with the other no-op - classmethods. diff --git a/source/isaaclab/changelog.d/daniela-move-scene-data.major.rst b/source/isaaclab/changelog.d/daniela-move-scene-data.major.rst deleted file mode 100644 index 39c979e086c4..000000000000 --- a/source/isaaclab/changelog.d/daniela-move-scene-data.major.rst +++ /dev/null @@ -1,23 +0,0 @@ -Added -^^^^^ - -* Added :mod:`isaaclab.scene_data` sub-package consolidating - :class:`~isaaclab.scene_data.SceneDataProvider`, - :class:`~isaaclab.scene_data.SceneDataBackend`, and - :class:`~isaaclab.scene_data.SceneDataFormat` in a single import location. - -Changed -^^^^^^^ - -* **Breaking:** Moved :class:`~isaaclab.scene_data.SceneDataProvider` from - :mod:`isaaclab.scene.scene_data_provider` and - :class:`~isaaclab.scene_data.SceneDataBackend` / - :class:`~isaaclab.scene_data.SceneDataFormat` from :mod:`isaaclab.physics` - to the new :mod:`isaaclab.scene_data` sub-package. Update imports:: - - # before - from isaaclab.scene.scene_data_provider import SceneDataProvider - from isaaclab.physics import SceneDataBackend, SceneDataFormat - - # after - from isaaclab.scene_data import SceneDataProvider, SceneDataBackend, SceneDataFormat diff --git a/source/isaaclab/changelog.d/fix-zero-wrench-fast-path.rst b/source/isaaclab/changelog.d/fix-zero-wrench-fast-path.rst deleted file mode 100644 index 1521e8f9db31..000000000000 --- a/source/isaaclab/changelog.d/fix-zero-wrench-fast-path.rst +++ /dev/null @@ -1,12 +0,0 @@ -Fixed -^^^^^ - -* Fixed a per-step performance regression in :func:`~isaaclab.envs.mdp.events.apply_external_force_torque` - when the event was configured with all-zero ``force_range`` and ``torque_range`` (a common default - for tasks that declare the event term but apply no disturbance). The event was unconditionally - sampling zero wrenches and routing them through the dual-buffer ``WrenchComposer`` introduced in - PR #5265, paying the full per-step compose-and-apply cost in - :meth:`~isaaclab.assets.Articulation.write_data_to_sim` for what is semantically a no-op. The - function now returns early when both ranges are exactly zero. This restores the H1, G1, and - Anymal-C ``Velocity-Rough`` throughput observed prior to PR #5265. Behaviour for non-zero ranges - is unchanged. diff --git a/source/isaaclab/changelog.d/mtrepte-update-debug-viz.rst b/source/isaaclab/changelog.d/mtrepte-update-debug-viz.rst deleted file mode 100644 index b7aa84db0dfc..000000000000 --- a/source/isaaclab/changelog.d/mtrepte-update-debug-viz.rst +++ /dev/null @@ -1,9 +0,0 @@ -Fixed -^^^^^ - -* Fixed visualization marker backend initialization so USD markers remain available during rendering even when standalone visualizers are not launched. - -Changed -^^^^^^^ - -* Removed temporary startup and runtime debug breadcrumbs from core simulation and environment setup logs. diff --git a/source/isaaclab/changelog.d/mym-deformable-backend-split.major.rst b/source/isaaclab/changelog.d/mym-deformable-backend-split.major.rst deleted file mode 100644 index 1fd4b8967bea..000000000000 --- a/source/isaaclab/changelog.d/mym-deformable-backend-split.major.rst +++ /dev/null @@ -1,19 +0,0 @@ -Added -^^^^^ - -* Added common deformable property and material base cfgs in :mod:`isaaclab.sim`. - -Changed -^^^^^^^ - -* Changed deformable spawners to accept backend-specific deformable property and - material cfgs. Use PhysX cfgs from :mod:`isaaclab_physx.sim` or Newton cfgs - from :mod:`isaaclab_newton.sim`. - -Deprecated -^^^^^^^^^^ - -* Deprecated generic deformable property and material cfgs in favor of - ``PhysxDeformableBodyPropertiesCfg``, ``PhysxDeformableBodyMaterialCfg``, - ``PhysxSurfaceDeformableBodyMaterialCfg``, ``NewtonDeformableBodyPropertiesCfg``, - ``NewtonDeformableBodyMaterialCfg``, and ``NewtonSurfaceDeformableBodyMaterialCfg``. diff --git a/source/isaaclab/changelog.d/mym-deformable_experimental.minor.rst b/source/isaaclab/changelog.d/mym-deformable_experimental.minor.rst deleted file mode 100644 index 059828bf03ab..000000000000 --- a/source/isaaclab/changelog.d/mym-deformable_experimental.minor.rst +++ /dev/null @@ -1,34 +0,0 @@ -Added -^^^^^ - -* Added backend-neutral deformable asset APIs, including - :class:`~isaaclab.assets.DeformableObject`, - :class:`~isaaclab.assets.DeformableObjectCfg`, and shared base/data classes. -* Added deformable body spawner, schema, and material APIs under - :mod:`isaaclab.sim`, including - :class:`~isaaclab.sim.DeformableObjectSpawnerCfg`, - :class:`~isaaclab.sim.DeformableBodyPropertiesCfg`, - :func:`~isaaclab.sim.define_deformable_body_properties`, - :func:`~isaaclab.sim.modify_deformable_body_properties`, - :class:`~isaaclab.sim.DeformableBodyMaterialCfg`, - :class:`~isaaclab.sim.SurfaceDeformableBodyMaterialCfg`, and - :func:`~isaaclab.sim.spawn_deformable_body_material`. -* Added ``pytetwild`` as a package dependency for tetrahedral mesh generation. -* Added deformable API, migration, and tutorial documentation for - backend-neutral imports and Newton backend selection. - -Changed -^^^^^^^ - -* Changed deformable demos and tutorials to use the backend-neutral - :mod:`isaaclab.assets` and :mod:`isaaclab.sim` APIs with selectable PhysX or - Newton backends. -* Changed USD spawning to support deformable objects whose USD assets contain - embedded tetrahedral mesh data. -* **Breaking:** Renamed :class:`~isaaclab.sim.spawners.meshes.MeshSquareCfg` - to :class:`~isaaclab.sim.spawners.meshes.MeshRectangleCfg` and - :func:`~isaaclab.sim.spawners.meshes.spawn_mesh_square` to - :func:`~isaaclab.sim.spawners.meshes.spawn_mesh_rectangle`. The ``size`` - attribute is now a ``tuple[float, float]`` of X- and Y-axis lengths instead - of a single edge length. Migrate ``MeshSquareCfg(size=s)`` to - ``MeshRectangleCfg(size=(s, s))``. diff --git a/source/isaaclab/changelog.d/ovphysx-frameview-dispatch.rst b/source/isaaclab/changelog.d/ovphysx-frameview-dispatch.rst deleted file mode 100644 index 030589c05f9c..000000000000 --- a/source/isaaclab/changelog.d/ovphysx-frameview-dispatch.rst +++ /dev/null @@ -1,8 +0,0 @@ -Fixed -^^^^^ - -* Fixed :class:`~isaaclab.sim.views.FrameView` dispatch under the OVPhysX - backend. ``FrameView(...)`` now routes to - :class:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView` instead of silently - falling through to ``FabricFrameView``, which returned stale USD spawn - poses for sensor frames riding on physics bodies. diff --git a/source/isaaclab/changelog.d/pbarejko-fix-torch-imports.skip b/source/isaaclab/changelog.d/pbarejko-fix-torch-imports.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index eb347d2de601..e4e4e448d359 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 = "5.5.1" +version = "6.0.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 68028a87110e..c2f80cfa07e7 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,137 @@ Changelog --------- +6.0.0 (2026-05-20) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :func:`~isaaclab.sim.schemas.define_actuator_properties` to author + ``NewtonActuator`` USD prims from IsaacLab actuator configs. Lives alongside + the other ``define_*_properties`` schema writers and is invoked from the + schema-side post-spawn hook below. +* Added :meth:`~isaaclab.assets.AssetBaseCfg._post_spawn` hook (no-op by + default) invoked by :class:`~isaaclab.assets.AssetBase` after spawning the + asset. :class:`~isaaclab.assets.ArticulationCfg` overrides it to author + Newton-native actuator prims from :attr:`~isaaclab.assets.ArticulationCfg.actuators`. +* Added common deformable property and material base cfgs in :mod:`isaaclab.sim`. +* Added backend-neutral deformable asset APIs, including + :class:`~isaaclab.assets.DeformableObject`, + :class:`~isaaclab.assets.DeformableObjectCfg`, and shared base/data classes. +* Added deformable body spawner, schema, and material APIs under + :mod:`isaaclab.sim`, including + :class:`~isaaclab.sim.DeformableObjectSpawnerCfg`, + :class:`~isaaclab.sim.DeformableBodyPropertiesCfg`, + :func:`~isaaclab.sim.define_deformable_body_properties`, + :func:`~isaaclab.sim.modify_deformable_body_properties`, + :class:`~isaaclab.sim.DeformableBodyMaterialCfg`, + :class:`~isaaclab.sim.SurfaceDeformableBodyMaterialCfg`, and + :func:`~isaaclab.sim.spawn_deformable_body_material`. +* Added ``pytetwild`` as a package dependency for tetrahedral mesh generation. +* Added deformable API, migration, and tutorial documentation for + backend-neutral imports and Newton backend selection. +* Added :mod:`isaaclab.scene_data` sub-package consolidating + :class:`~isaaclab.scene_data.SceneDataProvider`, + :class:`~isaaclab.scene_data.SceneDataBackend`, and + :class:`~isaaclab.scene_data.SceneDataFormat` in a single import location. + +Changed +^^^^^^^ + +* :class:`~isaaclab.assets.BaseArticulation` no longer imports + ``isaaclab_newton`` from its ``__init__``. Newton-native actuator authoring + now flows through the generic ``_post_spawn`` hook on + :class:`~isaaclab.assets.AssetBaseCfg`. +* :meth:`~isaaclab.physics.PhysicsManager.set_decimation` now has an explicit + ``pass`` body so the base class is consistent with the other no-op + classmethods. +* Removed temporary startup and runtime debug breadcrumbs from core simulation and environment setup logs. +* Changed deformable spawners to accept backend-specific deformable property and + material cfgs. Use PhysX cfgs from :mod:`isaaclab_physx.sim` or Newton cfgs + from :mod:`isaaclab_newton.sim`. +* Changed deformable demos and tutorials to use the backend-neutral + :mod:`isaaclab.assets` and :mod:`isaaclab.sim` APIs with selectable PhysX or + Newton backends. +* Changed USD spawning to support deformable objects whose USD assets contain + embedded tetrahedral mesh data. +* **Breaking:** Renamed :class:`~isaaclab.sim.spawners.meshes.MeshSquareCfg` + to :class:`~isaaclab.sim.spawners.meshes.MeshRectangleCfg` and + :func:`~isaaclab.sim.spawners.meshes.spawn_mesh_square` to + :func:`~isaaclab.sim.spawners.meshes.spawn_mesh_rectangle`. The ``size`` + attribute is now a ``tuple[float, float]`` of X- and Y-axis lengths instead + of a single edge length. Migrate ``MeshSquareCfg(size=s)`` to + ``MeshRectangleCfg(size=(s, s))``. +* **Breaking:** Moved :class:`~isaaclab.scene_data.SceneDataProvider` from + :mod:`isaaclab.scene.scene_data_provider` and + :class:`~isaaclab.scene_data.SceneDataBackend` / + :class:`~isaaclab.scene_data.SceneDataFormat` from :mod:`isaaclab.physics` + to the new :mod:`isaaclab.scene_data` sub-package. Update imports:: + + # before + from isaaclab.scene.scene_data_provider import SceneDataProvider + from isaaclab.physics import SceneDataBackend, SceneDataFormat + + # after + from isaaclab.scene_data import SceneDataProvider, SceneDataBackend, SceneDataFormat + +Deprecated +^^^^^^^^^^ + +* Deprecated generic deformable property and material cfgs in favor of + ``PhysxDeformableBodyPropertiesCfg``, ``PhysxDeformableBodyMaterialCfg``, + ``PhysxSurfaceDeformableBodyMaterialCfg``, ``NewtonDeformableBodyPropertiesCfg``, + ``NewtonDeformableBodyMaterialCfg``, and ``NewtonSurfaceDeformableBodyMaterialCfg``. + +Fixed +^^^^^ + +* Fixed three places where ``OvPhysxManager`` was misclassified as the + PhysX backend by a substring/schema match: + + - :meth:`~isaaclab.sensors.SensorBase._register_callbacks` matched + ``"physx" in physics_mgr_cls.__name__.lower()`` to gate the PhysX + ``IsaacEvents.PRIM_DELETION`` import — the substring also matches + ``"OvPhysxManager"``, so the ``isaaclab_physx`` import fired in + kitless OVPhysX mode and raised + :exc:`ModuleNotFoundError` because ``omni.physics.tensors`` is not + loaded. Switched to an exact ``physics_mgr_cls.__name__ == + "PhysxManager"`` match. + - :meth:`~isaaclab.assets.AssetBase.set_debug_vis` had the same + substring check guarding an ``import omni.kit.app`` call, which + would fire for OVPhysX-backed assets and break under + ``./scripts/run_ovphysx.sh``. Switched to an exact + ``"PhysxManager"`` match. + - :meth:`~isaaclab.physics.SceneDataProvider._get_backend` used + ``"physx" in manager_name`` to dispatch the backend factory; this + silently routed ``OvPhysxManager`` to the PhysX scene-data + provider. Switched to exact ``"PhysxManager"`` / + ``"NewtonManager"`` matches and an explicit ``ValueError`` for + unknown managers. +* Made + :attr:`~isaaclab.scene.InteractiveScene.physics_scene_path` accept a + bare :class:`pxr.UsdPhysics.Scene` prim as a fallback when no prim + with ``PhysxSceneAPI`` applied is on the stage. Kitless OVPhysX + does not load the ``omni.physx`` schema, so the auto-created scene + prim only carries the stock USD type. PhysX-backed flows continue + to prefer the ``PhysxSceneAPI`` prim. +* Fixed a per-step performance regression in :func:`~isaaclab.envs.mdp.events.apply_external_force_torque` + when the event was configured with all-zero ``force_range`` and ``torque_range`` (a common default + for tasks that declare the event term but apply no disturbance). The event was unconditionally + sampling zero wrenches and routing them through the dual-buffer ``WrenchComposer`` introduced in + PR #5265, paying the full per-step compose-and-apply cost in + :meth:`~isaaclab.assets.Articulation.write_data_to_sim` for what is semantically a no-op. The + function now returns early when both ranges are exactly zero. This restores the H1, G1, and + Anymal-C ``Velocity-Rough`` throughput observed prior to PR #5265. Behaviour for non-zero ranges + is unchanged. +* Fixed visualization marker backend initialization so USD markers remain available during rendering even when standalone visualizers are not launched. +* Fixed :class:`~isaaclab.sim.views.FrameView` dispatch under the OVPhysX + backend. ``FrameView(...)`` now routes to + :class:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView` instead of silently + falling through to ``FabricFrameView``, which returned stale USD spawn + poses for sensor frames riding on physics bodies. + + 5.5.1 (2026-05-19) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_contrib/changelog.d/mingxue-fixed_rlinf_install.rst b/source/isaaclab_contrib/changelog.d/mingxue-fixed_rlinf_install.rst deleted file mode 100644 index 1c3f0440de44..000000000000 --- a/source/isaaclab_contrib/changelog.d/mingxue-fixed_rlinf_install.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Fixed ``[rlinf]`` extra dependency declarations to avoid version conflicts with IsaacLab core - (torch, transformers, tokenizers). Conflicting packages are now documented as manual ``--no-deps`` - installation steps. diff --git a/source/isaaclab_contrib/changelog.d/mym-deformable-backend-split.skip b/source/isaaclab_contrib/changelog.d/mym-deformable-backend-split.skip deleted file mode 100644 index 37d59e91ab7b..000000000000 --- a/source/isaaclab_contrib/changelog.d/mym-deformable-backend-split.skip +++ /dev/null @@ -1 +0,0 @@ -Test-only updates for backend-specific deformable cfg imports. diff --git a/source/isaaclab_contrib/changelog.d/mym-deformable_experimental.minor.rst b/source/isaaclab_contrib/changelog.d/mym-deformable_experimental.minor.rst deleted file mode 100644 index 4167f0a33328..000000000000 --- a/source/isaaclab_contrib/changelog.d/mym-deformable_experimental.minor.rst +++ /dev/null @@ -1,16 +0,0 @@ -Added -^^^^^ - -* Added :mod:`isaaclab_contrib.deformable` with contributed Newton deformable - asset and VBD solver support, including - :class:`~isaaclab_contrib.deformable.DeformableObject`, - :class:`~isaaclab_contrib.deformable.VBDSolverCfg`, - :class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg`, and - :class:`~isaaclab_contrib.deformable.CoupledFeatherstoneVBDSolverCfg` for - one- and two-way rigid-deformable coupling. -* Added :class:`~isaaclab_contrib.deformable.NewtonModelCfg` for shared Newton - deformable contact parameters. -* Added Newton deformable coupling documentation with Franka soft-body lift - tuning guidance for - :class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg` and - :class:`~isaaclab_contrib.deformable.NewtonModelCfg`. diff --git a/source/isaaclab_contrib/config/extension.toml b/source/isaaclab_contrib/config/extension.toml index bdeec969ff56..ac99a4f4580c 100644 --- a/source/isaaclab_contrib/config/extension.toml +++ b/source/isaaclab_contrib/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.3.2" +version = "0.4.0" # Description title = "Isaac Lab External Contributions" diff --git a/source/isaaclab_contrib/docs/CHANGELOG.rst b/source/isaaclab_contrib/docs/CHANGELOG.rst index 24981d6deca6..2ffe1da42ae5 100644 --- a/source/isaaclab_contrib/docs/CHANGELOG.rst +++ b/source/isaaclab_contrib/docs/CHANGELOG.rst @@ -1,6 +1,34 @@ Changelog --------- +0.4.0 (2026-05-20) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :mod:`isaaclab_contrib.deformable` with contributed Newton deformable + asset and VBD solver support, including + :class:`~isaaclab_contrib.deformable.DeformableObject`, + :class:`~isaaclab_contrib.deformable.VBDSolverCfg`, + :class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg`, and + :class:`~isaaclab_contrib.deformable.CoupledFeatherstoneVBDSolverCfg` for + one- and two-way rigid-deformable coupling. +* Added :class:`~isaaclab_contrib.deformable.NewtonModelCfg` for shared Newton + deformable contact parameters. +* Added Newton deformable coupling documentation with Franka soft-body lift + tuning guidance for + :class:`~isaaclab_contrib.deformable.CoupledMJWarpVBDSolverCfg` and + :class:`~isaaclab_contrib.deformable.NewtonModelCfg`. + +Fixed +^^^^^ + +* Fixed ``[rlinf]`` extra dependency declarations to avoid version conflicts with IsaacLab core + (torch, transformers, tokenizers). Conflicting packages are now documented as manual ``--no-deps`` + installation steps. + + 0.3.2 (2026-05-12) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/changelog.d/antoiner-refactor-pr5455-followups.rst b/source/isaaclab_newton/changelog.d/antoiner-refactor-pr5455-followups.rst deleted file mode 100644 index d340938c97b4..000000000000 --- a/source/isaaclab_newton/changelog.d/antoiner-refactor-pr5455-followups.rst +++ /dev/null @@ -1,11 +0,0 @@ -Changed -^^^^^^^ - -* Moved Newton-native actuator USD authoring out of - ``isaaclab_newton.actuators.authoring`` (now deleted) into - :func:`~isaaclab.sim.schemas.define_actuator_properties`. The authoring step - is now invoked via the schema-side ``_post_spawn`` hook on - :class:`~isaaclab.assets.AssetBaseCfg`. -* Grouped :attr:`~isaaclab_newton.physics.NewtonManager._decimation` next to - :attr:`~isaaclab_newton.physics.NewtonManager._num_substeps` for consistency - with related solver-stepping configuration. diff --git a/source/isaaclab_newton/changelog.d/daniela-move-scene-data.rst b/source/isaaclab_newton/changelog.d/daniela-move-scene-data.rst deleted file mode 100644 index 1c077dc3f68a..000000000000 --- a/source/isaaclab_newton/changelog.d/daniela-move-scene-data.rst +++ /dev/null @@ -1,14 +0,0 @@ -Changed -^^^^^^^ - -* Updated imports of :class:`~isaaclab.scene_data.SceneDataBackend` and - :class:`~isaaclab.scene_data.SceneDataFormat` to their new location in - :mod:`isaaclab.scene_data` (previously :mod:`isaaclab.physics`). - -Fixed -^^^^^ - -* Fixed :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state` - retrieving the wrong simulation context. It now uses - :meth:`~isaaclab.sim.SimulationContext.instance` instead of the stale - ``PhysicsManager._sim`` reference. diff --git a/source/isaaclab_newton/changelog.d/mtrepte-update-debug-viz.rst b/source/isaaclab_newton/changelog.d/mtrepte-update-debug-viz.rst deleted file mode 100644 index 2ca3ab374223..000000000000 --- a/source/isaaclab_newton/changelog.d/mtrepte-update-debug-viz.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Fixed Newton visualization state updates for PhysX-backed simulations. diff --git a/source/isaaclab_newton/changelog.d/mym-deformable-backend-split.minor.rst b/source/isaaclab_newton/changelog.d/mym-deformable-backend-split.minor.rst deleted file mode 100644 index 3dade7d6fb9f..000000000000 --- a/source/isaaclab_newton/changelog.d/mym-deformable-backend-split.minor.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* Added Newton-specific deformable property and material cfgs. diff --git a/source/isaaclab_newton/changelog.d/mym-deformable_experimental.minor.rst b/source/isaaclab_newton/changelog.d/mym-deformable_experimental.minor.rst deleted file mode 100644 index aacba4481af9..000000000000 --- a/source/isaaclab_newton/changelog.d/mym-deformable_experimental.minor.rst +++ /dev/null @@ -1,33 +0,0 @@ -Added -^^^^^ - -* Added Newton deformable asset exports under - :mod:`isaaclab_newton.assets.deformable_object`. -* Added deformable registration hooks to Newton cloning so deformable assets can - be added per replicated world while their USD proxy meshes are skipped by the - Newton USD importer. -* Added Newton manager abstraction documentation for adding solver managers and - custom coupled solvers. - -Changed -^^^^^^^ - -* Changed Newton solver configuration exports so - :class:`~isaaclab_newton.physics.MJWarpSolverCfg`, - :class:`~isaaclab_newton.physics.XPBDSolverCfg`, - :class:`~isaaclab_newton.physics.FeatherstoneSolverCfg`, and - :class:`~isaaclab_newton.physics.KaminoSolverCfg` are provided from - :mod:`isaaclab_newton.physics.newton_manager_cfg`. -* Changed :class:`~isaaclab_newton.physics.NewtonCfg` to use - :class:`~isaaclab_newton.physics.MJWarpSolverCfg` as its explicit default - solver configuration. -* Changed :class:`~isaaclab_newton.physics.NewtonCfg` validation to reject - :class:`~isaaclab_newton.physics.MJWarpSolverCfg` configurations that combine - ``use_mujoco_contacts=True`` with ``collision_cfg``. Remove ``collision_cfg`` - or set ``use_mujoco_contacts=False``. - -Fixed -^^^^^ - -* Fixed Newton Fabric synchronization for deformable particle meshes and - particle-only scenes. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 24859f02a598..780f954e2c72 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.11.0" +version = "0.12.0" # 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 03ceddc82005..ae9cbdab43f4 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,61 @@ Changelog --------- +0.12.0 (2026-05-20) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added Newton-specific deformable property and material cfgs. +* Added Newton deformable asset exports under + :mod:`isaaclab_newton.assets.deformable_object`. +* Added deformable registration hooks to Newton cloning so deformable assets can + be added per replicated world while their USD proxy meshes are skipped by the + Newton USD importer. +* Added Newton manager abstraction documentation for adding solver managers and + custom coupled solvers. + +Changed +^^^^^^^ + +* Moved Newton-native actuator USD authoring out of + ``isaaclab_newton.actuators.authoring`` (now deleted) into + :func:`~isaaclab.sim.schemas.define_actuator_properties`. The authoring step + is now invoked via the schema-side ``_post_spawn`` hook on + :class:`~isaaclab.assets.AssetBaseCfg`. +* Grouped :attr:`~isaaclab_newton.physics.NewtonManager._decimation` next to + :attr:`~isaaclab_newton.physics.NewtonManager._num_substeps` for consistency + with related solver-stepping configuration. +* Changed Newton solver configuration exports so + :class:`~isaaclab_newton.physics.MJWarpSolverCfg`, + :class:`~isaaclab_newton.physics.XPBDSolverCfg`, + :class:`~isaaclab_newton.physics.FeatherstoneSolverCfg`, and + :class:`~isaaclab_newton.physics.KaminoSolverCfg` are provided from + :mod:`isaaclab_newton.physics.newton_manager_cfg`. +* Changed :class:`~isaaclab_newton.physics.NewtonCfg` to use + :class:`~isaaclab_newton.physics.MJWarpSolverCfg` as its explicit default + solver configuration. +* Changed :class:`~isaaclab_newton.physics.NewtonCfg` validation to reject + :class:`~isaaclab_newton.physics.MJWarpSolverCfg` configurations that combine + ``use_mujoco_contacts=True`` with ``collision_cfg``. Remove ``collision_cfg`` + or set ``use_mujoco_contacts=False``. +* Updated imports of :class:`~isaaclab.scene_data.SceneDataBackend` and + :class:`~isaaclab.scene_data.SceneDataFormat` to their new location in + :mod:`isaaclab.scene_data` (previously :mod:`isaaclab.physics`). + +Fixed +^^^^^ + +* Fixed Newton visualization state updates for PhysX-backed simulations. +* Fixed Newton Fabric synchronization for deformable particle meshes and + particle-only scenes. +* Fixed :meth:`~isaaclab_newton.physics.NewtonManager.update_visualization_state` + retrieving the wrong simulation context. It now uses + :meth:`~isaaclab.sim.SimulationContext.instance` instead of the stale + ``PhysicsManager._sim`` reference. + + 0.11.0 (2026-05-17) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contactsensor.minor.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contactsensor.minor.rst deleted file mode 100644 index 1fa4e49623c7..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contactsensor.minor.rst +++ /dev/null @@ -1,46 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_ovphysx.sensors.ContactSensor`, - :class:`~isaaclab_ovphysx.sensors.ContactSensorCfg`, and - :class:`~isaaclab_ovphysx.sensors.ContactSensorData` for the OVPhysX - backend, satisfying the - :class:`~isaaclab.sensors.contact_sensor.BaseContactSensor` and - :class:`~isaaclab.sensors.contact_sensor.BaseContactSensorData` - contracts. Wires net contact forces and the per-partner force matrix - through the OVPhysX :class:`ovphysx.api.ContactBinding` API - (``read_net_forces`` / ``read_force_matrix``); optional pose tracking - reads through a ``RIGID_BODY_POSE`` :class:`ovphysx.api.TensorBinding`. - Air/contact time tracking, - :meth:`~isaaclab_ovphysx.sensors.ContactSensor.compute_first_contact`, - :meth:`~isaaclab_ovphysx.sensors.ContactSensor.compute_first_air`, - history buffers, and reset semantics mirror the PhysX backend. -* Added the shared - :mod:`isaaclab_ovphysx.sensors.kernels` module with - :func:`~isaaclab_ovphysx.sensors.kernels.concat_pos_and_quat_to_pose_kernel` - and the 1D variant for reuse across future OVPhysX sensors. - -Changed -^^^^^^^ - -* Changed the existing - ``source/isaaclab_ovphysx/test/sensors/check_contact_sensor.py`` - stubs to real tests adapted from the PhysX - :mod:`isaaclab_physx.test.sensors.test_contact_sensor` suite. The - three tests that exercise ``track_contact_points`` or - ``track_friction_forces`` are decorated with - :func:`pytest.mark.skip` until the OVPhysX wheel ships - tensor-friendly per-sensor reads (see - ``docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md``); - the test bodies are preserved so the decorator can be removed in a - follow-up. - -Removed -^^^^^^^ - -* **Breaking:** Removed the five - ``source/isaaclab_ovphysx/test/sensors/check_contact_sensor.py`` - ``pytest.skip("Contact sensor not yet supported by ovphysx - backend.")`` placeholders in favour of the real test suite above. - No public migration is required; the placeholder names did not - appear in any external API. diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.major.rst deleted file mode 100644 index 460fdd789f28..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection.major.rst +++ /dev/null @@ -1,13 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_ovphysx.assets.RigidObjectCollection` and - :class:`~isaaclab_ovphysx.assets.RigidObjectCollectionData` for the - OVPhysX backend, completing the rigid-body asset surface alongside - :class:`~isaaclab_ovphysx.assets.RigidObject` and - :class:`~isaaclab_ovphysx.assets.Articulation`. Supports - ``(env, body)`` dual indexing and per-body property setters. Uses the - ovphysx 0.4.3+ native fused multi-prim binding API - (``create_tensor_binding(prim_paths=[...])``) so one binding spans all - ``num_instances * num_bodies`` prims per tensor type, mirroring the - strided-view reshape pattern used by the PhysX collection. diff --git a/source/isaaclab_ovphysx/changelog.d/daniela-move-scene-data.rst b/source/isaaclab_ovphysx/changelog.d/daniela-move-scene-data.rst deleted file mode 100644 index 3a830a73152a..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/daniela-move-scene-data.rst +++ /dev/null @@ -1,7 +0,0 @@ -Changed -^^^^^^^ - -* Updated imports of :class:`~isaaclab.scene_data.SceneDataBackend` and - :class:`~isaaclab.scene_data.SceneDataFormat` in - :mod:`~isaaclab_ovphysx.physics.ovphysx_manager` to their new location in - :mod:`isaaclab.scene_data` (previously :mod:`isaaclab.physics`). diff --git a/source/isaaclab_ovphysx/changelog.d/ovphysx-frameview.minor.rst b/source/isaaclab_ovphysx/changelog.d/ovphysx-frameview.minor.rst deleted file mode 100644 index e3e0f653d4bf..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/ovphysx-frameview.minor.rst +++ /dev/null @@ -1,11 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView`, a - Warp-native batched-prim view that reads world poses from the OVPhysX - scene data provider's ``body_q`` array. Mirrors - :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` in semantics - and API: ``set_world_poses`` / ``set_local_poses`` update the view's - internal ``site_local`` buffer and never mutate the physics state. - Scales and visibility delegate to a lazy internal - :class:`~isaaclab.sim.views.UsdFrameView`. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 8b0ccad725d6..4853a0c75a4c 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 = "2.1.0" +version = "3.0.0" # 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 2081986bd4d9..19c2b60ef750 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,79 @@ Changelog --------- +3.0.0 (2026-05-20) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.assets.RigidObjectCollection` and + :class:`~isaaclab_ovphysx.assets.RigidObjectCollectionData` for the + OVPhysX backend, completing the rigid-body asset surface alongside + :class:`~isaaclab_ovphysx.assets.RigidObject` and + :class:`~isaaclab_ovphysx.assets.Articulation`. Supports + ``(env, body)`` dual indexing and per-body property setters. Uses the + ovphysx 0.4.3+ native fused multi-prim binding API + (``create_tensor_binding(prim_paths=[...])``) so one binding spans all + ``num_instances * num_bodies`` prims per tensor type, mirroring the + strided-view reshape pattern used by the PhysX collection. +* Added :class:`~isaaclab_ovphysx.sensors.ContactSensor`, + :class:`~isaaclab_ovphysx.sensors.ContactSensorCfg`, and + :class:`~isaaclab_ovphysx.sensors.ContactSensorData` for the OVPhysX + backend, satisfying the + :class:`~isaaclab.sensors.contact_sensor.BaseContactSensor` and + :class:`~isaaclab.sensors.contact_sensor.BaseContactSensorData` + contracts. Wires net contact forces and the per-partner force matrix + through the OVPhysX :class:`ovphysx.api.ContactBinding` API + (``read_net_forces`` / ``read_force_matrix``); optional pose tracking + reads through a ``RIGID_BODY_POSE`` :class:`ovphysx.api.TensorBinding`. + Air/contact time tracking, + :meth:`~isaaclab_ovphysx.sensors.ContactSensor.compute_first_contact`, + :meth:`~isaaclab_ovphysx.sensors.ContactSensor.compute_first_air`, + history buffers, and reset semantics mirror the PhysX backend. +* Added the shared + :mod:`isaaclab_ovphysx.sensors.kernels` module with + :func:`~isaaclab_ovphysx.sensors.kernels.concat_pos_and_quat_to_pose_kernel` + and the 1D variant for reuse across future OVPhysX sensors. +* Added :class:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView`, a + Warp-native batched-prim view that reads world poses from the OVPhysX + scene data provider's ``body_q`` array. Mirrors + :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` in semantics + and API: ``set_world_poses`` / ``set_local_poses`` update the view's + internal ``site_local`` buffer and never mutate the physics state. + Scales and visibility delegate to a lazy internal + :class:`~isaaclab.sim.views.UsdFrameView`. + +Changed +^^^^^^^ + +* Changed the existing + ``source/isaaclab_ovphysx/test/sensors/check_contact_sensor.py`` + stubs to real tests adapted from the PhysX + :mod:`isaaclab_physx.test.sensors.test_contact_sensor` suite. The + three tests that exercise ``track_contact_points`` or + ``track_friction_forces`` are decorated with + :func:`pytest.mark.skip` until the OVPhysX wheel ships + tensor-friendly per-sensor reads (see + ``docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md``); + the test bodies are preserved so the decorator can be removed in a + follow-up. +* Updated imports of :class:`~isaaclab.scene_data.SceneDataBackend` and + :class:`~isaaclab.scene_data.SceneDataFormat` in + :mod:`~isaaclab_ovphysx.physics.ovphysx_manager` to their new location in + :mod:`isaaclab.scene_data` (previously :mod:`isaaclab.physics`). + +Removed +^^^^^^^ + +* **Breaking:** Removed the five + ``source/isaaclab_ovphysx/test/sensors/check_contact_sensor.py`` + ``pytest.skip("Contact sensor not yet supported by ovphysx + backend.")`` placeholders in favour of the real test suite above. + No public migration is required; the placeholder names did not + appear in any external API. + + 2.1.0 (2026-05-19) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/changelog.d/antoiner-refactor-pr5455-followups.rst b/source/isaaclab_physx/changelog.d/antoiner-refactor-pr5455-followups.rst deleted file mode 100644 index 5d20d3888780..000000000000 --- a/source/isaaclab_physx/changelog.d/antoiner-refactor-pr5455-followups.rst +++ /dev/null @@ -1,8 +0,0 @@ -Changed -^^^^^^^ - -* Reworded the FF-routing comments in - :class:`~isaaclab_physx.assets.Articulation` to refer to "actuated DOFs" - rather than splitting on implicit vs explicit, since the - ``synch_torque_and_apply_implicit_feedforwards`` kernel operates on the full - actuated DOF set. diff --git a/source/isaaclab_physx/changelog.d/daniela-move-scene-data.rst b/source/isaaclab_physx/changelog.d/daniela-move-scene-data.rst deleted file mode 100644 index d2e0b7df99ad..000000000000 --- a/source/isaaclab_physx/changelog.d/daniela-move-scene-data.rst +++ /dev/null @@ -1,6 +0,0 @@ -Changed -^^^^^^^ - -* Updated imports of :class:`~isaaclab.scene_data.SceneDataBackend` and - :class:`~isaaclab.scene_data.SceneDataFormat` to their new location in - :mod:`isaaclab.scene_data` (previously :mod:`isaaclab.physics`). diff --git a/source/isaaclab_physx/changelog.d/mtrepte-update-debug-viz.skip b/source/isaaclab_physx/changelog.d/mtrepte-update-debug-viz.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_physx/changelog.d/mym-deformable-backend-split.minor.rst b/source/isaaclab_physx/changelog.d/mym-deformable-backend-split.minor.rst deleted file mode 100644 index 0879ea0a90f9..000000000000 --- a/source/isaaclab_physx/changelog.d/mym-deformable-backend-split.minor.rst +++ /dev/null @@ -1,11 +0,0 @@ -Added -^^^^^ - -* Added PhysX-specific deformable property and material cfgs. - -Deprecated -^^^^^^^^^^ - -* Deprecated generic PhysX deformable cfg aliases in favor of - ``PhysxDeformableBodyPropertiesCfg``, ``PhysxDeformableBodyMaterialCfg``, - and ``PhysxSurfaceDeformableBodyMaterialCfg``. diff --git a/source/isaaclab_physx/changelog.d/mym-deformable_experimental.major.rst b/source/isaaclab_physx/changelog.d/mym-deformable_experimental.major.rst deleted file mode 100644 index aa4a2dad99fd..000000000000 --- a/source/isaaclab_physx/changelog.d/mym-deformable_experimental.major.rst +++ /dev/null @@ -1,24 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** Moved deformable body schema and material APIs from - :mod:`isaaclab_physx.sim` to :mod:`isaaclab.sim`, and moved deformable object - configuration from :mod:`isaaclab_physx.assets` to :mod:`isaaclab.assets`. - Import :class:`~isaaclab.sim.DeformableBodyPropertiesCfg`, - :func:`~isaaclab.sim.define_deformable_body_properties`, - :func:`~isaaclab.sim.modify_deformable_body_properties`, - :class:`~isaaclab.sim.DeformableObjectSpawnerCfg`, - :class:`~isaaclab.sim.DeformableBodyMaterialCfg`, - :class:`~isaaclab.sim.SurfaceDeformableBodyMaterialCfg`, and - :func:`~isaaclab.sim.spawn_deformable_body_material` from :mod:`isaaclab.sim` - instead of :mod:`isaaclab_physx.sim`; import - :class:`~isaaclab.assets.DeformableObjectCfg` from :mod:`isaaclab.assets` - instead of :mod:`isaaclab_physx.assets`. -* Changed PhysX deformable API documentation to direct users to the - backend-neutral :mod:`isaaclab.assets` and :mod:`isaaclab.sim` imports. - -Fixed -^^^^^ - -* Fixed :class:`~isaaclab_physx.assets.DeformableObject` state writer methods - to accept ``ProxyArray`` inputs without requiring manual conversion. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 37cee764eed8..2ff0f9bff5af 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.9.0" +version = "1.0.0" # 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 416ad468b059..92fdffe00ee8 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,55 @@ Changelog --------- +1.0.0 (2026-05-20) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added PhysX-specific deformable property and material cfgs. + +Changed +^^^^^^^ + +* Reworded the FF-routing comments in + :class:`~isaaclab_physx.assets.Articulation` to refer to "actuated DOFs" + rather than splitting on implicit vs explicit, since the + ``synch_torque_and_apply_implicit_feedforwards`` kernel operates on the full + actuated DOF set. +* **Breaking:** Moved deformable body schema and material APIs from + :mod:`isaaclab_physx.sim` to :mod:`isaaclab.sim`, and moved deformable object + configuration from :mod:`isaaclab_physx.assets` to :mod:`isaaclab.assets`. + Import :class:`~isaaclab.sim.DeformableBodyPropertiesCfg`, + :func:`~isaaclab.sim.define_deformable_body_properties`, + :func:`~isaaclab.sim.modify_deformable_body_properties`, + :class:`~isaaclab.sim.DeformableObjectSpawnerCfg`, + :class:`~isaaclab.sim.DeformableBodyMaterialCfg`, + :class:`~isaaclab.sim.SurfaceDeformableBodyMaterialCfg`, and + :func:`~isaaclab.sim.spawn_deformable_body_material` from :mod:`isaaclab.sim` + instead of :mod:`isaaclab_physx.sim`; import + :class:`~isaaclab.assets.DeformableObjectCfg` from :mod:`isaaclab.assets` + instead of :mod:`isaaclab_physx.assets`. +* Changed PhysX deformable API documentation to direct users to the + backend-neutral :mod:`isaaclab.assets` and :mod:`isaaclab.sim` imports. +* Updated imports of :class:`~isaaclab.scene_data.SceneDataBackend` and + :class:`~isaaclab.scene_data.SceneDataFormat` to their new location in + :mod:`isaaclab.scene_data` (previously :mod:`isaaclab.physics`). + +Deprecated +^^^^^^^^^^ + +* Deprecated generic PhysX deformable cfg aliases in favor of + ``PhysxDeformableBodyPropertiesCfg``, ``PhysxDeformableBodyMaterialCfg``, + and ``PhysxSurfaceDeformableBodyMaterialCfg``. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_physx.assets.DeformableObject` state writer methods + to accept ``ProxyArray`` inputs without requiring manual conversion. + + 0.9.0 (2026-05-17) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_rl/changelog.d/mtrepte-update-debug-viz.skip b/source/isaaclab_rl/changelog.d/mtrepte-update-debug-viz.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_contactsensor.rst b/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_contactsensor.rst deleted file mode 100644 index ab7247b1bea5..000000000000 --- a/source/isaaclab_tasks/changelog.d/antoiner-feat-ovphysx_contactsensor.rst +++ /dev/null @@ -1,10 +0,0 @@ -Added -^^^^^ - -* Added ``ovphysx`` preset to ``isaaclab_tasks.manager_based.locomotion.velocity`` - for use under the OVPhysX backend. ``AnymalDFlatPhysicsCfg`` now exposes - an ``ovphysx`` member, and the shared ``LocomotionVelocityRoughEnvCfg`` - injects the OVPhysX :class:`~isaaclab_ovphysx.sensors.ContactSensorCfg` - alongside the existing PhysX and Newton entries so the velocity task - selects the right contact sensor backend when run with - ``presets=ovphysx``. diff --git a/source/isaaclab_tasks/changelog.d/esekkin-pr-a-newton-xfail.rst b/source/isaaclab_tasks/changelog.d/esekkin-pr-a-newton-xfail.rst deleted file mode 100644 index 42736dca455b..000000000000 --- a/source/isaaclab_tasks/changelog.d/esekkin-pr-a-newton-xfail.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Removed the stale file-level ``@pytest.mark.xfail`` decorator on - ``test_environments_newton`` (the cited Hydra deep-nesting issue was already - resolved by PR #5029 and follow-ups #5130 / #5177). diff --git a/source/isaaclab_tasks/changelog.d/mingxue-fixed_rlinf_install.rst b/source/isaaclab_tasks/changelog.d/mingxue-fixed_rlinf_install.rst deleted file mode 100644 index bc6f04ff6d26..000000000000 --- a/source/isaaclab_tasks/changelog.d/mingxue-fixed_rlinf_install.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added ``Isaac-Assemble-Trocar-G129-Dex3-v0`` and ``Isaac-Assemble-Trocar-G129-Dex3-Eval-v0`` - environments for RL fine-tuning of VLA models with RLinf. diff --git a/source/isaaclab_tasks/changelog.d/mtrepte-update-debug-viz.skip b/source/isaaclab_tasks/changelog.d/mtrepte-update-debug-viz.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_tasks/changelog.d/mym-deformable-backend-split.rst b/source/isaaclab_tasks/changelog.d/mym-deformable-backend-split.rst deleted file mode 100644 index e0cd3070591c..000000000000 --- a/source/isaaclab_tasks/changelog.d/mym-deformable-backend-split.rst +++ /dev/null @@ -1,6 +0,0 @@ -Changed -^^^^^^^ - -* Changed Franka soft-object task configs to use backend-specific deformable cfgs. - Use Newton deformable cfgs from :mod:`isaaclab_newton.sim` or PhysX deformable - cfgs from :mod:`isaaclab_physx.sim` when customizing these tasks. diff --git a/source/isaaclab_tasks/changelog.d/mym-deformable_experimental.minor.rst b/source/isaaclab_tasks/changelog.d/mym-deformable_experimental.minor.rst deleted file mode 100644 index 561a08c3e261..000000000000 --- a/source/isaaclab_tasks/changelog.d/mym-deformable_experimental.minor.rst +++ /dev/null @@ -1,6 +0,0 @@ -Added -^^^^^ - -* Added manager-based Franka soft-body lifting environment - ``Isaac-Lift-Soft-Franka-v0`` as the documented rigid-deformable coupling - task. diff --git a/source/isaaclab_tasks/changelog.d/rwiltz-mcap-replay-agent.minor.rst b/source/isaaclab_tasks/changelog.d/rwiltz-mcap-replay-agent.minor.rst deleted file mode 100644 index 2760c4bfeab6..000000000000 --- a/source/isaaclab_tasks/changelog.d/rwiltz-mcap-replay-agent.minor.rst +++ /dev/null @@ -1,12 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** Removed the lazy legacy ``teleop_devices`` (``handtracking`` / ``manusvive``) - accessor on - :class:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.PickPlaceGR1T2EnvCfg`. - The env still exposes ``isaac_teleop`` (an :class:`~isaaclab_teleop.IsaacTeleopCfg`), which is - what the in-tree teleoperation, recording, and replay scripts use by default. Consumers that - read ``env_cfg.teleop_devices`` directly to build a legacy - :class:`~isaaclab.devices.openxr.OpenXRDevice` should construct it themselves or migrate to - :class:`~isaaclab_teleop.IsaacTeleopDevice` (see ``scripts/environments/teleoperation/teleop_se3_agent.py`` - for the migrated pattern). diff --git a/source/isaaclab_tasks/changelog.d/update-presets-doc.rst b/source/isaaclab_tasks/changelog.d/update-presets-doc.rst deleted file mode 100644 index 01c752920057..000000000000 --- a/source/isaaclab_tasks/changelog.d/update-presets-doc.rst +++ /dev/null @@ -1,9 +0,0 @@ -Added -^^^^^ - -* Added :func:`~isaaclab_tasks.utils.preset_cli.enumerate_task_presets` public helper that - returns the available preset names for a registered task, bucketed by selector type - (``physics=``, ``renderer=``, ``presets=``). Used by tooling such as ``list_envs.py``. -* Added ``--show_presets`` flag to ``scripts/environments/list_envs.py``. When set, a - **Presets** column is added to the environment table showing physics, renderer, and domain - preset names available for each environment. diff --git a/source/isaaclab_tasks/changelog.d/zhengyuz-active-tree-preset-resolution.rst b/source/isaaclab_tasks/changelog.d/zhengyuz-active-tree-preset-resolution.rst deleted file mode 100644 index b9fcc455ef99..000000000000 --- a/source/isaaclab_tasks/changelog.d/zhengyuz-active-tree-preset-resolution.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed nested :class:`~isaaclab_tasks.utils.hydra.PresetCfg` resolution so - child preset choices are scoped to the selected parent branch. -* Improved task config resolution time by bypassing Hydra composition when only - preset selections or plain scalar overrides are used. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 5fb0c305cbd8..9e67b40f576d 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.9.0" +version = "1.10.0" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 6dd978081a3d..7fee0f626042 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,59 @@ Changelog --------- +1.10.0 (2026-05-20) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :func:`~isaaclab_tasks.utils.preset_cli.enumerate_task_presets` public helper that + returns the available preset names for a registered task, bucketed by selector type + (``physics=``, ``renderer=``, ``presets=``). Used by tooling such as ``list_envs.py``. +* Added ``--show_presets`` flag to ``scripts/environments/list_envs.py``. When set, a + **Presets** column is added to the environment table showing physics, renderer, and domain + preset names available for each environment. +* Added ``Isaac-Assemble-Trocar-G129-Dex3-v0`` and ``Isaac-Assemble-Trocar-G129-Dex3-Eval-v0`` + environments for RL fine-tuning of VLA models with RLinf. +* Added ``ovphysx`` preset to ``isaaclab_tasks.manager_based.locomotion.velocity`` + for use under the OVPhysX backend. ``AnymalDFlatPhysicsCfg`` now exposes + an ``ovphysx`` member, and the shared ``LocomotionVelocityRoughEnvCfg`` + injects the OVPhysX :class:`~isaaclab_ovphysx.sensors.ContactSensorCfg` + alongside the existing PhysX and Newton entries so the velocity task + selects the right contact sensor backend when run with + ``presets=ovphysx``. +* Added manager-based Franka soft-body lifting environment + ``Isaac-Lift-Soft-Franka-v0`` as the documented rigid-deformable coupling + task. + +Changed +^^^^^^^ + +* **Breaking:** Removed the lazy legacy ``teleop_devices`` (``handtracking`` / ``manusvive``) + accessor on + :class:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.PickPlaceGR1T2EnvCfg`. + The env still exposes ``isaac_teleop`` (an :class:`~isaaclab_teleop.IsaacTeleopCfg`), which is + what the in-tree teleoperation, recording, and replay scripts use by default. Consumers that + read ``env_cfg.teleop_devices`` directly to build a legacy + :class:`~isaaclab.devices.openxr.OpenXRDevice` should construct it themselves or migrate to + :class:`~isaaclab_teleop.IsaacTeleopDevice` (see ``scripts/environments/teleoperation/teleop_se3_agent.py`` + for the migrated pattern). +* Changed Franka soft-object task configs to use backend-specific deformable cfgs. + Use Newton deformable cfgs from :mod:`isaaclab_newton.sim` or PhysX deformable + cfgs from :mod:`isaaclab_physx.sim` when customizing these tasks. + +Fixed +^^^^^ + +* Fixed nested :class:`~isaaclab_tasks.utils.hydra.PresetCfg` resolution so + child preset choices are scoped to the selected parent branch. +* Improved task config resolution time by bypassing Hydra composition when only + preset selections or plain scalar overrides are used. +* Removed the stale file-level ``@pytest.mark.xfail`` decorator on + ``test_environments_newton`` (the cited Hydra deep-nesting issue was already + resolved by PR #5029 and follow-ups #5130 / #5177). + + 1.9.0 (2026-05-19) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_teleop/changelog.d/rwiltz-mcap-replay-agent.minor.rst b/source/isaaclab_teleop/changelog.d/rwiltz-mcap-replay-agent.minor.rst deleted file mode 100644 index 4fa6c2501f49..000000000000 --- a/source/isaaclab_teleop/changelog.d/rwiltz-mcap-replay-agent.minor.rst +++ /dev/null @@ -1,36 +0,0 @@ -Added -^^^^^ - -* Added MCAP record/replay support to :class:`~isaaclab_teleop.IsaacTeleopDevice` via new - ``mcap_record_path`` and ``mcap_replay_path`` parameters on - :func:`~isaaclab_teleop.create_isaac_teleop_device` (mutually exclusive). ``mcap_replay_path`` - switches the underlying :class:`isacteleop.teleop_session_manager.TeleopSession` into - :class:`SessionMode.REPLAY` and feeds the recorded tracker stream through the configured - retargeting pipeline; ``mcap_record_path`` is a debug-grade knob that writes the live session - to a single continuous MCAP file for pairing with the replay agent in CI. It is **not** a - data-generation format -- the produced MCAP has no per-episode segmentation, no world-frame - anchor state, no env reset state, and no public Python decoder. -* Added a ``--mcap_record_path`` (debug-only) flag to ``scripts/tools/record_demos.py`` that - forwards into :func:`~isaaclab_teleop.create_isaac_teleop_device` when the IsaacTeleop stack - is in use. -* Added ``scripts/environments/teleoperation/teleop_replay_agent.py``, a non-interactive entry - point used by CI to replay captured Isaac Teleop sessions against an Isaac Lab environment. - The agent gates env stepping on :func:`~isaaclab_teleop.poll_control_events` so the recorded - START / STOP / RESET boundaries reproduce the original recording's pacing, and asks Kit to - ``post_quit`` on the first STOP-edge after teleop has been active so the host process exits - deterministically. - -Changed -^^^^^^^ - -* **Breaking:** Removed the ``isaaclab_teleop.automation`` subpackage, including - ``XcrReplayConfig`` and ``start_xcr_replay``. The XCR backend was a transitional Kit-level - OpenXR capture/replay path that pre-dated Isaac Teleop's native MCAP record/replay. Replays - now go through ``teleop_replay_agent.py`` against an MCAP capture produced by Isaac Teleop. -* **Breaking:** Removed the lazy legacy ``teleop_devices`` (``handtracking`` / ``manusvive``) - accessor on - :class:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.PickPlaceGR1T2EnvCfg`. - All in-tree scripts (``teleop_se3_agent.py``, ``record_demos.py``, ``teleop_replay_agent.py``) - prefer ``env_cfg.isaac_teleop``; consumers that built the legacy - :class:`~isaaclab.devices.openxr.OpenXRDevice` directly from the env config should construct - it themselves or migrate to :class:`~isaaclab_teleop.IsaacTeleopDevice`. diff --git a/source/isaaclab_teleop/config/extension.toml b/source/isaaclab_teleop/config/extension.toml index b4e568e6ae27..fb15dd480bcf 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.4.0" +version = "0.5.0" # Description title = "Isaac Lab Teleop" diff --git a/source/isaaclab_teleop/docs/CHANGELOG.rst b/source/isaaclab_teleop/docs/CHANGELOG.rst index 8e4b2c58cd48..3854ccece4aa 100644 --- a/source/isaaclab_teleop/docs/CHANGELOG.rst +++ b/source/isaaclab_teleop/docs/CHANGELOG.rst @@ -1,6 +1,47 @@ Changelog --------- +0.5.0 (2026-05-20) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added MCAP record/replay support to :class:`~isaaclab_teleop.IsaacTeleopDevice` via new + ``mcap_record_path`` and ``mcap_replay_path`` parameters on + :func:`~isaaclab_teleop.create_isaac_teleop_device` (mutually exclusive). ``mcap_replay_path`` + switches the underlying :class:`isacteleop.teleop_session_manager.TeleopSession` into + :class:`SessionMode.REPLAY` and feeds the recorded tracker stream through the configured + retargeting pipeline; ``mcap_record_path`` is a debug-grade knob that writes the live session + to a single continuous MCAP file for pairing with the replay agent in CI. It is **not** a + data-generation format -- the produced MCAP has no per-episode segmentation, no world-frame + anchor state, no env reset state, and no public Python decoder. +* Added a ``--mcap_record_path`` (debug-only) flag to ``scripts/tools/record_demos.py`` that + forwards into :func:`~isaaclab_teleop.create_isaac_teleop_device` when the IsaacTeleop stack + is in use. +* Added ``scripts/environments/teleoperation/teleop_replay_agent.py``, a non-interactive entry + point used by CI to replay captured Isaac Teleop sessions against an Isaac Lab environment. + The agent gates env stepping on :func:`~isaaclab_teleop.poll_control_events` so the recorded + START / STOP / RESET boundaries reproduce the original recording's pacing, and asks Kit to + ``post_quit`` on the first STOP-edge after teleop has been active so the host process exits + deterministically. + +Changed +^^^^^^^ + +* **Breaking:** Removed the ``isaaclab_teleop.automation`` subpackage, including + ``XcrReplayConfig`` and ``start_xcr_replay``. The XCR backend was a transitional Kit-level + OpenXR capture/replay path that pre-dated Isaac Teleop's native MCAP record/replay. Replays + now go through ``teleop_replay_agent.py`` against an MCAP capture produced by Isaac Teleop. +* **Breaking:** Removed the lazy legacy ``teleop_devices`` (``handtracking`` / ``manusvive``) + accessor on + :class:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.PickPlaceGR1T2EnvCfg`. + All in-tree scripts (``teleop_se3_agent.py``, ``record_demos.py``, ``teleop_replay_agent.py``) + prefer ``env_cfg.isaac_teleop``; consumers that built the legacy + :class:`~isaaclab.devices.openxr.OpenXRDevice` directly from the env config should construct + it themselves or migrate to :class:`~isaaclab_teleop.IsaacTeleopDevice`. + + 0.4.0 (2026-05-16) ~~~~~~~~~~~~~~~~~~ From a34c5f0f91bf5e17180bd47c9422d5bb79466f3d Mon Sep 17 00:00:00 2001 From: rwiltz <165190220+rwiltz@users.noreply.github.com> Date: Wed, 20 May 2026 05:58:57 -0400 Subject: [PATCH 124/133] Fix crash in kit visualizer (#5699) # Description Fixes a `Boost.Python.ArgumentError: Matrix4d.Transform(Matrix4d, NoneType)` crash in `KitVisualizer` that surfaced as a `RuntimeError` from `SimulationContext.initialize_visualizers()` whenever a `KitVisualizerCfg` was used with an explicitly configured `eye` / `lookat` and the active viewport's camera (typically `/OmniverseKit_Persp` on a freshly-opened stage) had no `omni:kit:centerOfInterest` attribute authored. `_set_viewport_camera` was issuing `ViewportCameraState.set_position_world(..., rotate=True)`. With `rotate=True`, the camera state reads `omni:kit:centerOfInterest` and pipes it through `Matrix4d.Transform`; on a fresh camera the attribute getter returns `None` and the boost binding rejects it. Fix: use `rotate=False` for the position set. The follow-up `set_target_world(..., rotate=True)` performs the look-at rotation and authors the COI as a side effect, so the final camera pose is unchanged. Reproducible from `scripts/environments/teleoperation/teleop_replay_agent.py` with `--viz kit`. The same bug is reachable from `record_demos.py` on a clean stage; existing runs avoided it only because something else had already authored the COI before `_set_viewport_camera` ran. 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 --- .../rwiltz-fix-viewport-camera-coi.skip | 0 .../test_simulation_context_visualizers.py | 67 +++++++++++++++++++ .../rwiltz-fix-viewport-camera-coi.rst | 13 ++++ .../kit/kit_visualizer.py | 8 ++- 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/rwiltz-fix-viewport-camera-coi.skip create mode 100644 source/isaaclab_visualizers/changelog.d/rwiltz-fix-viewport-camera-coi.rst diff --git a/source/isaaclab/changelog.d/rwiltz-fix-viewport-camera-coi.skip b/source/isaaclab/changelog.d/rwiltz-fix-viewport-camera-coi.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 9e590356932d..4e0cf97a3e74 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -526,6 +526,73 @@ def test_kit_visualizer_default_camera_source_accepts_set_camera_view(monkeypatc assert applied_camera_poses == [((1.0, 2.0, 3.0), (0.0, 0.0, 1.0))] +def test_kit_visualizer_set_viewport_camera_does_not_require_authored_coi(monkeypatch: pytest.MonkeyPatch): + """Regression: ``_set_viewport_camera`` must not feed an unauthored ``omni:kit:centerOfInterest`` into + ``ViewportCameraState.set_position_world``. + + A freshly-opened stage's default ``/OmniverseKit_Persp`` camera has no ``omni:kit:centerOfInterest`` attribute + authored. ``ViewportCameraState.set_position_world(..., rotate=True)`` reads that attribute as ``None`` and + crashes inside ``Matrix4d.Transform`` (the boost binding rejects ``NoneType``). ``_set_viewport_camera`` must + therefore use ``rotate=False`` for the eye set; the follow-up ``set_target_world(..., rotate=True)`` performs + the look-at rotation and authors the COI as a side effect. + + The fake ``ViewportCameraState`` here mirrors that boost-binding behavior: ``set_position_world(..., rotate=True)`` + raises ``TypeError``, so the old call path would surface inside ``_set_viewport_camera`` exactly as it did in + production. + """ + + class _FakeViewportApi: + def get_active_camera(self): + return "/OmniverseKit_Persp" + + state_holder: dict[str, Any] = {} + + class _FakeCameraState: + def __init__(self, camera_path: str, viewport_api): + self.position_calls: list[tuple[Any, bool]] = [] + self.target_calls: list[tuple[Any, bool]] = [] + state_holder["state"] = self + + def set_position_world(self, world_position, rotate): + if rotate: + raise TypeError( + "Python argument types in Matrix4d.Transform(Matrix4d, NoneType) did not match C++ signature" + ) + self.position_calls.append((world_position, rotate)) + + def set_target_world(self, world_target, rotate): + self.target_calls.append((world_target, rotate)) + + camera_state_module = type(sys)("omni.kit.viewport.utility.camera_state") + camera_state_module.ViewportCameraState = _FakeCameraState + + monkeypatch.setitem(sys.modules, "omni", type(sys)("omni")) + monkeypatch.setitem(sys.modules, "omni.kit", type(sys)("omni.kit")) + monkeypatch.setitem(sys.modules, "omni.kit.viewport", type(sys)("omni.kit.viewport")) + monkeypatch.setitem(sys.modules, "omni.kit.viewport.utility", type(sys)("omni.kit.viewport.utility")) + monkeypatch.setitem(sys.modules, "omni.kit.viewport.utility.camera_state", camera_state_module) + + cfg = KitVisualizerCfg() + visualizer = kit_visualizer.KitVisualizer(cfg) + visualizer._viewport_api = _FakeViewportApi() + + eye = (1.0, 2.0, 3.0) + target = (4.0, 5.0, 6.0) + + visualizer._set_viewport_camera(eye, target) + + state = state_holder["state"] + assert len(state.position_calls) == 1 + pos_arg, pos_rotate = state.position_calls[0] + assert pos_rotate is False + assert (float(pos_arg[0]), float(pos_arg[1]), float(pos_arg[2])) == eye + + assert len(state.target_calls) == 1 + tgt_arg, tgt_rotate = state.target_calls[0] + assert tgt_rotate is True + assert (float(tgt_arg[0]), float(tgt_arg[1]), float(tgt_arg[2])) == target + + def test_get_cli_visualizer_types_handles_non_string_setting_without_crashing(): ctx = object.__new__(SimulationContext) ctx.get_setting = lambda name: {"types": "newton,kit"} if name == "/isaaclab/visualizer/types" else None diff --git a/source/isaaclab_visualizers/changelog.d/rwiltz-fix-viewport-camera-coi.rst b/source/isaaclab_visualizers/changelog.d/rwiltz-fix-viewport-camera-coi.rst new file mode 100644 index 000000000000..4f51ff7a4879 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/rwiltz-fix-viewport-camera-coi.rst @@ -0,0 +1,13 @@ +Fixed +^^^^^ + +* Fixed :meth:`~isaaclab_visualizers.kit.KitVisualizer._set_viewport_camera` + raising ``Boost.Python.ArgumentError: Matrix4d.Transform(Matrix4d, NoneType)`` + during ``sim.reset()`` when ``KitVisualizerCfg.eye`` / ``lookat`` were + configured. The call was issuing ``ViewportCameraState.set_position_world(..., + rotate=True)`` on a freshly-initialized viewport camera, which reads + ``omni:kit:centerOfInterest`` from the camera prim and pipes it through + ``world_xform.Transform(...)``; on an unauthored COI the attribute getter + returns ``None`` and the C++ binding rejects it. The position set now uses + ``rotate=False`` -- the subsequent ``set_target_world(..., rotate=True)`` + authors the COI and rotates the camera to the configured target. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index ec51d8b183d4..5931a11ca917 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -343,8 +343,14 @@ def _set_viewport_camera(self, position: tuple[float, float, float], target: tup if not camera_path: camera_path = "/OmniverseKit_Persp" + # ``rotate=False`` for the position set: a freshly-opened stage's default + # ``/OmniverseKit_Persp`` has no authored ``omni:kit:centerOfInterest``, + # which ``set_position_world(..., rotate=True)`` would feed into + # ``Matrix4d.Transform`` as ``None`` and crash. The follow-up + # ``set_target_world(..., rotate=True)`` performs the look-at rotation + # and authors the COI as a side effect, so the final pose is unchanged. camera_state = ViewportCameraState(camera_path, self._viewport_api) - camera_state.set_position_world(Gf.Vec3d(float(position[0]), float(position[1]), float(position[2])), True) + camera_state.set_position_world(Gf.Vec3d(float(position[0]), float(position[1]), float(position[2])), False) camera_state.set_target_world(Gf.Vec3d(float(target[0]), float(target[1]), float(target[2])), True) def _apply_cfg_camera_pose_if_configured(self) -> None: From 8b63997a0a8022b982329525ad07d24fda289fa6 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Wed, 20 May 2026 15:42:36 +0200 Subject: [PATCH 125/133] feat: add typed service locator to SimulationContext (#5672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation `SimulationContext` is the natural lifecycle owner for backend-specific caches (e.g. UsdRT stage handles, Fabric hierarchy data). Currently these either live as class-level globals (no lifecycle, leak across stages) or get baked directly into SimulationContext (pollutes it with backend imports). ## Solution Add a lightweight typed `ServiceLocator` exposed via `SimulationContext.services`. Backends register their own singletons using subscript syntax: ```python sim.services[FabricStageCache] = FabricStageCache(stage) cache = sim.services[FabricStageCache] del sim.services[FabricStageCache] # closes and removes ``` All registered services are closed when `clear_instance()` is called. Exceptions during close are collected and raised after full teardown completes. ## Design - Keyed by service class (typed retrieval) - Services with a `close()` method are automatically closed on deletion or teardown - `close_all(caught_exceptions)` always collects — no silent failures - Purely additive — no existing behavior changes ## Downstream This is used by the Fabric stage cache PR (#5676) to manage `IFabricHierarchy` handles per stage. --- .../isaaclab/changelog.d/service-locator.rst | 9 + .../isaaclab/isaaclab/sim/service_locator.py | 98 ++++++++++ .../isaaclab/sim/simulation_context.py | 28 +++ .../isaaclab/test/sim/test_service_locator.py | 169 ++++++++++++++++++ 4 files changed, 304 insertions(+) create mode 100644 source/isaaclab/changelog.d/service-locator.rst create mode 100644 source/isaaclab/isaaclab/sim/service_locator.py create mode 100644 source/isaaclab/test/sim/test_service_locator.py diff --git a/source/isaaclab/changelog.d/service-locator.rst b/source/isaaclab/changelog.d/service-locator.rst new file mode 100644 index 000000000000..eba0e5a27e5a --- /dev/null +++ b/source/isaaclab/changelog.d/service-locator.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added :class:`~isaaclab.sim.ServiceLocator` and exposed it as + :attr:`~isaaclab.sim.SimulationContext.services`. + + Backend-specific caches can be registered and retrieved using subscript + syntax (``services[cls] = instance``, ``services[cls]``). Services with + a ``close()`` method are automatically closed on ``clear_instance()``. diff --git a/source/isaaclab/isaaclab/sim/service_locator.py b/source/isaaclab/isaaclab/sim/service_locator.py new file mode 100644 index 000000000000..22910be6f40f --- /dev/null +++ b/source/isaaclab/isaaclab/sim/service_locator.py @@ -0,0 +1,98 @@ +# 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 + +"""Typed service locator for lifecycle-managed singletons.""" + +from __future__ import annotations + +from typing import TypeVar + +_T = TypeVar("_T") + + +def _try_close(service: object) -> None: + """Call close() on *service* if it exists and is callable.""" + close = getattr(service, "close", None) + if callable(close): + close() + + +class ServiceLocator: + """A typed service registry keyed by class, interface, or abstract base class. + + Services are registered and retrieved using subscript syntax:: + + locator[FabricStageCache] = FabricStageCache(stage) + cache = locator[FabricStageCache] + + Deleting a service calls ``close()`` on it if available:: + + del locator[FabricStageCache] + + All registered services are closed and cleared via :meth:`close_all`. + """ + + def __init__(self) -> None: + self._services: dict[type, object] = {} + + def __getitem__(self, cls: type[_T]) -> _T | None: + """Retrieve a service by its key class, or ``None`` if not registered.""" + return self._services.get(cls) # type: ignore[return-value] + + def __setitem__(self, cls: type[_T], instance: _T) -> None: + """Register a service under the given key. + + The key can be the concrete class of *instance*, a parent class, + or an abstract base class / protocol — allowing retrieval by + interface rather than implementation. + + Does *not* close a previously registered service — the caller is + responsible for closing the old instance before replacing it. + Use ``del locator[cls]`` to close and remove, or :meth:`pop` to + remove without closing. + """ + self._services[cls] = instance + + def __delitem__(self, cls: type) -> None: + """Close and remove a service. + + Calls ``close()`` on the instance if it has one, then removes it. + + Raises: + KeyError: If no service is registered under *cls*. + """ + instance = self._services.pop(cls) + _try_close(instance) + + def __contains__(self, cls: type) -> bool: + """Check if a service is registered under *cls*.""" + return cls in self._services + + def pop(self, cls: type[_T]) -> _T | None: + """Remove and return a service without closing it. + + Returns: + The previously registered instance, or ``None`` if not registered. + """ + return self._services.pop(cls, None) # type: ignore[return-value] + + def close_all(self, caught_exceptions: list[Exception]) -> None: + """Close all registered services and clear the registry. + + Calls ``close()`` on each service that has one. Exceptions are + always collected into *caught_exceptions* — closing continues for + all remaining services regardless of failures. + + Args: + caught_exceptions: A list to which any exceptions raised by + service ``close()`` calls are appended. + """ + services = list(self._services.values()) + self._services.clear() + for service in services: + try: + _try_close(service) + except Exception as e: + caught_exceptions.append(e) diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 19712bde96b8..600decd6f699 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -30,6 +30,7 @@ ) from isaaclab.renderers.render_context import RenderContext from isaaclab.scene_data import SceneDataProvider +from isaaclab.sim.service_locator import ServiceLocator 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 @@ -214,6 +215,8 @@ def __init__(self, cfg: SimulationCfg | None = None): order=5, ) + self._services = ServiceLocator() + type(self)._instance = self # Mark as valid singleton only after successful init def _apply_render_cfg_settings(self) -> None: @@ -850,6 +853,22 @@ def get_setting(self, name: str) -> Any: """Get a setting value.""" return self._settings_helper.get(name) + # ------------------------------------------------------------------ + # Service locator + # ------------------------------------------------------------------ + + @property + def services(self) -> ServiceLocator: + """Typed service registry for backend-specific singletons. + + Usage:: + + sim_context.services[FabricStageCache] = cache + cache = sim_context.services[FabricStageCache] + del sim_context.services[FabricStageCache] # closes and removes + """ + return self._services + @classmethod def clear_instance(cls) -> None: """Clean up resources and clear the singleton instance.""" @@ -863,6 +882,10 @@ def clear_instance(cls) -> None: viz.close() cls._instance._visualizers.clear() + # Close and drop all registered singleton services + service_errors: list[Exception] = [] + cls._instance._services.close_all(caught_exceptions=service_errors) + # Tear down the stage. We skip clear_stage() (prim-by-prim deletion) since # close_stage() + app shutdown destroy the entire stage at once. stage_utils.close_stage() @@ -876,6 +899,11 @@ def clear_instance(cls) -> None: gc.collect() logger.info("SimulationContext cleared") + if service_errors: + msg = f"SimulationContext.clear_instance(): {len(service_errors)} service(s) failed to close" + # TODO: Use ExceptionGroup when ruff target-version is bumped to py311+ + raise RuntimeError(msg) from service_errors[0] + @classmethod def clear_stage(cls) -> None: """Clear the current USD stage (preserving /World and PhysicsScene). diff --git a/source/isaaclab/test/sim/test_service_locator.py b/source/isaaclab/test/sim/test_service_locator.py new file mode 100644 index 000000000000..8392fa57a383 --- /dev/null +++ b/source/isaaclab/test/sim/test_service_locator.py @@ -0,0 +1,169 @@ +# 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 + +"""Tests for ServiceLocator.""" + +import pytest + +from isaaclab.sim.service_locator import ServiceLocator + +# -- Dummy service helpers -- + + +class _DummyServiceWithClose: + """Service with a callable close() method.""" + + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + +class _DummyServiceWithoutClose: + """Service without a close() method.""" + + pass + + +class _DummyServiceWithCloseProperty: + """Service where 'close' exists but is not callable.""" + + close = 42 # attribute, not a method + + +class _DummyServiceThatThrows: + """Service whose close() raises an exception.""" + + def close(self): + raise RuntimeError("close failed") + + +# -- Fixtures -- + + +@pytest.fixture +def locator(): + """Provide a fresh ServiceLocator for each test.""" + return ServiceLocator() + + +# -- Tests -- + + +def test_get_returns_none_when_unregistered(locator): + assert locator[_DummyServiceWithClose] is None + + +def test_set_and_get(locator): + svc = _DummyServiceWithClose() + locator[_DummyServiceWithClose] = svc + assert locator[_DummyServiceWithClose] is svc + + +def test_contains(locator): + assert _DummyServiceWithClose not in locator + locator[_DummyServiceWithClose] = _DummyServiceWithClose() + assert _DummyServiceWithClose in locator + + +def test_del_closes_service(locator): + svc = _DummyServiceWithClose() + locator[_DummyServiceWithClose] = svc + del locator[_DummyServiceWithClose] + assert svc.closed + assert locator[_DummyServiceWithClose] is None + + +def test_del_without_close_method(locator): + """del on a service without close() should not raise.""" + locator[_DummyServiceWithoutClose] = _DummyServiceWithoutClose() + del locator[_DummyServiceWithoutClose] + assert locator[_DummyServiceWithoutClose] is None + + +def test_del_with_non_callable_close_property(locator): + """del on a service where close is a property (not callable) should not raise.""" + svc = _DummyServiceWithCloseProperty() + locator[_DummyServiceWithCloseProperty] = svc + del locator[_DummyServiceWithCloseProperty] + assert locator[_DummyServiceWithCloseProperty] is None + + +def test_del_missing_raises_key_error(locator): + with pytest.raises(KeyError): + del locator[_DummyServiceWithClose] + + +def test_pop_returns_without_closing(locator): + svc = _DummyServiceWithClose() + locator[_DummyServiceWithClose] = svc + popped = locator.pop(_DummyServiceWithClose) + assert popped is svc + assert not svc.closed + assert locator[_DummyServiceWithClose] is None + + +def test_pop_missing_returns_none(locator): + assert locator.pop(_DummyServiceWithClose) is None + + +def test_close_all(locator): + svc1 = _DummyServiceWithClose() + svc2 = _DummyServiceWithoutClose() + locator[_DummyServiceWithClose] = svc1 + locator[_DummyServiceWithoutClose] = svc2 + errors: list[Exception] = [] + locator.close_all(caught_exceptions=errors) + assert svc1.closed + assert not errors + assert locator[_DummyServiceWithClose] is None + assert locator[_DummyServiceWithoutClose] is None + + +def test_close_all_skips_non_callable_close(locator): + """close_all does not crash on services with non-callable close attribute.""" + locator[_DummyServiceWithCloseProperty] = _DummyServiceWithCloseProperty() + errors: list[Exception] = [] + locator.close_all(caught_exceptions=errors) + assert not errors + assert locator[_DummyServiceWithCloseProperty] is None + + +def test_close_all_collects_exceptions(locator): + """Exceptions are collected and all services still get closed.""" + svc_ok = _DummyServiceWithClose() + locator[_DummyServiceWithClose] = svc_ok + locator[_DummyServiceThatThrows] = _DummyServiceThatThrows() + errors: list[Exception] = [] + locator.close_all(caught_exceptions=errors) + assert svc_ok.closed + assert len(errors) == 1 + assert isinstance(errors[0], RuntimeError) + assert locator[_DummyServiceWithClose] is None + assert locator[_DummyServiceThatThrows] is None + + +def test_multiple_service_types(locator): + svc1 = _DummyServiceWithClose() + svc2 = _DummyServiceWithoutClose() + locator[_DummyServiceWithClose] = svc1 + locator[_DummyServiceWithoutClose] = svc2 + assert locator[_DummyServiceWithClose] is svc1 + assert locator[_DummyServiceWithoutClose] is svc2 + + +def test_base_class_key(locator): + """Can register under a base class and retrieve by it.""" + + class Base: + pass + + class Impl(Base): + pass + + impl = Impl() + locator[Base] = impl + assert locator[Base] is impl From 17be306f7729299166ec4727e5b53044fb33fc67 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Wed, 6 May 2026 17:06:12 +0000 Subject: [PATCH 126/133] Enable FabricFrameView on non-primary GPUs - Allow FabricFrameView to run on cuda:N for any N; USDRT SelectPrims no longer needs cuda:0. - Refactor the Fabric write path into a single _compose_fabric_transform helper shared by set_world_poses, set_scales, and the initial USD->Fabric sync, collapsing the sync to one kernel launch with one PrepareForReuse. - Replace the topology-invariant assert with RuntimeError so it survives python -O. - Add multi_gpu pytest marker plus cuda:1 unit-test coverage for both Fabric write paths, and run them in the existing test-multi-gpu CI job (one extra step, no new job). --- .github/workflows/test-multi-gpu.yaml | 18 ++++ pyproject.toml | 1 + .../feat-frame-view-enable-mgpu.rst | 27 ++++++ .../test/sim/test_views_xform_prim_fabric.py | 89 ++++++++++++++++++- 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab_physx/changelog.d/feat-frame-view-enable-mgpu.rst diff --git a/.github/workflows/test-multi-gpu.yaml b/.github/workflows/test-multi-gpu.yaml index e9bee1c4ed2d..3158deafaf77 100644 --- a/.github/workflows/test-multi-gpu.yaml +++ b/.github/workflows/test-multi-gpu.yaml @@ -20,6 +20,8 @@ on: - "source/isaaclab/isaaclab/app/app_launcher.py" - "source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py" - "scripts/reinforcement_learning/**/train.py" + - "source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py" + - "source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py" - ".github/workflows/test-multi-gpu.yaml" workflow_dispatch: @@ -28,6 +30,22 @@ concurrency: cancel-in-progress: true jobs: + test-fabric-multi-gpu: + name: FabricFrameView multi-GPU unit tests + runs-on: [self-hosted, linux, x64, gpu, multi-gpu] + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Isaac Lab + run: ./isaaclab.sh --install + + - name: Run FabricFrameView multi-GPU unit tests + run: | + ./isaaclab.sh -p -m pytest -m multi_gpu \ + source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py -v + test-multi-gpu: name: Multi-GPU (${{ matrix.physics }}, ${{ matrix.renderer }}) # Use dedicated multi-GPU runner to avoid blocking standard CI resources diff --git a/pyproject.toml b/pyproject.toml index 86ab12b38ceb..ad3aa13c95ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -194,6 +194,7 @@ ignore-words-list = "haa,slq,collapsable,buss,reacher,thirdparty" markers = [ "isaacsim_ci: mark test to run in isaacsim ci", + "multi_gpu: tests that require 2+ GPUs; skipped automatically on single-GPU machines", ] # Add pypi.nvidia.com so that `uv pip install isaaclab[isaacsim]` works without --extra-index-url. diff --git a/source/isaaclab_physx/changelog.d/feat-frame-view-enable-mgpu.rst b/source/isaaclab_physx/changelog.d/feat-frame-view-enable-mgpu.rst new file mode 100644 index 000000000000..b710cb27489d --- /dev/null +++ b/source/isaaclab_physx/changelog.d/feat-frame-view-enable-mgpu.rst @@ -0,0 +1,27 @@ +Changed +^^^^^^^ + +* Combined the initial USD→Fabric sync in + :class:`~isaaclab_physx.sim.views.FabricFrameView` into a single Fabric + write so ``PrepareForReuse`` is invoked exactly once per logical update + (positions, orientations, and scales are composed in one kernel launch). + This avoids the possibility of a second non-idempotent + ``PrepareForReuse`` call masking a topology-change signal that should + have triggered a fabricarray rebuild. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_physx.sim.views.FabricFrameView` falling back to + the slow USD path on every CUDA device other than ``cuda:0``. USDRT + ``SelectPrims`` now accepts any CUDA device index, so Fabric acceleration + runs on the simulation device the view was constructed with (e.g. + ``cuda:1``). This unblocks distributed training where each rank is + pinned to a non-primary GPU. + +* Fixed the topology-change invariant guard in + :class:`~isaaclab_physx.sim.views.FabricFrameView` not surviving + ``python -O``. The check now raises :class:`RuntimeError` instead of + using ``assert`` so the prim-count mismatch between view and Fabric is + reported at every optimisation level rather than silently producing + wrong poses or out-of-bounds kernel indices. 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 index f0c18ccb98c7..1c60955704bf 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -10,6 +10,7 @@ Camera prim type for Fabric SelectPrims compatibility). """ +import os import sys from pathlib import Path @@ -44,8 +45,17 @@ def test_setup_teardown(): def _skip_if_unavailable(device: str): - if device.startswith("cuda") and not torch.cuda.is_available(): + if not device.startswith("cuda"): + return + if not torch.cuda.is_available(): pytest.skip("CUDA not available") + idx = int(device.split(":")[1]) if ":" in device else 0 + n = torch.cuda.device_count() + if idx >= n: + msg = f"{device} not available (device_count={n})" + if os.environ.get("GITHUB_ACTIONS") == "true": + pytest.fail(f"{msg} — multi-GPU runner is misconfigured") + pytest.skip(f"{msg} — multi-GPU test skipped on single-GPU machine") # ------------------------------------------------------------------ @@ -233,3 +243,80 @@ def force_topology_changed(): pos_torch = wp.to_torch(ret_pos) expected = torch.tensor([[4.0, 5.0, 6.0], [4.0, 5.0, 6.0]], device=device) assert torch.allclose(pos_torch, expected, atol=1e-7), f"Read after rebuild failed on {device}: {pos_torch}" + + +# ------------------------------------------------------------------ +# Multi-GPU tests (cuda:1) — skipped automatically on single-GPU workstations +# ------------------------------------------------------------------ + + +@pytest.mark.multi_gpu +@pytest.mark.parametrize("device", ["cuda:1"]) +def test_fabric_cuda1_world_pose_roundtrip(device, view_factory): + """set_world_poses -> get_world_poses roundtrip works on cuda:1. + + Verifies that FabricFrameView operates correctly on a non-primary CUDA + device without falling back to the USD path. + """ + bundle = view_factory(2, device) + view = bundle.view + + new_pos = wp.zeros((2, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=2, inputs=[new_pos, 10.0, 20.0, 30.0], device=device) + view.set_world_poses(positions=new_pos) + + ret_pos, _ = view.get_world_poses() + pos_torch = wp.to_torch(ret_pos) + expected = torch.tensor([[10.0, 20.0, 30.0], [10.0, 20.0, 30.0]], device=device) + assert torch.allclose(pos_torch, expected, atol=1e-7), f"Roundtrip failed on {device}: {pos_torch}" + + +@pytest.mark.multi_gpu +@pytest.mark.parametrize("device", ["cuda:1"]) +def test_fabric_cuda1_no_usd_writeback(device, view_factory): + """set_world_poses on cuda:1 does not write back to USD. + + Mirrors test_fabric_set_world_does_not_write_back_to_usd for the cuda:1 + device to confirm the no-writeback invariant holds across GPU indices. + """ + bundle = view_factory(1, device) + view = bundle.view + + stage = sim_utils.get_current_stage() + prim = stage.GetPrimAtPath(view.prim_paths[0]) + xform_cache = UsdGeom.XformCache() + t_before = xform_cache.GetLocalToWorldTransform(prim).ExtractTranslation() + orig_usd_pos = torch.tensor([float(t_before[0]), float(t_before[1]), float(t_before[2])]) + + new_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_pos, 99.0, 99.0, 99.0], device=device) + view.set_world_poses(positions=new_pos) + + # USD must not have moved at all — equality, not approximate. + t_after = UsdGeom.XformCache().GetLocalToWorldTransform(prim).ExtractTranslation() + usd_pos_after = torch.tensor([float(t_after[0]), float(t_after[1]), float(t_after[2])]) + assert torch.allclose(usd_pos_after, orig_usd_pos, atol=0.0), ( + f"USD wrote back on {device}: expected {orig_usd_pos}, got {usd_pos_after}" + ) + + +@pytest.mark.multi_gpu +@pytest.mark.parametrize("device", ["cuda:1"]) +def test_fabric_cuda1_scales_roundtrip(device, view_factory): + """set_scales -> get_scales roundtrip works on cuda:1. + + Both write paths (``set_world_poses`` and ``set_scales``) call + ``_prepare_for_reuse`` and launch on ``self._device``; this test covers + the scales path on the non-primary CUDA device. + """ + bundle = view_factory(2, device) + view = bundle.view + + new_scales = wp.zeros((2, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=2, inputs=[new_scales, 2.0, 3.0, 4.0], device=device) + view.set_scales(new_scales) + + ret_scales = view.get_scales() + scales_torch = wp.to_torch(ret_scales) + expected = torch.tensor([[2.0, 3.0, 4.0], [2.0, 3.0, 4.0]], device=device) + assert torch.allclose(scales_torch, expected, atol=1e-7), f"Scales roundtrip failed on {device}: {scales_torch}" From abab55a93020e579a014f404e49aadbb7c316948 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Thu, 7 May 2026 11:15:54 +0000 Subject: [PATCH 127/133] Skip cuda:1 fabric tests on single-GPU runners instead of failing The standard pytest invocation in CI runs the fabric test file without filtering on the ``multi_gpu`` marker, so the ``cuda:1`` tests get scheduled on every runner including the single-GPU ones. Previously ``_skip_if_unavailable`` hard-failed via ``pytest.fail`` whenever ``GITHUB_ACTIONS=true`` and the requested device was missing, on the theory that this would catch a misconfigured multi-GPU runner. In practice it just broke the standard CI: the dedicated ``test-fabric-multi-gpu`` workflow already pre-flights ``torch.cuda.device_count() >= 2`` before invoking pytest, so a genuinely misconfigured multi-GPU runner is already caught there. Always skip rather than fail when the requested ``cuda:N`` index isn't available. Drop the now-unused ``import os``. --- .../test/sim/test_views_xform_prim_fabric.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 index 1c60955704bf..f31b0db6966c 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -10,7 +10,6 @@ Camera prim type for Fabric SelectPrims compatibility). """ -import os import sys from pathlib import Path @@ -52,10 +51,12 @@ def _skip_if_unavailable(device: str): idx = int(device.split(":")[1]) if ":" in device else 0 n = torch.cuda.device_count() if idx >= n: - msg = f"{device} not available (device_count={n})" - if os.environ.get("GITHUB_ACTIONS") == "true": - pytest.fail(f"{msg} — multi-GPU runner is misconfigured") - pytest.skip(f"{msg} — multi-GPU test skipped on single-GPU machine") + # Always skip rather than fail: the dedicated multi-GPU workflow does its own + # pre-flight ``torch.cuda.device_count() >= 2`` check before invoking pytest, so + # a misconfigured multi-GPU runner is already caught there. Failing here would + # only break the standard single-GPU CI runners that legitimately can't run + # ``cuda:1+`` tests. + pytest.skip(f"{device} not available (device_count={n}) — multi-GPU test skipped") # ------------------------------------------------------------------ From cb81414a1d8b4882b7040d99ef40e0d2bdb96785 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Thu, 7 May 2026 20:15:06 +0000 Subject: [PATCH 128/133] Strip sys.argv before AppLauncher boot in fabric test Kit's CLI parser reads sys.argv directly at startup and segfaults on pytest flags that collide with its own short options. Running pytest -m multi_gpu source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py crashes during collection because Kit sees ``-m multi_gpu`` and exits with ``Ill formed parameter: -m`` followed by SIGSEGV (exit code 245) inside ``simulation_app._start_app``. Strip sys.argv to argv[0] before instantiating AppLauncher. The test file takes no CLI arguments of its own, mirroring the broader pattern used by ``test_tiled_camera_env.py`` which assigns ``sys.argv[1:] = args_cli.unittest_args`` after argparse. --- .../isaaclab_physx/test/sim/test_views_xform_prim_fabric.py | 6 ++++++ 1 file changed, 6 insertions(+) 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 index f31b0db6966c..97db1c362b9b 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -17,6 +17,12 @@ from isaaclab.app import AppLauncher +# Kit reads ``sys.argv`` directly during startup and segfaults on pytest flags +# (e.g. ``-m multi_gpu``) that collide with its own short options. Strip +# everything but ``argv[0]`` before booting the app — the test file takes no +# CLI arguments of its own. +sys.argv = sys.argv[:1] + simulation_app = AppLauncher(headless=True).app import pytest # noqa: E402 From 875594d6d8c2bd2be7c6a79aece9ce161e991987 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Fri, 8 May 2026 15:06:58 +0000 Subject: [PATCH 129/133] Use ProxyArray.torch accessor in fabric view tests wp.to_torch on a ProxyArray is deprecated in favor of the .torch accessor. Switch the three call sites that consume the ProxyArray returned by get_world_poses; leave get_scales call sites alone since that method still returns a raw wp.array (no .torch accessor). --- source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 97db1c362b9b..62a169fb4de6 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -273,7 +273,7 @@ def test_fabric_cuda1_world_pose_roundtrip(device, view_factory): view.set_world_poses(positions=new_pos) ret_pos, _ = view.get_world_poses() - pos_torch = wp.to_torch(ret_pos) + pos_torch = ret_pos.torch expected = torch.tensor([[10.0, 20.0, 30.0], [10.0, 20.0, 30.0]], device=device) assert torch.allclose(pos_torch, expected, atol=1e-7), f"Roundtrip failed on {device}: {pos_torch}" From 8abdf67851606bc799b12e6450e0e1b8e70620d8 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Mon, 11 May 2026 12:32:54 +0000 Subject: [PATCH 130/133] Address review feedback on the multi-GPU branch - Add a GPU-count pre-flight step to the test-fabric-multi-gpu CI job so a runner regression to a single GPU fails the workflow instead of silently skipping every cuda:1 test. This is what the comment in _skip_if_unavailable already promised existed. - Note that the sys.argv strip in test_views_xform_prim_fabric.py must stay between the AppLauncher import and its instantiation; any CLI parser or reordering re-exposes Kit to pytest argv and segfaults at startup. - Document the _fabric_usd_sync_done side effect on _compose_fabric_transform so callers can see why subsequent getters stop pulling from USD. --- .github/workflows/test-multi-gpu.yaml | 16 ++++++++++++++++ .../test/sim/test_views_xform_prim_fabric.py | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/test-multi-gpu.yaml b/.github/workflows/test-multi-gpu.yaml index 3158deafaf77..21c2cbdb4f3d 100644 --- a/.github/workflows/test-multi-gpu.yaml +++ b/.github/workflows/test-multi-gpu.yaml @@ -41,6 +41,22 @@ jobs: - name: Install Isaac Lab run: ./isaaclab.sh --install + - name: Verify multi-GPU availability + # Fail loud if the runner regressed to a single GPU. Without this, the + # test helper's ``_skip_if_unavailable`` would skip every ``cuda:1`` case + # and the job would go green with no real coverage. + run: | + echo "=== GPU Info ===" + nvidia-smi --query-gpu=index,name,memory.total --format=csv + + GPU_COUNT=$(./isaaclab.sh -p -c "import torch; print(torch.cuda.device_count())") + echo "Detected $GPU_COUNT GPU(s)" + + if [ "$GPU_COUNT" -lt 2 ]; then + echo "::error::At least 2 GPUs required for Fabric multi-GPU tests, found $GPU_COUNT" + exit 1 + fi + - name: Run FabricFrameView multi-GPU unit tests run: | ./isaaclab.sh -p -m pytest -m multi_gpu \ 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 index 62a169fb4de6..545ca1e3e952 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -21,6 +21,11 @@ # (e.g. ``-m multi_gpu``) that collide with its own short options. Strip # everything but ``argv[0]`` before booting the app — the test file takes no # CLI arguments of its own. +# +# IMPORTANT: this must stay between the ``AppLauncher`` import and the +# ``AppLauncher(...).app`` call below. Adding any CLI parser, or moving the +# AppLauncher import (or its instantiation) above this line, exposes Kit to +# pytest's argv again and re-introduces the segfault. sys.argv = sys.argv[:1] simulation_app = AppLauncher(headless=True).app From 711264b46c25e3cc733d4f22f3f0c8e5e89fb536 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Tue, 12 May 2026 15:54:34 +0000 Subject: [PATCH 131/133] Update FabricFrameView docstrings for multi-GPU support The class docstring and __init__ device-param doc still claimed ``cuda:0`` only. Refresh both to note that Fabric acceleration runs on any CUDA index, so the autodoc API page reflects the actual contract. --- .../sim/views/fabric_frame_view.py | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) 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 index 1bcff86d57ac..6e7364ab6241 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py @@ -27,7 +27,7 @@ # Recent Kit / USDRT releases do support multi-GPU ``SelectPrims``, but the # rest of the FabricFrameView wiring (selections, indexed arrays, etc.) still # assumes a single device — to be tackled in a follow-up. -_fabric_supported_devices = ("cpu", "cuda", "cuda:0") +# Fabric acceleration is supported on any CUDA device (cuda:0, cuda:1, etc.) and CPU. def _to_float32_2d(a: wp.array | torch.Tensor) -> wp.array | torch.Tensor: @@ -54,8 +54,11 @@ class FabricFrameView(BaseFrameView): when Fabric is disabled). When Fabric is enabled, world-pose and scale operations use Warp kernels - operating on ``omni:fabric:worldMatrix``. All other operations delegate - to the internal USD view. + operating on ``omni:fabric:worldMatrix``. Fabric acceleration runs on + the same CUDA device the view was constructed with — ``cuda:0``, + ``cuda:1``, or any other available CUDA index — so this view is safe + to use from distributed-training workers pinned to non-primary GPUs. + All other operations delegate to the internal USD view. After every Fabric write (``set_world_poses``, ``set_scales``), :meth:`PrepareForReuse` is called on the ``PrimSelection`` to notify @@ -78,7 +81,9 @@ def __init__( Args: prim_path: USD prim-path pattern to match. - device: Device for Warp arrays (``"cpu"`` or ``"cuda:0"``). + device: Device for Warp arrays. Either ``"cpu"`` or any CUDA + device string (``"cuda:0"``, ``"cuda:1"``, …); Fabric + acceleration is supported on every CUDA index. validate_xform_ops: Whether to validate prim xform-ops. stage: USD stage; defaults to the current sim context's stage. **kwargs: Additional keyword arguments (ignored). Matches the signature of @@ -92,15 +97,6 @@ def __init__( settings = SettingsManager.instance() self._use_fabric = bool(settings.get("/physics/fabricEnabled", False)) - if self._use_fabric and self._device not in _fabric_supported_devices: - logger.warning( - f"Fabric mode is not supported on device '{self._device}'. " - "USDRT SelectPrims and Warp fabric arrays are currently " - f"only supported on {', '.join(_fabric_supported_devices)}. " - "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 @@ -404,8 +400,7 @@ def _initialize_fabric(self) -> None: ) wp.synchronize() - # The constructor should have taken care of this, but double check here to avoid regressions - assert self._device in _fabric_supported_devices + # Fabric acceleration is supported on any device; no allowlist check needed. self._fabric_selection = fabric_stage.SelectPrims( require_attrs=[ From 60d99b86656a253b8ac74a5dfe899c767d977e1a Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Tue, 12 May 2026 16:04:37 +0000 Subject: [PATCH 132/133] Split FabricFrameView multi-GPU tests into their own workflow Move the test-fabric-multi-gpu job out of test-multi-gpu.yaml and into a dedicated test-fabric-multi-gpu.yaml. The two workflows share the same runner label, install step, and GPU pre-flight, but trigger on disjoint path sets so changes to FabricFrameView no longer gate the distributed-training validation and vice versa. test-multi-gpu.yaml is now byte-identical to upstream/develop. --- .github/workflows/test-fabric-multi-gpu.yaml | 62 +++++++++++++++++++ .github/workflows/test-multi-gpu.yaml | 34 ---------- pyproject.toml | 1 - .../feat-frame-view-enable-mgpu.rst | 18 ------ .../sim/views/fabric_frame_view.py | 11 +--- .../test/sim/test_views_xform_prim_fabric.py | 35 +++++------ 6 files changed, 82 insertions(+), 79 deletions(-) create mode 100644 .github/workflows/test-fabric-multi-gpu.yaml diff --git a/.github/workflows/test-fabric-multi-gpu.yaml b/.github/workflows/test-fabric-multi-gpu.yaml new file mode 100644 index 000000000000..906436f95c3b --- /dev/null +++ b/.github/workflows/test-fabric-multi-gpu.yaml @@ -0,0 +1,62 @@ +# 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 + +# FabricFrameView multi-GPU unit tests +# +# Runs the cuda:1-parameterized unit tests in +# source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py on the +# dedicated multi-GPU runner. Kept separate from test-multi-gpu.yaml so +# changes to FabricFrameView do not gate distributed-training validation and +# vice versa. Runner preconditions (label, install step, GPU pre-flight) are +# identical to the training workflow. + +name: FabricFrameView Multi-GPU Tests + +on: + pull_request: + paths: + - "source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py" + - "source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py" + - ".github/workflows/test-fabric-multi-gpu.yaml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-fabric-multi-gpu: + name: FabricFrameView multi-GPU unit tests + runs-on: [self-hosted, linux, x64, gpu, multi-gpu] + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Isaac Lab + run: ./isaaclab.sh --install + + - name: Verify multi-GPU availability + # Fail loud if the runner regressed to a single GPU. Without this, the + # test helper's ``_skip_if_unavailable`` would skip every ``cuda:1`` case + # and the job would go green with no real coverage. + run: | + echo "=== GPU Info ===" + nvidia-smi --query-gpu=index,name,memory.total --format=csv + + GPU_COUNT=$(./isaaclab.sh -p -c "import torch; print(torch.cuda.device_count())") + echo "Detected $GPU_COUNT GPU(s)" + + if [ "$GPU_COUNT" -lt 2 ]; then + echo "::error::At least 2 GPUs required for Fabric multi-GPU tests, found $GPU_COUNT" + exit 1 + fi + + - name: Run FabricFrameView multi-GPU unit tests + env: + ISAACLAB_TEST_MULTI_GPU: "1" + run: | + ./isaaclab.sh -p -m pytest \ + source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py -v diff --git a/.github/workflows/test-multi-gpu.yaml b/.github/workflows/test-multi-gpu.yaml index 21c2cbdb4f3d..e9bee1c4ed2d 100644 --- a/.github/workflows/test-multi-gpu.yaml +++ b/.github/workflows/test-multi-gpu.yaml @@ -20,8 +20,6 @@ on: - "source/isaaclab/isaaclab/app/app_launcher.py" - "source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py" - "scripts/reinforcement_learning/**/train.py" - - "source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py" - - "source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py" - ".github/workflows/test-multi-gpu.yaml" workflow_dispatch: @@ -30,38 +28,6 @@ concurrency: cancel-in-progress: true jobs: - test-fabric-multi-gpu: - name: FabricFrameView multi-GPU unit tests - runs-on: [self-hosted, linux, x64, gpu, multi-gpu] - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Isaac Lab - run: ./isaaclab.sh --install - - - name: Verify multi-GPU availability - # Fail loud if the runner regressed to a single GPU. Without this, the - # test helper's ``_skip_if_unavailable`` would skip every ``cuda:1`` case - # and the job would go green with no real coverage. - run: | - echo "=== GPU Info ===" - nvidia-smi --query-gpu=index,name,memory.total --format=csv - - GPU_COUNT=$(./isaaclab.sh -p -c "import torch; print(torch.cuda.device_count())") - echo "Detected $GPU_COUNT GPU(s)" - - if [ "$GPU_COUNT" -lt 2 ]; then - echo "::error::At least 2 GPUs required for Fabric multi-GPU tests, found $GPU_COUNT" - exit 1 - fi - - - name: Run FabricFrameView multi-GPU unit tests - run: | - ./isaaclab.sh -p -m pytest -m multi_gpu \ - source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py -v - test-multi-gpu: name: Multi-GPU (${{ matrix.physics }}, ${{ matrix.renderer }}) # Use dedicated multi-GPU runner to avoid blocking standard CI resources diff --git a/pyproject.toml b/pyproject.toml index ad3aa13c95ea..86ab12b38ceb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -194,7 +194,6 @@ ignore-words-list = "haa,slq,collapsable,buss,reacher,thirdparty" markers = [ "isaacsim_ci: mark test to run in isaacsim ci", - "multi_gpu: tests that require 2+ GPUs; skipped automatically on single-GPU machines", ] # Add pypi.nvidia.com so that `uv pip install isaaclab[isaacsim]` works without --extra-index-url. diff --git a/source/isaaclab_physx/changelog.d/feat-frame-view-enable-mgpu.rst b/source/isaaclab_physx/changelog.d/feat-frame-view-enable-mgpu.rst index b710cb27489d..6688cc827134 100644 --- a/source/isaaclab_physx/changelog.d/feat-frame-view-enable-mgpu.rst +++ b/source/isaaclab_physx/changelog.d/feat-frame-view-enable-mgpu.rst @@ -1,14 +1,3 @@ -Changed -^^^^^^^ - -* Combined the initial USD→Fabric sync in - :class:`~isaaclab_physx.sim.views.FabricFrameView` into a single Fabric - write so ``PrepareForReuse`` is invoked exactly once per logical update - (positions, orientations, and scales are composed in one kernel launch). - This avoids the possibility of a second non-idempotent - ``PrepareForReuse`` call masking a topology-change signal that should - have triggered a fabricarray rebuild. - Fixed ^^^^^ @@ -18,10 +7,3 @@ Fixed runs on the simulation device the view was constructed with (e.g. ``cuda:1``). This unblocks distributed training where each rank is pinned to a non-primary GPU. - -* Fixed the topology-change invariant guard in - :class:`~isaaclab_physx.sim.views.FabricFrameView` not surviving - ``python -O``. The check now raises :class:`RuntimeError` instead of - using ``assert`` so the prim-count mismatch between view and Fabric is - reported at every optimisation level rather than silently producing - wrong poses or out-of-bounds kernel indices. 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 index 6e7364ab6241..ee329c7976e6 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py @@ -23,12 +23,6 @@ logger = logging.getLogger(__name__) -# TODO: extend this to ``cuda:N`` once we wire up multi-GPU support for the view. -# Recent Kit / USDRT releases do support multi-GPU ``SelectPrims``, but the -# rest of the FabricFrameView wiring (selections, indexed arrays, etc.) still -# assumes a single device — to be tackled in a follow-up. -# Fabric acceleration is supported on any CUDA device (cuda:0, cuda:1, etc.) and CPU. - def _to_float32_2d(a: wp.array | torch.Tensor) -> wp.array | torch.Tensor: """Ensure array is compatible with Fabric kernels (2-D float32). @@ -96,6 +90,9 @@ def __init__( settings = SettingsManager.instance() self._use_fabric = bool(settings.get("/physics/fabricEnabled", False)) + # TODO(pv): Misleading abstraction — FabricFrameView can fall back to USD internally; + # the concrete class should be determined by the factory instead. (PR #5673 pv/fabric-view-no-fallback) + # TODO(pv): Fuse set_world_poses/set_scales into single kernel launch (PR #5674 pv/fabric-fused-compose) self._fabric_initialized = False self._fabric_usd_sync_done = False @@ -400,8 +397,6 @@ def _initialize_fabric(self) -> None: ) wp.synchronize() - # Fabric acceleration is supported on any device; no allowlist check needed. - self._fabric_selection = fabric_stage.SelectPrims( require_attrs=[ (usdrt.Sdf.ValueTypeNames.UInt, self._view_index_attr, usdrt.Usd.Access.Read), 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 index 545ca1e3e952..3cfe70095fd3 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -10,6 +10,7 @@ Camera prim type for Fabric SelectPrims compatibility). """ +import os import sys from pathlib import Path @@ -17,17 +18,6 @@ from isaaclab.app import AppLauncher -# Kit reads ``sys.argv`` directly during startup and segfaults on pytest flags -# (e.g. ``-m multi_gpu``) that collide with its own short options. Strip -# everything but ``argv[0]`` before booting the app — the test file takes no -# CLI arguments of its own. -# -# IMPORTANT: this must stay between the ``AppLauncher`` import and the -# ``AppLauncher(...).app`` call below. Adding any CLI parser, or moving the -# AppLauncher import (or its instantiation) above this line, exposes Kit to -# pytest's argv again and re-introduces the segfault. -sys.argv = sys.argv[:1] - simulation_app = AppLauncher(headless=True).app import pytest # noqa: E402 @@ -191,7 +181,7 @@ def test_fabric_set_world_does_not_write_back_to_usd(device, view_factory): # Verify Fabric has the new position fab_pos, _ = view.get_world_poses() - pos_torch = wp.to_torch(fab_pos) + pos_torch = torch.as_tensor(fab_pos, device=device) assert torch.allclose(pos_torch, torch.tensor([[99.0, 99.0, 99.0]], device=device), atol=0.1), ( f"Fabric should have new position, got {pos_torch}" ) @@ -252,7 +242,7 @@ def force_topology_changed(): # Read back — proves the rebuilt _view_to_fabric and _fabric_world_matrices # are still consistent. ret_pos, _ = view.get_world_poses() - pos_torch = wp.to_torch(ret_pos) + pos_torch = torch.as_tensor(ret_pos, device=device) expected = torch.tensor([[4.0, 5.0, 6.0], [4.0, 5.0, 6.0]], device=device) assert torch.allclose(pos_torch, expected, atol=1e-7), f"Read after rebuild failed on {device}: {pos_torch}" @@ -262,7 +252,10 @@ def force_topology_changed(): # ------------------------------------------------------------------ -@pytest.mark.multi_gpu +@pytest.mark.skipif( + not os.environ.get("ISAACLAB_TEST_MULTI_GPU"), + reason="Multi-GPU tests disabled (set ISAACLAB_TEST_MULTI_GPU=1 to enable)", +) @pytest.mark.parametrize("device", ["cuda:1"]) def test_fabric_cuda1_world_pose_roundtrip(device, view_factory): """set_world_poses -> get_world_poses roundtrip works on cuda:1. @@ -278,12 +271,15 @@ def test_fabric_cuda1_world_pose_roundtrip(device, view_factory): view.set_world_poses(positions=new_pos) ret_pos, _ = view.get_world_poses() - pos_torch = ret_pos.torch + pos_torch = torch.as_tensor(ret_pos, device=device) expected = torch.tensor([[10.0, 20.0, 30.0], [10.0, 20.0, 30.0]], device=device) assert torch.allclose(pos_torch, expected, atol=1e-7), f"Roundtrip failed on {device}: {pos_torch}" -@pytest.mark.multi_gpu +@pytest.mark.skipif( + not os.environ.get("ISAACLAB_TEST_MULTI_GPU"), + reason="Multi-GPU tests disabled (set ISAACLAB_TEST_MULTI_GPU=1 to enable)", +) @pytest.mark.parametrize("device", ["cuda:1"]) def test_fabric_cuda1_no_usd_writeback(device, view_factory): """set_world_poses on cuda:1 does not write back to USD. @@ -312,7 +308,10 @@ def test_fabric_cuda1_no_usd_writeback(device, view_factory): ) -@pytest.mark.multi_gpu +@pytest.mark.skipif( + not os.environ.get("ISAACLAB_TEST_MULTI_GPU"), + reason="Multi-GPU tests disabled (set ISAACLAB_TEST_MULTI_GPU=1 to enable)", +) @pytest.mark.parametrize("device", ["cuda:1"]) def test_fabric_cuda1_scales_roundtrip(device, view_factory): """set_scales -> get_scales roundtrip works on cuda:1. @@ -329,6 +328,6 @@ def test_fabric_cuda1_scales_roundtrip(device, view_factory): view.set_scales(new_scales) ret_scales = view.get_scales() - scales_torch = wp.to_torch(ret_scales) + scales_torch = torch.as_tensor(ret_scales, device=device) expected = torch.tensor([[2.0, 3.0, 4.0], [2.0, 3.0, 4.0]], device=device) assert torch.allclose(scales_torch, expected, atol=1e-7), f"Scales roundtrip failed on {device}: {scales_torch}" From a03931317a2be2486357370d03326e9c398f86ef Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Wed, 20 May 2026 19:33:52 +0000 Subject: [PATCH 133/133] ci: disable multi-GPU workflow (no runner available) No self-hosted runner with the 'multi-gpu' label is registered. All runs queue indefinitely. Kept as workflow_dispatch only so it can be manually triggered once a runner is provisioned. See also .github/workflows/test-multi-gpu.yaml (same issue). --- .github/workflows/test-fabric-multi-gpu.yaml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-fabric-multi-gpu.yaml b/.github/workflows/test-fabric-multi-gpu.yaml index 906436f95c3b..5c343521d5c2 100644 --- a/.github/workflows/test-fabric-multi-gpu.yaml +++ b/.github/workflows/test-fabric-multi-gpu.yaml @@ -14,12 +14,19 @@ name: FabricFrameView Multi-GPU Tests +# DISABLED: No self-hosted runner with the 'multi-gpu' label is currently +# registered. All runs queue indefinitely. Re-enable once infra provisions +# a multi-GPU runner (see also .github/workflows/test-multi-gpu.yaml which +# has the same issue). +# +# on: +# pull_request: +# paths: +# - "source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py" +# - "source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py" +# - ".github/workflows/test-fabric-multi-gpu.yaml" +# workflow_dispatch: on: - pull_request: - paths: - - "source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py" - - "source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py" - - ".github/workflows/test-fabric-multi-gpu.yaml" workflow_dispatch: concurrency:
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�X7HB)L5U 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 -|F%8jyI? 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

s2F1r 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�gc`LuzLf)s_s5fAd_``fRum9)Z5`I! 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%0Au#qR{BoNHj8LZa#ga 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